# Alerts that don't know which cloud they run in

Every cloud sells you alarms. CloudWatch Alarms, Azure Monitor alert rules, Cloud Monitoring alerting policies: each one is preconfigured, each one is a few clicks, and each one is defined in a format that only its own cloud understands. If you run in more than one, you end up with three alerting systems, three on-call routings, three definitions of "the API is unhealthy", and, sooner or later, three different thresholds for the same thing because someone tuned one and forgot the others.

We've argued that [logs](/en/blog/your-logs-should-not-know-which-cloud) and [traces](/en/blog/your-traces-should-not-know-which-cloud) should be emitted by the application and stored somewhere the cloud doesn't own. Alerts are the third piece, and they're the one that matters at 02:40, because an alert is the thing that decides whether you're awake. This is how we define them once, from metrics the application emits, so that "error rate above 1 %" means the same thing on EKS, GKE and AKS, and moving a service between them doesn't change what pages you.

## Alert on what the user sees, not on what the cloud sees

The cloud's native alarms are about the cloud's resources: CPU on an instance, 5xx count on a load balancer, throttles on a table. They're useful, and they're the wrong thing to page on, because a user doesn't experience CPU. A user experiences a slow or failed request. So the alerts that page are on three numbers per service, measured from the application's own point of view:

- **Rate**: requests per second, so a drop to zero is visible.
- **Errors**: fraction of requests that failed.
- **Duration**: latency at the 95th or 99th percentile.

These come from the same OpenTelemetry pipeline that carries the traces. The collector's `spanmetrics` connector turns every server span into a histogram of durations and a counter of calls, labelled with `service.name`, the HTTP route, the status code and, because `resourcedetection` ran first, `cloud.provider`. No new instrumentation in the app. The spans you already emit become the metrics you alert on.

```yaml
connectors:
  spanmetrics:
    histogram:
      explicit: { buckets: [50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s] }
    dimensions:
      - name: http.route
      - name: http.response.status_code
    exemplars: { enabled: true }
    resource_metrics_key_attributes: [service.name, cloud.provider, cloud.region]

service:
  pipelines:
    traces:  { receivers: [otlp], processors: [resourcedetection, batch], exporters: [otlphttp/tempo, spanmetrics] }
    metrics: { receivers: [spanmetrics, otlp], processors: [batch], exporters: [prometheusremotewrite/mimir] }
```

The metrics go to Mimir (or plain Prometheus, or VictoriaMetrics; the architecture doesn't care). Exemplars are on, so a point on the latency graph links to a real trace that produced it.

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline from spans to a page. Applications on EKS, GKE and AKS emit spans over OTLP. The collector runs resourcedetection, then the spanmetrics connector derives request counts and duration histograms labelled by service, route, status and cloud provider. Metrics go to Mimir. Grafana alert rules, defined once as code, evaluate PromQL over all clouds and route to the on-call pager. Native cloud alarms for infrastructure feed the same pager but never page on their own.">
<defs><marker id="arrA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="40" width="150" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="90" y="62" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps</text><text x="90" y="82" text-anchor="middle" fill="#ffd166" font-size="11">EKS · GKE · AKS</text><text x="90" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">spans over OTLP</text><text x="90" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">no metrics code</text>
<line x1="167" y1="85" x2="200" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrA)"/>
<rect x="203" y="40" width="190" height="90" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="298" y="62" text-anchor="middle" fill="#f1f3ff" font-weight="700">collector</text><text x="298" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">resourcedetection →</text><text x="298" y="100" text-anchor="middle" fill="#4fffb0" font-size="11">spanmetrics connector</text><text x="298" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">rate · errors · duration</text>
<line x1="395" y1="85" x2="428" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrA)"/>
<rect x="431" y="40" width="130" height="90" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="496" y="62" text-anchor="middle" fill="#f1f3ff" font-weight="700">Mimir</text><text x="496" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">labels include</text><text x="496" y="100" text-anchor="middle" fill="#7b8cff" font-size="11">cloud_provider</text><text x="496" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">exemplars → Tempo</text>
<line x1="563" y1="85" x2="596" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrA)"/>
<rect x="599" y="40" width="150" height="90" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="674" y="62" text-anchor="middle" fill="#f1f3ff" font-weight="700">Grafana alerting</text><text x="674" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">rules as code</text><text x="674" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">burn rate on SLOs</text><text x="674" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">one rule, all clouds</text>
<line x1="751" y1="85" x2="784" y2="85" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrA)"/>
<rect x="787" y="40" width="98" height="90" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="836" y="80" text-anchor="middle" fill="#ff6b8a" font-weight="700">pager</text><text x="836" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">one rotation</text>
<rect x="203" y="160" width="358" height="44" rx="10" fill="#0d1120" stroke="#2a3150" stroke-width="1.5" stroke-dasharray="5,3"/><text x="382" y="178" text-anchor="middle" fill="#9aa3c7" font-size="11">native alarms: RDS CPU, NAT errors, Aurora ACU, quota limits</text><text x="382" y="195" text-anchor="middle" fill="#9aa3c7" font-size="11">stay in each cloud · forward to the same pager as low priority</text>
<path d="M561,182 L836,182 L836,132" fill="none" stroke="#2a3150" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrA)"/>
</g>
</svg>
</div>

