ObservabilityLXXXIV · Configuration as CodeConfigAsCode
Alert Rules as Code
What you'll learn
- Structure recording_rules and alert rules as groups: blocks in versioned YAML files
- Run promtool check rules in CI to gate merges that touch rule_files
- Write a promtool unit-test fixture with input_series and alert blocks to verify a rule before deploy
- Use a canary Prometheus on a copy of production to verify a rule fires when expected
- Diagnose the four most common rule-file production failures: silent misfire, wrong `for:`, label drift, expression drift
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 new on-call rotation arrives. The first review of the rule
files is a directory listing of 23 YAML files across two
repositories. A team has been adding alerts.rules.yml blocks
every quarter; the average file is 380 lines; many of them have
duplicated alert names with subtly different thresholds. Three
HighErrorBudgetBurn rules exist with for: 5m, for: 1h, and
for: 6h, in that order. None of them is wrong in isolation; all
three together mean an error-budget burn fires three pages for the
same incident.
This is the failure shape that rules-as-code is designed to prevent. The rule files belong in one place; they pass a linter in CI; they are unit-tested against fixture data; they are deployed through the same discipline as the rest of the configuration.
What it is
Rules-as-code is the discipline of treating alert and recording
rules as a single versioned artefact that passes through code
review, CI validation, unit-test fixtures, and canary deploy before
it reaches production. In Prometheus 2.55.x, the artefact is a set
of YAML files under rule_files: in prometheus.yml. Each file
contains one or more groups: and each group contains a list of
rules: (alerts and recordings).
A rule file is the operative definition of “what does Prometheus page on?”. A change to the file is a change to the behaviour of the on-call rotation. That change goes through review like any other production code.
Why a sysadmin cares
The alert rules are the loudest part of observability. When they are wrong, two distinct symptoms appear:
- Spam. A rule that fires and stays firing for a transient
condition. The on-call engineer pages out, finds nothing, and
the rule still fires again the next morning. The fix is a
for:clause and a unit test that asserts the rule does not fire on a single-sample spike. - Silence. A rule that does not fire when it should. The on-call rotation never pages because the threshold is wrong, the metric name has drifted, or the label selector is too narrow. The fix is a unit test that asserts the rule does fire on the expected input, plus a canary that runs the new rule against a copy of production.
Both shapes are preventable. Both are amplified when the rule files are not under configuration management.
How it works
The mental model is “rules are data, evaluated every
evaluation_interval, with linter + unit-test gate before
deploy”:
rules/*.yml (in Git)
|
promtool check rules (CI gate)
|
promtool test rules (unit-test gate; runs fixtures)
|
canary Prometheus (smoke test against production data)
|
rule_files glob in prometheus.yml (prod)
|
Prometheus evaluates every group every evaluation_interval
|
Alerts fire -> Alertmanager -> on-call
Two details matter. First, promtool check rules parses every
rule file with the same loader Prometheus uses at runtime. CI
must run it on every pull request that touches a rule file. A
non-zero exit blocks merge.
Second, promtool test rules runs unit-test fixtures that
synthesise samples and assert the expected alerts. A fixture file
is YAML; it is checked into Git alongside the rules.
How to configure it
A working rule-file layout under rules/:
rules/
recording.rules.yml # pre-aggregations; SLI building blocks
alerts.sli.rules.yml # service-level alerts (latency, error rate, throughput)
alerts.slo.rules.yml # error-budget burn alerts
alerts.host.rules.yml # host-level alerts
rules-test/ # fixture files, one YAML per rule group
alerts.sli.test.yml
A recording rule:
groups:
- name: api.recording
interval: 30s # override the global evaluation_interval for this group
rules:
- record: api:request_error_rate:ratio_5m
expr: |
sum(rate(http_requests_total{job="api",code=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="api"}[5m]))
labels:
team: payments
sli: error_rate
An alert rule that depends on the recording rule:
groups:
- name: api.alerts
rules:
- alert: HighErrorRate
expr: api:request_error_rate:ratio_5m > 0.01
for: 5m
labels:
severity: critical
team: payments
sli: error_rate
annotations:
summary: 'api error rate above 1 percent'
description: 'See {{ $labels.instance }} — 5xx ratio is {{ $value | humanizePercentage }}.'
Three disciplines in the YAML above:
for: 5mmeans “the expression must be true for five minutes before firing”. Without it, a single spike pages out.- Labels under
labels:are routing labels. Alertmanager matches on them. Puttingseverity: criticalandteam: paymentshere is how routing trees select the right team. - Annotations under
annotations:are the only template the Alertmanager can render. The placeholders{{ $labels.* }}and{{ $value }}are mandatory for a useful page.
The CI gate:
.PHONY: rules
rules:
promtool check rules rules/*.yml
promtool test rules rules-test/*.yml
The full unit test for the alert above:
rule_files:
- ../alerts.sli.rules.yml
evaluation_interval: 1m
tests:
- interval: 1m
# synthetic series that crosses the 1 percent threshold at t=5m
input_series:
- series: 'http_requests_total{job="api",code="500"}'
values: '0+0x10'
- series: 'http_requests_total{job="api"}'
values: '100+0x10'
alert_rule_test:
- eval_time: 6m
alertname: HighErrorRate
exp_alerts:
- exp_labels:
severity: critical
team: payments
job: api
exp_annotations:
summary: 'api error rate above 1 percent'
Run it:
promtool test rules rules-test/alerts.sli.test.yml
How to validate it
Three validations, each in a different stage.
# 1. CI: rule file parses with the runtime loader
promtool check rules rules/*.yml
# 2. CI: unit-test fixtures pass
promtool test rules rules-test/*.yml
# 3. Prod: rule_files on disk matches the live config
ssh prom-prod-01 curl -fsS http://localhost:9090/api/v1/status/config \
| jq -r .data.yaml.original \
| grep -A2 "rule_files"
For canary, run a second Prometheus pointed at the new rules
against a read-replica of the production TSDB (or a remote-write
target that mirrors the same metrics). Watch the canary
/api/v1/rules for the expected alert name and confirm the
lastEvaluation advances.
How it can fail
Six concrete failure modes appear repeatedly.
- Duplicate alert names across files. Two rules with the
same
alert:name in different files. Prometheus loads both; Alertmanager receives both; the on-call sees two pages for the same condition. The fix is a CI regex that asserts alert names are unique across the rules directory. for:too short. Afor: 10son an error-rate rule fires on a single second of network noise. The fix is a CI rule that assertsfor:is at least1mfor any alert that derives from arate()over a5mwindow.expr:label selector too narrow. A rule scoped to a single instance rather than a job. When the instance rotates, the rule goes silent. The fix is a fixture that asserts the alert fires on multiple instances.- Recording rule expression drift. A recording rule that produces a metric name different from what the alert consumes due to a copy-paste typo. The alert never fires. The fix is unit tests that assert the recording rule produces the expected series name at the expected labels.
- Rule file in Git but not in
rule_filesglob. A new file is added torules/; the glob picks it up; everything is fine. The failure shape is the reverse: the file is added to the directory but is excluded by a stricter glob. The fix is to always glob the directory and never a single file. for:blocking the rule from firing during recovery. Settingfor: 0rather than removing the clause causes different behaviour from the absence offor:in some clients. The fix is to omit the clause when no hold-down is intended.
Security implications
Rule files do not expose new attack surface to the running service. The labels and annotations in a rule can contain user-supplied data only if the underlying metric labels contain it. The discipline:
- Do not interpolate untrusted text into
annotations. The$valueand$labels.*placeholders are safe; raw text from an HTTP query parameter is not. - Reviewers must confirm that label names used in alert routing
(e.g.
team) are sourced from a fixed set rather than from runtime cardinality. Otherwise, an alert can route to a team that does not exist. *.rules.ymlfiles belong under the sameCODEOWNERSdiscipline asprometheus.yml. Treating them as second-class configuration is the failure mode.
Performance implications
Rule evaluation is a CPU and memory cost on the Prometheus host. The big knobs:
evaluation_interval— halving it doubles the rule-evaluation cost. Recompute the cost when adding a group.- The complexity of
expr:— arate()over[5m]evaluated over a high-cardinality label set can dominate rule-evaluation cost. Limit label cardinality in the metric side; refactor long expressions into recording rules. - Recording rules reduce repeated evaluation cost. A recording rule that aggregates once per interval and is referenced by five alert rules is cheaper than five alert rules each running the same expression.
The file size itself is not the bottleneck. A 100 KiB rules file parses in milliseconds.
Production guidance
- One directory for rules. Globs in
rule_files. promtool check rulesin CI on every change.promtool test rulesin CI for every rule group.- Assert in CI: alert names are unique;
for:is at least1mon rate-based rules; expressions reference existing metric names. - Deploy rules through the same GitOps pipeline as
prometheus.yml. - Canary Prometheus runs the new rules against a copy of the workload before the canary is promoted to production.
Verification
You should now be able to answer:
- What is the role of
promtool check rulesandpromtool test rulesin a CI pipeline? - Why is
for: 1m(or longer) the right default for an alert that derives from arate(...)expression? - What is the failure shape when two rule files both declare the same alert name?
- What does a unit-test fixture file contain, and what does it assert?
Quiz
Knowledge check · 8 questions
Q1. Which CLI subcommand validates a Prometheus rule file against the runtime schema?
Q2. What does the `for:` clause do on an alert rule?
Q3. A rule file can pass promtool check rules and still produce wrong alerts in production if the expression is semantically wrong.
Q4. Which of these belong in a Prometheus rule unit-test fixture file?
Q5. Name the CLI command that runs a unit-test fixture against a rules directory.
Q6. Why prefer a directory glob (`rules/*.yml`) for `rule_files` in prometheus.yml?
Q7. Which block on a Prometheus alert rule attaches the labels Alertmanager uses for routing?
Q8. Alertmanager loads its routing tree directly from a rules/ directory under /etc/alertmanager.
Passing score: 75%. Answers are checked in this browser.