12 ยท A Multi-Cloud Observability Hub for AWS, Azure and GCP

Code Repository / projects / 12-multi-cloud-observability-hub
Project 12 ยท AWS CDK (TypeScript)

One pane of glass for three clouds, built on OpenTelemetry, with no AWS keys stored on Azure or GCP.

README

Format: AWS CDK (TypeScript) + OpenTelemetry collector config ยท Est. cost: Managed Grafana $9/editor/month; AMP ~$0.90 per 10M samples ingested (small fleets: a few $/month)

One pane of glass for AWS, Azure and GCP, built on open standards so no vendor lock-in:

 AWS (EC2/ECS/EKS)          Azure VMs                     GCP VMs
 ADOT collector             otelcol + Entra ID token      otelcol + Google ID token
     โ”‚ IAM role                 โ”‚ OIDC โ†’ STS                  โ”‚ web identity โ†’ STS     (no stored AWS keys)
     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ–ผ                        โ–ผ
      Amazon Managed Prometheus      X-Ray / CloudWatch Logs
      (recording + alert rules)              โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ–ผ
                 Amazon Managed Grafana (SSO)  โ—€โ”€โ”€ CloudWatch cross-account (OAM sink)

Every series carries a cloud_provider label, so one dashboard compares all three clouds side by side.

Deploy

npm install
npx cdk deploy \
  -c gcpServiceAccountUniqueId=123456789012345678901 \
  -c azureTenantId=<tenant-guid> -c azureAppId=<app-registration-client-id> -c azurePrincipalId=<managed-identity-object-id> \
  -c sourceAccountIds=111111111111,222222222222
  • Grafana needs IAM Identity Center. Use -c enableGrafana=false to skip it.
  • Each external cloud is optional; omit its context values to skip it.

Install the collector (any cloud)

# GCP / Azure VM
sudo cp collector/otel-collector-config.yaml /etc/otel/config.yaml
sudo cp collector/credential-process-gcp.sh /etc/otel/      # or -azure.sh
export CLOUD_PROVIDER=gcp AWS_REGION=us-east-1 AWS_CONFIG_FILE=/etc/otel/aws-config \
       REMOTE_WRITE_URL=<RemoteWriteUrl>
otelcol-contrib --config /etc/otel/config.yaml

On AWS, attach the observability-hub-collector managed policy to the instance/task role instead.

Prove it

In Grafana โ†’ Explore โ†’ Prometheus:

cloud:cpu_utilization:avg
count by (cloud_provider) (up)

You'll see one line per cloud. Stop a VM on any cloud and the HostDown alert fires within 5 minutes.

Clean up

npx cdk destroy

Files (9)

README.md ยท 55 lines
# 12 ยท Multi-Cloud Observability Hub

**Format:** AWS CDK (TypeScript) + OpenTelemetry collector config ยท **Est. cost:** Managed Grafana $9/editor/month; AMP ~$0.90 per 10M samples ingested (small fleets: a few $/month)

**One pane of glass for AWS, Azure and GCP**, built on open standards so no vendor lock-in:

```
 AWS (EC2/ECS/EKS)          Azure VMs                     GCP VMs
 ADOT collector             otelcol + Entra ID token      otelcol + Google ID token
     โ”‚ IAM role                 โ”‚ OIDC โ†’ STS                  โ”‚ web identity โ†’ STS     (no stored AWS keys)
     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ–ผ                        โ–ผ
      Amazon Managed Prometheus      X-Ray / CloudWatch Logs
      (recording + alert rules)              โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ–ผ
                 Amazon Managed Grafana (SSO)  โ—€โ”€โ”€ CloudWatch cross-account (OAM sink)
```
Every series carries a `cloud_provider` label, so one dashboard compares all three clouds side by side.

## Deploy
```bash
npm install
npx cdk deploy \
  -c gcpServiceAccountUniqueId=123456789012345678901 \
  -c azureTenantId=<tenant-guid> -c azureAppId=<app-registration-client-id> -c azurePrincipalId=<managed-identity-object-id> \
  -c sourceAccountIds=111111111111,222222222222
```
- Grafana needs **IAM Identity Center**. Use `-c enableGrafana=false` to skip it.
- Each external cloud is optional; omit its context values to skip it.

## Install the collector (any cloud)
```bash
# GCP / Azure VM
sudo cp collector/otel-collector-config.yaml /etc/otel/config.yaml
sudo cp collector/credential-process-gcp.sh /etc/otel/      # or -azure.sh
export CLOUD_PROVIDER=gcp AWS_REGION=us-east-1 AWS_CONFIG_FILE=/etc/otel/aws-config \
       REMOTE_WRITE_URL=<RemoteWriteUrl>
otelcol-contrib --config /etc/otel/config.yaml
```
On AWS, attach the `observability-hub-collector` managed policy to the instance/task role instead.