## The rules, written once

Grafana alert rules live in a YAML file in the repository and are provisioned on every deploy, the same way the dashboards are. One rule per SLO, not one per service per cloud. The query does the fan-out:

```yaml
# alerting/rules.yaml
groups:
  - name: api-slo
    interval: 1m
    rules:
      - alert: ApiErrorBudgetBurn
        for: 2m
        labels: { severity: page, team: platform }
        annotations:
          summary: 'api error budget burning {{ $labels.cloud_provider }} · {{ $value | humanizePercentage }}'
          runbook: https://runbooks.internal/api-errors
        expr: |
          (
            sum by (cloud_provider) (rate(calls_total{service_name="api", http_response_status_code=~"5.."}[5m]))
            /
            sum by (cloud_provider) (rate(calls_total{service_name="api"}[5m]))
          ) > (14.4 * 0.001)
          and
          (
            sum by (cloud_provider) (rate(calls_total{service_name="api", http_response_status_code=~"5.."}[1h]))
            /
            sum by (cloud_provider) (rate(calls_total{service_name="api"}[1h]))
          ) > (14.4 * 0.001)
```

That's a multi-window burn-rate alert for a 99.9 % availability SLO: page when the error rate over the last five minutes *and* the last hour both exceed 14.4 times the error budget, which corresponds to burning the whole 30-day budget in about two days. The `sum by (cloud_provider)` is the entire multi-cloud story. One rule, one expression, and it fires separately for `aws`, `gcp` and `azure`, with the cloud in the alert title, if only one of them is on fire. Moving the API from EKS to GKE changes which label value fires. It doesn't change the rule.

A second rule with a longer window (6 hours and 3 days, threshold 1×) catches slow burns as a ticket rather than a page. Latency gets the same treatment on `histogram_quantile(0.99, sum by (le, cloud_provider) (rate(duration_bucket{...}[5m])))`.

