← All runbooks in Observability
Runbook: Investigate an Alert Not Firing
1 · Prerequisites
Confirm every item is in place before any state change.
- Alert Failure Anatomy
- The Alert Rule Anatomy
- Rule Evaluation
- Alert Time-to-Fire
- promtool check rules
- promtool test rules
- Read access to the Prometheus HTTP API and to the rule files on disk
- Read access to Alertmanager and to the receiver's own delivery log or status page
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/queryand 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 queryand 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: 30magainst 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.
- 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/rulesand look for the alert by name. A YAML error drops every rule in that file, not only the broken one - 2Stage 1b — is the rule healthy? In the same
/api/v1/rulesoutput, readhealthandlastErrorfor the rule. Cross-checkprometheus_rule_evaluation_failures_totalandprometheus_rule_group_iterations_missed_total: a group that cannot finish inside its interval silently skips evaluations - 3Stage 2 — does the expression return a series? Run the rule's
exprverbatim 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 - 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**Stage 3 — has the
for:dwell elapsed?** QueryALERTS_FOR_STATEfor the alertname:2is firing,1is pending,0is inactive. A series that reaches pending and never advances means the condition ended before the dwell did - 6Stage 4 — did the firing alert reach Alertmanager? Check
/api/v1/alertmanagerson Prometheus to confirm it has a target at all, watchprometheus_notifications_errors_totalandprometheus_notifications_dropped_total, then look for the alert withamtool alert query - 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 - 8Stage 6 — did the receiver accept it? Read
alertmanager_notifications_failed_totalby integration and the Alertmanager log for the notify attempt. An expired credential or a rate-limited API answers here with an HTTP status - 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
- 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
- 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/ruleswithhealth: "ok"and an emptylastError - ✓The rule's expression, run verbatim against
/api/v1/query, returns the series you expect — including for the population that was missed - ✓
ALERTS_FOR_STATEfor the alertname reaches2while the condition holds - ✓
amtool alert queryshows the alert present in Alertmanager with the labels the route needs - ✓
amtool config routes testwith those exact labels resolves to the intended receiver, not to the default one - ✓
alertmanager_notifications_failed_totalfor 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 rulesfixture 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 withpromtool check rulesoramtool 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:
- The condition is true right now. Not an hour ago. A rule that stopped firing because the problem stopped is correct.
- 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.
- 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?
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.
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:
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:
| Shape | What happened | Tell |
|---|---|---|
| Label drift | The job label became checkout-svc-canary on part of the fleet | The metric exists; the matcher’s value does not appear in the label list |
| Renamed metric | An exporter upgrade replaced the metric name | The bare metric name returns nothing at all |
| Missing recording rule | The rule the alert depends on was deleted in a config edit | The expression references a name that is not in /api/v1/rules |
| Wrong aggregation | sum by (service) drops the label the alert needed | Series 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?
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?
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?
amtool config routes test \
--config.file=/etc/alertmanager/alertmanager.yml \
alertname=CheckoutHighErrorRate \
severity=page \
team=checkoutFeed 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 action | Follow-up |
|---|---|
| Rule or route edited on the server | Revert in the repository; redeploy so the change survives |
for: shortened to reproduce faster | Put it back to the tier-appropriate value |
| Silence created for a test | amtool silence expire ID, by ID, before you stand down |
| Synthetic alert pushed to Alertmanager | Remove it, and tell on-call it was a test |
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.