## Prove it
In Grafana โ†’ Explore โ†’ Prometheus:
```promql
cloud:cpu_utilization:avg
count by (cloud_provider) (up)
```
You'll see one line per cloud. Stop a VM on any cloud and the `HostDown` alert fires within 5 minutes.

## Clean up
```bash
npx cdk destroy
```
bin/app.ts ยท 17 lines
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { ObservabilityHubStack } from '../lib/observability-hub-stack';

const app = new cdk.App();
const ctx = (k: string) => app.node.tryGetContext(k) as string | undefined;

new ObservabilityHubStack(app, 'ObservabilityHub', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
  enableGrafana: ctx('enableGrafana') !== 'false', // needs IAM Identity Center
  gcpServiceAccountUniqueId: ctx('gcpServiceAccountUniqueId'),
  azure: ctx('azureTenantId') && ctx('azureAppId') && ctx('azurePrincipalId')
    ? { tenantId: ctx('azureTenantId')!, appId: ctx('azureAppId')!, principalId: ctx('azurePrincipalId')! }
    : undefined,
  sourceAccountIds: ctx('sourceAccountIds')?.split(',').filter(Boolean) ?? [],
});
lib/observability-hub-stack.ts ยท 168 lines
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as aps from 'aws-cdk-lib/aws-aps';
import * as grafana from 'aws-cdk-lib/aws-grafana';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as oam from 'aws-cdk-lib/aws-oam';

export interface ObservabilityHubStackProps extends cdk.StackProps {
  enableGrafana: boolean;
  /** GCP service account "uniqueId" (numeric) whose identity tokens may assume the writer role. */
  gcpServiceAccountUniqueId?: string;
  /** Azure Entra ID app registration + managed identity / service principal object ID. */
  azure?: { tenantId: string; appId: string; principalId: string };
  /** AWS accounts that share CloudWatch telemetry into this monitoring account. */
  sourceAccountIds: string[];
}

/**
 * One pane of glass for AWS + Azure + GCP, built on open standards:
 * OpenTelemetry collectors everywhere -> Amazon Managed Prometheus -> Amazon Managed Grafana.
 * External clouds authenticate with keyless OIDC federation (no AWS access keys on other clouds).
 */
export class ObservabilityHubStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ObservabilityHubStackProps) {
    super(scope, id, props);

    const amp = new aps.CfnWorkspace(this, 'Prometheus', { alias: 'multi-cloud-hub' });

    new aps.CfnRuleGroupsNamespace(this, 'Rules', {
      workspace: amp.attrArn,
      name: 'multi-cloud',
      data: [
        'groups:',
        '  - name: fleet',
        '    rules:',
        '      - record: cloud:cpu_utilization:avg',
        '        expr: avg by (cloud_provider) (1 - rate(system_cpu_time_seconds_total{state="idle"}[5m]))',
        '      - alert: HostDown',
        '        expr: up == 0',
        '        for: 5m',
        '        labels: { severity: critical }',
        '        annotations: { summary: "{{ $labels.instance }} ({{ $labels.cloud_provider }}) is down" }',
        '      - alert: HighCpu',
        '        expr: (1 - rate(system_cpu_time_seconds_total{state="idle"}[5m])) > 0.9',
        '        for: 10m',
        '        labels: { severity: warning }',
      ].join('\n'),
    });

