Skip to main content
RunBook Academy

← All runbooks in Observability

high riskservice affecting~30 min

Runbook: Investigate an Alert Not Firing

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · Confirm the condition is true right now, not merely true an hour ago. Run the alert's own expression against /api/v1/query and read the result. A rule that does not fire on a condition that has ended is behaving correctly
  • · Confirm a rule actually exists for this condition. A missing page with no rule behind it is a rule gap, which is a design problem for later, not a pipeline failure to debug now
  • · Confirm nobody was notified. Check the receiver itself — the PagerDuty incident list, the Slack channel, the mailbox — not the team's memory of it
  • · Rule out suppression before you rule out failure: amtool silence query and the Alertmanager inhibition rules. A silenced or inhibited alert did fire and was withheld by policy; that is a different investigation with a different owner
  • · Name who is watching the condition by hand while detection is broken, and until when. Until the pipeline is fixed there is no automated detection for this failure mode, and that gap needs an owner rather than an assumption
  • · Note the time the condition began, and how long it persisted. An alert with for: 30m against a condition that lasted ninety seconds is not broken; it is wrong, and the fix is a different one

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Stage 1 — is the rule loaded? promtool check rules /etc/prometheus/rules/*.yml, then confirm it is live: curl -sf http://localhost:9090/api/v1/rules and look for the alert by name. A YAML error drops every rule in that file, not only the broken one
  2. 2Stage 1b — is the rule healthy? In the same /api/v1/rules output, read health and lastError for the rule. Cross-check prometheus_rule_evaluation_failures_total and prometheus_rule_group_iterations_missed_total: a group that cannot finish inside its interval silently skips evaluations
  3. 3Stage 2 — does the expression return a series? Run the rule's expr verbatim through /api/v1/query. An empty result means the rule sits inactive forever no matter what the system is doing. This is roughly half of all cases; do not move past it quickly
  4. 4Stage 2b — if empty, does the underlying metric exist? Query the bare metric name with no label matchers, then compare the label sets you get back with the matchers in the rule. Label drift, a renamed metric after an exporter upgrade, or a deleted recording rule all land here
  5. 5**Stage 3 — has the for: dwell elapsed?** Query ALERTS_FOR_STATE for the alertname: 2 is firing, 1 is pending, 0 is inactive. A series that reaches pending and never advances means the condition ended before the dwell did
  6. 6Stage 4 — did the firing alert reach Alertmanager? Check /api/v1/alertmanagers on Prometheus to confirm it has a target at all, watch prometheus_notifications_errors_total and prometheus_notifications_dropped_total, then look for the alert with amtool alert query
  7. 7Stage 5 — did the route match? Reproduce the routing decision offline with the exact label set Prometheus sent: amtool config routes test --config.file=/etc/alertmanager/alertmanager.yml alertname=... severity=... team=.... A misspelt matcher sends a page to the default receiver, which is usually not a pager
  8. 8Stage 6 — did the receiver accept it? Read alertmanager_notifications_failed_total by integration and the Alertmanager log for the notify attempt. An expired credential or a rate-limited API answers here with an HTTP status
  9. 9Fix the first stage that failed and stop. Fixing two stages at once means you will not know which one mattered, and one of your two changes is probably wrong
  10. 10Prove the repair end to end before standing down: with the fix in place, either wait for the real condition to recur or drive a controlled synthetic through the same route, and confirm arrival at the receiver
  11. 11Write down the stage that failed. "The rule's job matcher did not cover the canary fleet" is actionable; "alerting was flaky" guarantees the same hour is spent again

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓The alert appears in /api/v1/rules with health: "ok" and an empty lastError
  • ✓The rule's expression, run verbatim against /api/v1/query, returns the series you expect — including for the population that was missed
  • ✓ALERTS_FOR_STATE for the alertname reaches 2 while the condition holds
  • ✓amtool alert query shows the alert present in Alertmanager with the labels the route needs
  • ✓amtool config routes test with those exact labels resolves to the intended receiver, not to the default one
  • ✓alertmanager_notifications_failed_total for that integration is flat across the test
  • ✓A human confirms arrival at the receiver — the page landed, the channel message appeared, the mail arrived. Delivery is the only verification that is not self-reported
  • ✓A regression test exists for the defect: a promtool test rules fixture that fails against the old rule and passes against the fixed one

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶This investigation is read-only up to the point where you change a rule file or an Alertmanager route; there is nothing to undo before that
  • ↶Revert rule and routing changes through the repository, not by editing the file on the server: git revert --no-edit HEAD, re-validate with promtool check rules or amtool check-config, then reload
  • ↶If you shortened a for: to force the alert to reproduce, put it back. A dwell chosen under pressure to make a test go faster becomes the dwell that flaps on every scrape gap
  • ↶Expire any silence you created for the test, by ID, the moment the test finishes: amtool silence expire ID. A silence added to run one experiment and left behind is the exact defect this runbook exists to find
  • ↶Remove any synthetic alert you pushed into Alertmanager, and tell whoever is on call that it was a test — before they start working it
  • ↶A rule reload that is rejected changes nothing: the previous rules stay loaded. Fix the syntax and reload again rather than restarting the server

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate immediately, in parallel with the investigation, if the condition is a live customer-visible failure. Detection is broken and something is broken; those need two people, not one person doing them in sequence
  • · Escalate to the service owner when the fix is a rule-content decision — the right threshold, the right dwell, the population that should have been covered. That is an ownership question, not an operator's to guess at 03:00
  • · Escalate to the platform team if the failure is at stage 4 — Prometheus cannot reach Alertmanager. That is a platform outage affecting every alert, not just this one, and the blast radius is the whole estate
  • · Escalate to the receiver's owner, and check its public status page, if stage 6 shows repeated integration errors. A rotated PagerDuty routing key or an expired webhook is somebody else's change
  • · Escalate after 30 minutes without identifying a stage. The pipeline has six of them and each has a cheap check; if none has answered, the assumption at the top — that the condition is true and a rule covers it — is the thing to re-examine

The cost of this failure is asymmetric. A false positive wakes someone who could have slept. An alert that does not fire leaves someone asleep through a real incident, and the outage lasts from the moment the condition appeared to the moment somebody found out by other means — usually a customer.

That asymmetry is why this runbook is ordered rather than exhaustive. There are six stages between a metric and a pager, each with a check that takes seconds. The discipline is to run them in order and stop at the first one that fails.

Before you start: is this actually a non-firing alert?

Three things must all be true, and confirming them takes a minute:

  1. The condition is true right now. Not an hour ago. A rule that stopped firing because the problem stopped is correct.
  2. A rule exists whose purpose is this condition. If there is no rule, this is a rule gap: a design conversation for the morning, not a pipeline to debug.
  3. No notification reached the receiver. Check the receiver, not the team’s memory of it.

The pipeline, and why the order is not negotiable

target /metrics  ->  TSDB  ->  rule evaluator  ->  alert state
                                                        |
                                                        v
    receiver   <-  route match  <-  silence / inhibit / group

Each stage has a faster check than the one after it. Reaching for amtool before you have run the rule’s expression means ten minutes spent proving a route is correct while the expression it depends on returns nothing.

Roughly half of real cases end at stage 2 — the expression returns no series. That is not a guess about your estate; it is where label drift accumulates fastest, because every exporter upgrade and every instrumentation change is an opportunity for a matcher to stop matching.

Stage 1 — Is the rule loaded, and is it healthy?

Read-only / Safeparse on disk, then confirm live
promtool check rules /etc/prometheus/rules/checkout.yml

PROM=http://localhost:9090
curl -sf "$PROM/api/v1/rules" \
| jq -r '.data.groups[] | .file as $f | .rules[]
    | select(.name == "CheckoutHighErrorRate")
    | [$f, .name, .health, .lastError] | @tsv'

Two distinct questions live here. promtool check rules answers “does this file parse”. The API answers “is this rule actually loaded in the running server”, which is a different thing entirely — a file that was never deployed, or a rule_files glob that stopped matching, parses perfectly and is loaded nowhere.

A rule can also be loaded and still not evaluating. Two metrics answer that:

# expressions that error at evaluation time
prometheus_rule_evaluation_failures_total

# groups that could not finish inside their interval and skipped a tick
prometheus_rule_group_iterations_missed_total

A missed iteration is the quiet one. The rule is present, healthy in the UI, and simply not being run often enough — usually because an expensive expression in the same group is eating the interval.

Stage 2 — Does the expression return a series?

This is where you should expect to spend your time.

Read-only / Saferun the rule's own expr, verbatim
PROM=http://localhost:9090

curl -sfG "$PROM/api/v1/query" \
--data-urlencode 'query=sum by (service, region) (rate(http_requests_total{job="checkout-svc",code=~"5.."}[5m]))' \
| jq '.data.result | length'

Copy the expression out of the rule file rather than retyping it. The whole point is to test the expression that is loaded, not the one you believe is loaded.

If the result is empty, the rule sits inactive forever regardless of what the system is doing. Now find out why by walking the matchers outward, dropping one at a time:

Read-only / Safedoes the metric exist at all, and with which labels
PROM=http://localhost:9090

curl -sfG "$PROM/api/v1/query" \
--data-urlencode 'query=count by (job, code, environment) (http_requests_total)' \
| jq -r '.data.result[] | [.metric.job, .metric.code, .metric.environment] | @tsv'

Then compare what came back with what the rule asks for. The four shapes, in rough order of frequency:

ShapeWhat happenedTell
Label driftThe job label became checkout-svc-canary on part of the fleetThe metric exists; the matcher’s value does not appear in the label list
Renamed metricAn exporter upgrade replaced the metric nameThe bare metric name returns nothing at all
Missing recording ruleThe rule the alert depends on was deleted in a config editThe expression references a name that is not in /api/v1/rules
Wrong aggregationsum by (service) drops the label the alert neededSeries come back, but not with the label set the route matches on

The canary case is the one that costs the most, because the alert works. It fires for the population it was written against and silently misses the new one. Nobody notices until the missed population is the one that breaks.

Stage 3 — Has the dwell elapsed?

Read-only / Safeper-series alert state
PROM=http://localhost:9090

curl -sfG "$PROM/api/v1/query" \
--data-urlencode 'query=ALERTS_FOR_STATE{alertname="CheckoutHighErrorRate"}' \
| jq -r '.data.result[] | [.metric.instance, .value[1]] | @tsv'

0 is inactive, 1 is pending, 2 is firing. The state is per series, not per rule: a rule returning three series runs three independent state machines, and one of them can be stuck while the others are fine.

If series reach pending and never advance, the condition is ending before the dwell does. Total time to fire is the sum of four things:

time_to_fire = scrape_interval + rule_interval + for_dwell + group_wait

With a 15s scrape, a 30s rule interval, for: 5m and a 10s group_wait, the alert lands between 5m10s and 5m55s after the condition begins. If the condition typically lasts ninety seconds, no amount of debugging will make that alert fire — the dwell is simply wrong for the thing it watches.

Stage 4 — Did the alert reach Alertmanager?

Read-only / Safethe handoff
PROM=http://localhost:9090

curl -sf "$PROM/api/v1/alertmanagers" | jq '.data'

curl -sfG "$PROM/api/v1/query" \
--data-urlencode 'query=prometheus_notifications_errors_total'

amtool alert query --alertmanager.url=http://alertmanager:9093

/api/v1/alertmanagers tells you whether Prometheus has an Alertmanager target at all. An empty list is a decisive answer: the alerting: block in prometheus.yml is wrong or its discovery resolves to nothing, and every alert in the estate is affected, not only this one. That changes the severity of what you are handling.

If the alert is firing in Prometheus and absent from amtool alert query, the failure is in that hop. If it is present, move on — stages 5 and 6 are Alertmanager’s business.

Stage 5 — Did the route match?

Read-only / Safereproduce the routing decision offline
amtool config routes test \
--config.file=/etc/alertmanager/alertmanager.yml \
alertname=CheckoutHighErrorRate \
severity=page \
team=checkout

Feed it the labels Prometheus actually sent, copied from amtool alert query — not the labels the rule file says it sets. Those can differ: external labels are added, and relabelling can rewrite them on the way out.

The classic defect is a matcher that is almost right. A route matching severity = "pages" against a rule emitting severity: page matches nothing, so the alert falls through to the default receiver. It was delivered — just to a channel nobody watches at 03:00. This looks identical to “no alert” from the on-call’s side, and it is why the check is a routing test rather than a search of the pager history.

Stage 6 — Did the receiver accept it?

# failures per integration - the label names the receiver type
alertmanager_notifications_failed_total

# for comparison: the attempt count
alertmanager_notifications_total

Read the Alertmanager log alongside them. A failing integration answers with a status code, and the status code is the diagnosis: 401 after a credential rotation, 429 from a rate-limited API, a connection error from an outbound path that a firewall change closed. Check the receiver’s own status page before assuming the fault is local.

Holding, when the fix is not immediate

If the condition is live and the pipeline is broken, detection for that failure mode does not exist. That is a state you may have to accept for a while, but not one to leave implicit.

Name a person to watch the condition by hand, name what they are watching — the dashboard panel, the query, the customer channel — and name the time the arrangement ends. “Someone will keep an eye on it” is not a control. A named human, a named signal and an end time is.

Rollback

Everything up to stage 5 is read-only. What needs undoing is what you changed to investigate:

Emergency actionFollow-up
Rule or route edited on the serverRevert in the repository; redeploy so the change survives
for: shortened to reproduce fasterPut it back to the tier-appropriate value
Silence created for a testamtool silence expire ID, by ID, before you stand down
Synthetic alert pushed to AlertmanagerRemove it, and tell on-call it was a test
Configuration changerevert through the repository
git revert --no-edit HEAD
promtool check rules /etc/prometheus/rules/checkout.yml
curl -sf -X POST "http://localhost:9090/-/reload"

A rejected reload changes nothing — the previously loaded rules keep running. That is a safety property, not a reason to restart the server.

Close it with a test, not with a fix

The defect that caused this is a rule-file defect that loaded cleanly and evaluated quietly. Nothing will catch the next one except a test that encodes the case:

# test/checkout_test.yml
rule_files:
  - ../rules/checkout.yml
evaluation_interval: 30s

tests:
  - interval: 30s
    input_series:
      - series: 'http_requests_total{job="checkout-svc-canary",code="500"}'
        values: '0+10x20'
    alert_rule_test:
      - eval_time: 6m
        alertname: CheckoutHighErrorRate
        exp_alerts:
          - exp_labels:
              severity: page
              team: checkout

Run it with promtool test rules test/checkout_test.yml. The test must fail against the old rule before you trust it — a test written after the fix that passes immediately has proved nothing.

Escalation

  • The condition is a live customer-visible failure: escalate now, in parallel. Two problems, two people.
  • The fix is a threshold, a dwell, or which population should be covered: that belongs to the service owner.
  • Stage 4 failed: platform team, and treat it as estate-wide.
  • Stage 6 shows repeated integration errors: the receiver’s owner, plus its status page.
  • Thirty minutes with no stage identified: re-examine the assumption that the condition is true and that a rule covers it.

References

  1. Prometheus alerting rules
  2. Unit testing rules with promtool
  3. Prometheus HTTP API: rules and alerts
  4. Alertmanager configuration