<div class="article-figure">
<svg viewBox="0 0 900 210" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Chart of error rate over 24 hours for the api service, one line per cloud. AWS and Azure stay well below the 0.1 percent objective. GCP spikes to 2 percent at 02:40 for twenty minutes; the 5-minute and 1-hour burn-rate windows both cross the 14.4 times threshold and a page fires for cloud_provider gcp only. A slow 0.3 percent plateau on Azure in the afternoon crosses only the long-window rule and creates a ticket.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="22" fill="#f1f3ff" font-size="14" font-weight="700">api error rate by cloud · one rule, three label values</text>
<line x1="60" y1="170" x2="880" y2="170" stroke="#9aa3c7"/><line x1="60" y1="40" x2="60" y2="170" stroke="#9aa3c7"/>
<line x1="60" y1="164" x2="880" y2="164" stroke="#2a3150" stroke-dasharray="3,3"/><text x="884" y="167" fill="#9aa3c7" font-size="10">0.1 % SLO</text>
<line x1="60" y1="120" x2="880" y2="120" stroke="#ff6b8a" stroke-dasharray="4,3"/><text x="884" y="123" fill="#ff6b8a" font-size="10">14.4× · page</text>
<polyline points="60,167 200,166 400,167 600,166 880,167" fill="none" stroke="#ffd166" stroke-width="2"/><text x="70" y="52" fill="#ffd166" font-size="10">aws</text>
<polyline points="60,167 140,167 150,60 175,58 190,166 400,167 880,167" fill="none" stroke="#4fffb0" stroke-width="2"/><text x="70" y="66" fill="#4fffb0" font-size="10">gcp</text>
<polyline points="60,167 500,167 520,150 700,150 720,167 880,167" fill="none" stroke="#7b8cff" stroke-width="2"/><text x="70" y="80" fill="#7b8cff" font-size="10">azure</text>
<rect x="148" y="40" width="44" height="130" fill="#ff6b8a" opacity="0.12"/><text x="170" y="192" text-anchor="middle" fill="#ff6b8a" font-size="10">02:40 · page: gcp</text>
<rect x="515" y="40" width="210" height="130" fill="#7b8cff" opacity="0.08"/><text x="620" y="192" text-anchor="middle" fill="#7b8cff" font-size="10">14:00–19:00 · slow burn: ticket, azure</text>
<text x="60" y="206" fill="#9aa3c7" font-size="10">00:00</text><text x="880" y="206" text-anchor="end" fill="#9aa3c7" font-size="10">24:00</text>
</g>
</svg>
</div>

## What stays native, and how it joins

The cloud's own alarms don't go away; they change job. RDS CPU, Aurora ACU utilisation, NAT gateway error port allocation, DynamoDB throttles, service quota limits: these are properties of infrastructure the cloud owns, and the cloud's monitoring sees them first and best. We keep those alarms native, defined in the same CDK that defines the resource, and route them to the same pager as *low priority*. They inform; they don't wake anyone. If a NAT starts dropping packets, the API error rate alert pages within two minutes anyway, and the NAT alarm is sitting there in the incident channel explaining why.

That inversion is the whole point. Symptoms page. Causes annotate. The symptom alerts are cloud-neutral because the application emitted the data; the cause alerts are cloud-specific because the cloud emitted the data. Neither pretends to be the other.

## Three things that took us longer than they should have

**Cardinality.** `spanmetrics` with `http.route` as a dimension is fine. With `http.target` (the raw path, including IDs) it generates a time series per order and Mimir falls over. Use the route template, never the path.

**Silence during deploys.** A rolling deploy produces a burst of connection resets for a few seconds. The `for: 2m` on the page rule absorbs it. Without it, every deploy paged someone for the first week.

**The pager needs to be one thing.** We had CloudWatch going to one on-call tool and Grafana going to another, because they'd been set up at different times. Two rotations, two apps on the phone, and one night where the person on call for one wasn't on call for the other. Everything now routes through Grafana's contact points to one rotation. The native alarms get there via an SNS topic → webhook, which took an hour to set up and should have been day one.

## The short version

Alert on the three numbers the user feels, derived from spans you already emit, stored in a metrics backend you own, evaluated by rules you keep in git with a `sum by (cloud_provider)` in them. Keep the cloud's alarms for the cloud's things, at low priority, into the same pager. When a service moves clouds, one label value changes and nobody edits an alert.

Want the rules file for your own services, tuned to real SLOs rather than round numbers? [Talk to us](/contact).