    const otlpLogs = new logs.LogGroup(this, 'ExternalLogs', {
      logGroupName: '/observability-hub/external-clouds',
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const writerPermissions = new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({ actions: ['aps:RemoteWrite'], resources: [amp.attrArn] }),
        new iam.PolicyStatement({ actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'], resources: ['*'] }),
        new iam.PolicyStatement({
          actions: ['logs:CreateLogStream', 'logs:PutLogEvents', 'logs:DescribeLogStreams'],
          resources: [otlpLogs.logGroupArn, `${otlpLogs.logGroupArn}:*`],
        }),
      ],
    });

    // ---------------------------------------------------------------- AWS-side collectors (EC2/ECS/EKS)
    new iam.ManagedPolicy(this, 'AwsCollectorPolicy', {
      managedPolicyName: 'observability-hub-collector',
      document: writerPermissions,
      description: 'Attach to ADOT collector roles running inside AWS',
    });

    // ---------------------------------------------------------------- GCP: keyless via Google identity tokens
    if (props.gcpServiceAccountUniqueId) {
      const gcpRole = new iam.Role(this, 'GcpCollectorRole', {
        roleName: 'observability-hub-gcp-collector',
        maxSessionDuration: cdk.Duration.hours(1),
        assumedBy: new iam.WebIdentityPrincipal('accounts.google.com', {
          StringEquals: {
            'accounts.google.com:aud': props.gcpServiceAccountUniqueId, // token "azp" = the service account
            'accounts.google.com:oaud': 'sts.amazonaws.com', // token "aud" requested by the collector
          },
        }),
        inlinePolicies: { write: writerPermissions },
      });
      new cdk.CfnOutput(this, 'GcpCollectorRoleArn', { value: gcpRole.roleArn });
    }

    // ---------------------------------------------------------------- Azure: keyless via Entra ID OIDC
    if (props.azure) {
      const issuer = `login.microsoftonline.com/${props.azure.tenantId}/v2.0`;
      const provider = new iam.OpenIdConnectProvider(this, 'AzureOidc', {
        url: `https://${issuer}`,
        clientIds: [props.azure.appId],
      });
      const azureRole = new iam.Role(this, 'AzureCollectorRole', {
        roleName: 'observability-hub-azure-collector',
        maxSessionDuration: cdk.Duration.hours(1),
        assumedBy: new iam.OpenIdConnectPrincipal(provider, {
          StringEquals: { [`${issuer}:aud`]: props.azure.appId, [`${issuer}:sub`]: props.azure.principalId },
        }),
        inlinePolicies: { write: writerPermissions },
      });
      new cdk.CfnOutput(this, 'AzureCollectorRoleArn', { value: azureRole.roleArn });
    }

    // ---------------------------------------------------------------- AWS multi-account CloudWatch
    if (props.sourceAccountIds.length) {
      const sink = new oam.CfnSink(this, 'MonitoringSink', {
        name: 'observability-hub',
        policy: {
          Version: '2012-10-17',
          Statement: [
            {
              Effect: 'Allow',
              Principal: { AWS: props.sourceAccountIds },
              Action: ['oam:CreateLink', 'oam:UpdateLink'],
              Resource: '*',
              Condition: {
                'ForAllValues:StringEquals': {
                  'oam:ResourceTypes': ['AWS::CloudWatch::Metric', 'AWS::Logs::LogGroup', 'AWS::XRay::Trace'],
                },
              },
            },
          ],
        },
      });
      new cdk.CfnOutput(this, 'OamSinkArn', { value: sink.attrArn });
    }

    // ---------------------------------------------------------------- Grafana
    if (props.enableGrafana) {
      const grafanaRole = new iam.Role(this, 'GrafanaRole', {
        assumedBy: new iam.ServicePrincipal('grafana.amazonaws.com', {
          conditions: { StringEquals: { 'aws:SourceAccount': this.account } },
        }),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonGrafanaCloudWatchAccess'),
          iam.ManagedPolicy.fromAwsManagedPolicyName('AWSXrayReadOnlyAccess'),
        ],
      });
      grafanaRole.addToPolicy(
        new iam.PolicyStatement({
          actions: ['aps:ListWorkspaces', 'aps:DescribeWorkspace', 'aps:QueryMetrics', 'aps:GetLabels', 'aps:GetSeries', 'aps:GetMetricMetadata'],
          resources: ['*'],
        }),
      );
      const ws = new grafana.CfnWorkspace(this, 'Grafana', {
        name: 'multi-cloud-hub',
        accountAccessType: 'CURRENT_ACCOUNT',
        authenticationProviders: ['AWS_SSO'],
        permissionType: 'CUSTOMER_MANAGED',
        roleArn: grafanaRole.roleArn,
        dataSources: ['PROMETHEUS', 'CLOUDWATCH', 'XRAY'],
        grafanaVersion: '10.4',
        pluginAdminEnabled: true,
      });
      new cdk.CfnOutput(this, 'GrafanaUrl', { value: `https://${ws.attrEndpoint}` });
    }

