ObservabilityXVIII · Alerting RulesAlertingRules
`for:` and Hysteresis
What you'll learn
- Set the for: clause to a value that absorbs the dominant noise source without hiding real outages
- Differentiate the evaluation_interval from the for: clause and explain why both matter
- Use keep_firing_for to avoid the resolve-flap-re-fire cycle on long-running incidents
- Read a Prometheus state transition trace and decide whether a rule is too sensitive, too slow, or correctly tuned
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
The first version of a rule says for: 30s. Two days later the
on-call rota shows seven pages in 24 hours, every one of them the
same alert firing on a 30-second scrape blip and resolving when the
next scrape came in clean. The team tightens the rule with a
better expression and bumps for: to 5m. Pages stop. The cost
is that the same rule now takes five minutes longer to detect a
real incident. The trade-off between flapping and detection
latency is what for: exists to manage.
What it is
The for: clause on an alert rule is the dwell time the rule’s
expr must continuously return a result series before the alert
transitions from pending to firing. Its purpose is anti-flap:
absorb short-lived breaches of the threshold so the alert does not
toggle every evaluation.
keep_firing_for (added in Prometheus 2.42) is the symmetric
post-fire dwell time. Once the rule has fired, the alert stays
firing for at least keep_firing_for even if the expr returns
empty. Its purpose is anti-resolve: prevent the alert from
chattering into resolution during a long incident that oscillates
near the threshold.
Together, for: and keep_firing_for describe a hysteresis band:
the alert enters firing after for: of continuous breach and exits
firing after keep_firing_for of continuous recovery.
Why a sysadmin cares
A rule with no for: (or for: 0s, the default) fires on every
evaluation where the threshold is crossed. On a 30-second scrape
interval, a 90-second blip produces three notifications. The
Alertmanager group_wait and group_interval smooth some of this,
but the underlying signal is noisy and the on-call rota pays the
cost.
A rule with for: set too long misses real incidents. A rule with
keep_firing_for set too long makes the alert look stuck when it
has actually recovered. Both tuning choices are operational
judgements, and both are testable.
How it works
The state machine is the same one introduced in lesson 01, with two time-bound transitions:
evaluation_interval ticks
|
v
expr evaluated
|
+--- empty ---> if pending -> inactive (timer reset)
| if firing -> if keep_firing_for elapsed -> resolved
| else stay firing
+--- non-empty
|
+--- no prior alert for this series
| -> create pending alert
| (start for: timer)
|
+--- prior alert in pending
| -> tick for: timer; if elapsed and expr
| still non-empty -> transition to firing
|
+--- prior alert in firing
-> reset keep_firing_for timer; stay firing
The two time-bound transitions are:
- pending to firing, gated by
for:. - firing to resolved, gated by
keep_firing_for(or, if absent, by the expr returning empty for one evaluation).
A worked example. A rule with for: 5m and an evaluation interval
of 30s:
t=00:00 expr returns non-empty alert created, pending
t=00:30 expr returns non-empty pending, timer at 30s
t=01:00 expr returns non-empty pending, timer at 60s
t=01:30 expr returns empty pending -> inactive, timer reset
t=02:00 expr returns non-empty pending recreated, timer at 0
t=02:30 expr returns non-empty pending, timer at 30s
...
t=07:00 timer hits 5m, expr firing, Alertmanager notified
still non-empty
The key behaviours:
- The timer resets whenever the expr returns empty.
- A single empty evaluation is enough to reset the timer.
evaluation_intervaldoes not change the for: budget; it only changes how often the timer is checked. With a 30s interval andfor: 5m, the rule still fires after 5 minutes of continuous breach, not 5 minutes plus one interval.
How to configure it
A worked pair. First, the rule for a user-impact error budget:
groups:
- name: orders-api.slo
interval: 30s
rules:
- alert: OrdersApiHighErrorRate
expr: |
sum by (service, region) (
rate(http_requests_total{service="orders-api", status=~"5.."}[5m])
)
/
sum by (service, region) (
rate(http_requests_total{service="orders-api"}[5m])
)
> 0.05
for: 5m
keep_firing_for: 30m
labels:
severity: critical
team: checkout
annotations:
summary: 'orders-api 5xx ratio above 5% for 5 minutes in {{ $labels.region }}'
runbook_url: 'https://runbooks.example.com/checkout/orders-api-5xx'
- alert: TlsCertExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 14
for: 1h
labels:
severity: warning
team: platform
annotations:
summary: 'TLS cert for {{ $labels.instance }} expires in less than 14 days'
runbook_url: 'https://runbooks.example.com/platform/rotate-tls'
Two rules, two anti-flap strategies:
- The error-rate alert pages on a sustained breach (
for: 5m) and stays firing through a long incident (keep_firing_for: 30m). Thefor:is longer than the typical GC pause or deploy blip. Thekeep_firing_foris longer than the typical oscillation period during a regional incident. - The TLS alert uses
for: 1hwith nokeep_firing_for. The cert is a discrete signal; once it has been below 14 days for an hour, the alert should fire and stay firing until the cert is actually rotated.
Three knobs and the trade-offs:
| Setting | Too small | Too large |
|---|---|---|
for: | Flaps on every scrape blip | Misses real, brief outages |
keep_firing_for | Resolves during a long incident | Alert appears stuck after recovery |
evaluation_interval | Wastes CPU on stable rules | Slow to detect a state change |
How to validate it
Three checks. The first two are read-only against a running Prometheus; the third is a unit test.
# 1. Inspect the live state and the dwell counter.
curl -s 'http://prometheus:9090/api/v1/query?query=ALERTS_FOR_STATE' \
| jq '.data.result[] | select(.metric.alertname == "OrdersApiHighErrorRate")
| {state: .metric.alertstate, seconds: .value[1]}'
Expected output during the dwell:
{
"state": "pending",
"seconds": "298.5"
}
The seconds value should be increasing on every evaluation. If
it jumps back to a small number, the expr returned empty in the
interim and the timer reset.
# 2. Inspect the alert state itself.
curl -s http://prometheus:9090/api/v1/alerts \
| jq '.data.alerts[] | select(.labels.alertname == "OrdersApiHighErrorRate")
| {state, activeAt, keepFiringSince}'
Expected output once firing:
{
"state": "firing",
"activeAt": "2026-08-13T03:09:30.000Z",
"keepFiringSince": "2026-08-13T03:14:30.000Z"
}
activeAt is when the alert entered pending.
keepFiringSince is when it transitioned to firing. The gap is
the dwell plus evaluation jitter.
# 3. Unit-test the rule with promtool. The test file (covered in
# detail in lesson 06) replays a synthetic series and asserts
# that the rule fires only after the for: dwell.
promtool test rules test_orders_api.yml
How it can fail
Six failure modes:
-
Flap at the scrape interval. Symptom: the alert fires and resolves every 60 seconds. Cause:
for:is shorter than the dominant noise period, or the expr is intrinsically noisy (e.g. a count over a 1-minute window). Lengthenfor:or rewrite the expr over a longer window. -
Pending forever, never firing. Symptom: the alert has been in pending for hours. Cause: the expr returns series only briefly, so the for: timer resets before it elapses. Confirm by watching the expr in Grafana Explore.
-
Real incident delayed by
for:. Symptom: the on-call rota knew something was wrong from dashboards, but the alert did not fire until 10 minutes in. Cause:for:is too long for the user-impact threshold. Tightenfor:and add a parallel shorter-for:rule at lower severity. -
Resolves during a long incident. Symptom: a 90-minute outage produces three resolve-and-re-fire cycles. Cause: the expr oscillates near the threshold during partial recovery. Add
keep_firing_forat a value larger than the typical oscillation period. -
Alert appears stuck after recovery. Symptom: the rule keeps firing long after the underlying metric is healthy. Cause:
keep_firing_foris too long. Either shorten it or remove it for rules where immediate resolution is desirable. -
for:larger than the scrape interval, but the rule never evaluates long enough to fire. Symptom: the alert remains in pending. Cause:evaluation_intervalis shorter than the scrape interval, and the metric is missing for an extended period. Useabsent()or add a synthetic zero to the series so the rule can observe the gap.
How to troubleshoot it
In order:
- Inspect the current state.
/api/v1/alertsand/api/v1/query?query=ALERTS_FOR_STATE. The state and the dwell counter tell you whether the rule is waiting, firing, or stuck. - Inspect the expr over the same window. Compute the expr in Grafana Explore for the past hour. If the expr returned empty for any sub-window, the for: timer reset.
- Inspect the scrape health.
up{job="..."}shows whether the source was being scraped. A scrape gap shows up as a hole in the metric and a reset in the rule. - Inspect the eval interval. Confirm
evaluation_intervalon the rule group. A 5-minutefor:with a 1-minute interval is fine; a 5-minutefor:with a 30-second interval does not fire faster. - Re-tune empirically. Change
for:andkeep_firing_forin a non-production replica. Watch the rule against synthetic load. Promote once the trade-off looks right.
Security implications
for: and keep_firing_for are pure timing parameters with no
security implications on their own. The expr they gate, however,
runs every evaluation. A rule whose expr is a heavy aggregation
over a high-cardinality metric can be used to amplify a small
input into an expensive rule evaluation; for: does not
mitigate that. Bound the expr with label selectors before
lengthening for:.
Performance implications
A short evaluation_interval on an expensive group multiplies CPU
cost. The cheap path is to lengthen evaluation_interval on the
group rather than shortening for:; the two are independent
knobs and serve different purposes. A common pattern:
groups:
- name: expensive-rollups
interval: 5m
rules:
- alert: SomethingOverThreshold
expr: <expensive>
for: 10m
keep_firing_for: 30m
The expensive expr runs every 5 minutes; the for: provides 10
minutes of dwell; keep_firing_for keeps the alert sticky
during a long incident. None of the three knobs fight each other.
Production guidance
- Start with
for: 5mfor user-impact alerts,for: 15mfor service-internal alerts. Tighten only after observing the noise pattern. - Set
keep_firing_foron rules where the expr can oscillate near the threshold during a long incident (error rates, saturation, capacity). Omit it where resolution should propagate immediately (TLS expiry, backup freshness). - Match
for:to the dominant noise period, not the scrape interval. Scrape blips are absorbed byfor:; longer noise needs a longerfor:. - Match
keep_firing_forto the dominant oscillation period during an incident. If you do not know, leave it unset and add it when the alert starts to flap.
Verification
- What transition does
for:gate, and what transition doeskeep_firing_forgate? - What happens to the
for:timer when the expr returns empty for one evaluation while the alert is in pending? - How does
evaluation_intervalinteract withfor:? Does a shorterevaluation_intervalfire the alert sooner? - When is
keep_firing_forharmful rather than helpful?
Quiz
Knowledge check · 8 questions
Q1. The for: clause delays the transition from which state to which state?
Q2. A rule has for: 5m and evaluation_interval: 30s. The earliest the rule fires after the expr first returns non-empty is:
Q3. A rule with for: 5m will fire if the expr returns non-empty for 4 minutes, then empty for 30 seconds, then non-empty for another 4 minutes.
Q4. keep_firing_for: 30m is most useful when:
Q5. Name one common cause of alert flapping even when for: 5m is set on the rule.
Q6. Which of these are valid values for the for: clause in Prometheus 2.55?
Q7. When for: is omitted from a Prometheus 2.55 alert rule, the default value is:
Q8. When the expr returns no data while an alert is in the pending state, Prometheus:
Passing score: 75%. Answers are checked in this browser.