    new cdk.CfnOutput(this, 'RemoteWriteUrl', { value: `${amp.attrPrometheusEndpoint}api/v1/remote_write` });
    new cdk.CfnOutput(this, 'PrometheusQueryUrl', { value: `${amp.attrPrometheusEndpoint}api/v1/query` });
    new cdk.CfnOutput(this, 'ExternalLogGroup', { value: otlpLogs.logGroupName });
  }
}
collector/credential-process-azure.sh ยท 22 lines
#!/usr/bin/env bash
# AWS credential_process for an Azure VM with a managed identity: get an Entra ID token
# for the app registration and exchange it for short-lived AWS credentials.
#
# /etc/otel/aws-config:
#   [default]
#   credential_process = /etc/otel/credential-process-azure.sh <AZURE_APP_ID> arn:aws:iam::123456789012:role/observability-hub-azure-collector
set -euo pipefail
APP_ID="$1"
ROLE_ARN="$2"
TOKEN=$(curl -sf -H Metadata:true \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://${APP_ID}" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["access_token"])')

aws sts assume-role-with-web-identity \
  --role-arn "$ROLE_ARN" \
  --role-session-name "azure-$(hostname)" \
  --web-identity-token "$TOKEN" \
  --duration-seconds 3600 \
  --query '{Version:`1`,AccessKeyId:Credentials.AccessKeyId,SecretAccessKey:Credentials.SecretAccessKey,SessionToken:Credentials.SessionToken,Expiration:Credentials.Expiration}' \
  --output json
collector/credential-process-gcp.sh ยท 23 lines
#!/usr/bin/env bash
# AWS credential_process for a GCP VM: exchange the VM service account's Google identity
# token for short-lived AWS credentials. No AWS keys are ever stored on the VM.
#
# /etc/otel/aws-config:
#   [default]
#   credential_process = /etc/otel/credential-process-gcp.sh arn:aws:iam::123456789012:role/observability-hub-gcp-collector
#
# Trust (see stack): accounts.google.com:aud  == service account uniqueId (token "azp")
#                    accounts.google.com:oaud == sts.amazonaws.com        (token "aud")
set -euo pipefail
ROLE_ARN="$1"
TOKEN=$(curl -sf -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=sts.amazonaws.com&format=full")

aws sts assume-role-with-web-identity \
  --role-arn "$ROLE_ARN" \
  --role-session-name "gcp-$(hostname)" \
  --web-identity-token "$TOKEN" \
  --duration-seconds 3600 \
  --query '{Version:`1`,AccessKeyId:Credentials.AccessKeyId,SecretAccessKey:Credentials.SecretAccessKey,SessionToken:Credentials.SessionToken,Expiration:Credentials.Expiration}' \
  --output json
collector/otel-collector-config.yaml ยท 68 lines
# AWS Distro for OpenTelemetry (ADOT) / otelcol-contrib config - identical on AWS, Azure and GCP.
# Only CLOUD_PROVIDER, AWS_REGION and REMOTE_WRITE_URL differ per environment.
#   export CLOUD_PROVIDER=gcp   # aws | azure | gcp
#   export AWS_REGION=us-east-1
#   export REMOTE_WRITE_URL=https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-xxxx/api/v1/remote_write
#   export AWS_CONFIG_FILE=/etc/otel/aws-config   # see credential-process scripts for Azure/GCP

extensions:
  sigv4auth:
    region: ${env:AWS_REGION}
    service: aps
  health_check: {}

receivers:
  hostmetrics:
    collection_interval: 30s
    scrapers: { cpu: {}, memory: {}, disk: {}, filesystem: {}, network: {}, load: {} }
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
  prometheus:
    config:
      scrape_configs:
        - job_name: local-apps
          scrape_interval: 30s
          static_configs:
            - targets: ['localhost:9100']   # node_exporter or your app's /metrics

processors:
  memory_limiter: { check_interval: 5s, limit_percentage: 80, spike_limit_percentage: 25 }
  resourcedetection:
    detectors: [env, system, ec2, gcp, azure]
    timeout: 5s
    override: false
  resource:
    attributes:
      - { key: cloud_provider, value: '${env:CLOUD_PROVIDER}', action: upsert }
  batch: { timeout: 10s, send_batch_size: 1024 }

exporters:
  prometheusremotewrite:
    endpoint: ${env:REMOTE_WRITE_URL}
    auth: { authenticator: sigv4auth }
    resource_to_telemetry_conversion: { enabled: true }
  awsxray:
    region: ${env:AWS_REGION}
  awscloudwatchlogs:
    region: ${env:AWS_REGION}
    log_group_name: /observability-hub/external-clouds
    log_stream_name: ${env:CLOUD_PROVIDER}-${env:HOSTNAME}

service:
  extensions: [sigv4auth, health_check]
  pipelines:
    metrics:
      receivers: [hostmetrics, otlp, prometheus]
      processors: [memory_limiter, resourcedetection, resource, batch]
      exporters: [prometheusremotewrite]
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resourcedetection, resource, batch]
      exporters: [awsxray]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, resourcedetection, resource, batch]
      exporters: [awscloudwatchlogs]
cdk.json ยท 6 lines
{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
  "context": {}
}
package.json ยท 23 lines
{
  "name": "12-multi-cloud-observability-hub",
  "version": "1.0.0",
  "private": true,
  "bin": { "app": "bin/app.js" },
  "scripts": {
    "build": "tsc",
    "synth": "cdk synth",
    "deploy": "cdk deploy --all",
    "destroy": "cdk destroy --all"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "aws-cdk": "^2.1000.0",
    "ts-node": "^10.9.2",
    "typescript": "~5.6.3"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.200.0",
    "constructs": "^10.4.2"
  }
}
tsconfig.json ยท 14 lines
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["es2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "exclude": ["node_modules", "cdk.out"]
}

Build it yourself

Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.