ObservabilityXVIII · Alerting RulesAlertingRules
Rule Lint and Review
What you'll learn
- Run promtool check rules against an alert rule file and interpret the exit code
- Write a unit-test file for a rule using promtool test rules with mock time series
- Wire a GitHub Actions pipeline that runs promtool check and test on every pull request
- Schedule a review cadence that catches stale thresholds and missing runbooks
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
A team inherits 240 rules from a contractor who left two years ago. Half have no runbook URL. A third reference metrics that no longer exist. None have unit tests. The team spent six weeks auditing the rule set by hand, and burned three quarters of that auditing rules that turned out to be wrong in obvious ways a static check would have caught. The lesson is that the discipline of linting and reviewing rules is the same discipline as linting and reviewing code: cheap to automate, expensive to skip.
What it is
Rule lint is the set of static and dynamic checks that confirm a rule is syntactically valid, semantically correct, and operationally complete before it reaches production. The three layers:
- Static check —
promtool check rulesparses the YAML, validates the PromQL expression, and reports parse errors. This is the cheap, fast layer that catches typos and structural mistakes. - Unit test —
promtool test rulesevaluates the rule against synthetic time series and asserts the expected alert state at specified timestamps. This is the layer that catches semantic mistakes: a rule that parses but never fires, or fires under conditions it should not. - Review — a human reviewer checks the operational
completeness: runbook URL, dashboard URL, severity label,
team label,
for:value, threshold justification. This is the layer that catches the omissions no tool can detect.
A team that adopts all three layers catches the rule problems that one or two of the layers miss.
Why a sysadmin cares
Every alert rule is a piece of production code. It runs every 30 seconds. Its output reaches the on-call rota. A bug in a rule is a production incident waiting to happen: the rule either misses a real outage or pages the rota for a non-event. The cost of either mistake is measured in minutes per page and in credibility with the rota.
The review cadence matters because rules decay. The threshold that was correct two years ago is too low now (traffic grew) or too high (the codebase changed). The runbook URL that pointed at a Confluence page now points at an archive. The team label that matched the team two years ago now matches a different team. A scheduled review catches the decay; an ad-hoc review lets it accumulate.
How it works
The three layers fit into a CI pipeline:
pull request opened
|
v
promtool check rules <-- static parse, PromQL validate
|
v
promtool test rules <-- synthetic series, expected alerts
|
v
human review <-- runbook, dashboard, severity
|
v
merge to main
|
v
deploy + SIGHUP Prometheus
|
v
recurring review (quarterly) -- check stale thresholds, dead runbooks
A pull request that fails any of the first three layers does not merge. The recurring review is separate from the PR workflow; it catches problems that grew over time, not problems that were introduced in the latest change.
How to configure it
Two halves: the rule file and the test fixture.
The rule (a worked example, abridged):
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
labels:
severity: critical
team: checkout
annotations:
summary: 'orders-api 5xx ratio above 5% in {{ $labels.region }}'
runbook_url: 'https://runbooks.example.com/checkout/orders-api-5xx'
The test fixture, in test/orders-api_test.yml:
rule_files:
- ../orders-api.yml
evaluation_interval: 30s
tests:
# Test 1: a 4-minute breach should NOT fire (within for: dwell).
- interval: 30s
input_series:
- series: 'http_requests_total{service="orders-api",region="eu-west-1",status="200"}'
values: '0 100 100 100 100 100 100 100 100'
- series: 'http_requests_total{service="orders-api",region="eu-west-1",status="500"}'
values: '0 1 1 1 1 1 1 1 1'
alert_rule_test:
- eval_time: 4m
alertname: OrdersApiHighErrorRate
exp_alerts: []
# Test 2: a 6-minute breach SHOULD fire.
- interval: 30s
input_series:
- series: 'http_requests_total{service="orders-api",region="eu-west-1",status="200"}'
values: '0 100 100 100 100 100 100 100 100'
- series: 'http_requests_total{service="orders-api",region="eu-west-1",status="500"}'
values: '0 10 10 10 10 10 10 10 10'
alert_rule_test:
- eval_time: 6m
alertname: OrdersApiHighErrorRate
exp_alerts:
- exp_labels:
severity: critical
team: checkout
service: orders-api
region: eu-west-1
exp_annotations:
summary: 'orders-api 5xx ratio above 5% in eu-west-1'
runbook_url: 'https://runbooks.example.com/checkout/orders-api-5xx'
Three things to notice:
- The
input_series:block defines synthetic time series. The first test has a 1% error rate (1 in 100), which is below the 5% threshold; the alert should not fire at 4m. The second test has a 10% error rate (10 in 100), which is above; the alert should fire at 6m. - The
alert_rule_test:block asserts the alert state at a specificeval_time.exp_alerts:is the expected list; an empty list means no alert should exist. - The
exp_labels:andexp_annotations:blocks confirm the rule produces the labels and annotations the Alertmanager routes expect.
The CI pipeline (GitHub Actions, abridged):
name: rules
on:
pull_request:
paths:
- 'rules/**'
- '.github/workflows/rules.yml'
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: promtool check
uses: prometheus/promtool-github-action@v0.1.0
with:
arguments: check rules rules/
- name: promtool test
run: |
for f in rules/test/*.yml; do
echo "Testing $f"
promtool test rules "$f"
done
Two checks. The first uses a published action that installs
promtool and runs check rules. The second walks every test
file under rules/test/ and runs promtool test rules against
each. A failure on either step blocks the merge.
How to validate it
Three checks. The first is the static check; the second is the unit test; the third is the live confirmation.
# 1. Static check. Runs in milliseconds.
promtool check rules /etc/prometheus/rules/orders-api.yml
Expected output:
Checking /etc/prometheus/rules/orders-api.yml
SUCCESS: found 1 rules, 1 alerts
A non-zero exit with a parse error means the rule will not load. Fix the YAML and rerun.
# 2. Unit test. Runs in milliseconds against synthetic data.
promtool test rules /etc/prometheus/rules/test/orders-api_test.yml
Expected output:
SUCCESS
A non-zero exit with a test failure names the failing test
(Test 2: 6m breach should fire) and the actual alert state
versus the expected. Fix the rule or the test until they match.
# 3. Live confirmation after deploy. Same checks as lesson 01.
curl -s http://prometheus:9090/api/v1/rules \
| jq '.data.groups[].rules[] | select(.name == "OrdersApiHighErrorRate")
| {state, lastEvaluation}'
Expected output:
{
"state": "inactive",
"lastEvaluation": "2026-08-13T04:00:00.000Z"
}
state: inactive is fine; the rule is loaded and the expr
returned no series.
How it can fail
Six failure modes:
-
promtool check rulespasses but the rule never fires in production. Symptom: rule loaded, expr returns data, but/api/v1/alertsis empty. Cause: the rule’sfor:is larger than the longest window in the unit test, so the test never observed the firing state. Lengthen the test window or setfor:shorter for the test. -
Unit test passes but the rule misroutes. Symptom: the alert fires but the Alertmanager catch-all picks it up. Cause: the rule’s
labels:block was not asserted inexp_labels:. Addseverity,team, andservicetoexp_labels:so the test fails if the labels regress. -
CI runs
promtoolfrom a different version than production. Symptom:check rulespasses in CI but the rule fails to load in production. Cause: the CI image pins promtool 2.54 and production runs Prometheus 2.55. Pin the same version (or a version known to be compatible) in both. -
No unit test for a rule that mutates labels. Symptom: a rule that adds a label in
labels:goes un-reviewed because the test file does not cover the labels block. Cause: the rule author skipped the test. Add a CI step that fails when a rule is added without a matching test file. -
Review checklist is not enforced. Symptom: rules reach production without a runbook URL, because the checklist is a wiki page nobody reads. Cause: the checklist is not enforced by tooling. Encode the checklist in a linter (mixin, custom script) so a missing runbook URL fails the build.
-
No recurring review. Symptom: a rule that was correct two years ago has a threshold that no longer matches the traffic shape. Cause: nobody re-reads the rules after the initial merge. Schedule a quarterly review and assign owners.
How to troubleshoot it
In order:
- Did
promtool check rulespass? If not, the rule does not parse. Fix the YAML and rerun. - Did
promtool test rulespass? If not, the test failed. The error message names the failing test and the actual versus expected state. Adjust the rule or the test until they match. - Did the rule load in Prometheus?
/api/v1/rules. Missing rule: fix the glob andSIGHUP. - Does the expr return data in production? Compute it in Grafana Explore. Empty result: the metric is missing or the selector is wrong.
- Does the alert reach the right receiver? Check
Alertmanager
/api/v2/alertsfor thereceivers[]field. - Is the recurring review happening? Confirm the review calendar entry exists; if it does not, schedule it.
Security implications
Rule lint and review are not security-sensitive on their own. The risks are operational and indirect:
- A unit-test fixture that includes real-looking secrets (an
API key used in a
runbook_url) commits those secrets to the repo. Use placeholder values and confirm with a secret scanner. - A review that approves a rule without checking the
runbook_urlcan leak the existence of an incident to a third-party wiki. Confirm the URL points at trusted infrastructure.
Performance implications
Lint and unit tests run in milliseconds. The cost is in CI
minutes, not in production CPU. A pipeline that runs
promtool check rules and promtool test rules on every pull
request adds seconds to the CI run, not minutes. The cost is
worth it for the mistake prevention.
A unit-test fixture that uses a very long input_series (more
than a few hundred samples) slows the test. Keep test fixtures
small: enough samples to exercise the rule, no more.
Production guidance
- Adopt the three layers. Static check first; unit test second; human review third. Do not merge without all three.
- Pin
promtoolin CI to the same version as production. A two-version drift catches you out. - Encode the human-review checklist in a linter so missing runbook URLs fail the build, not just the reviewer.
- Schedule a recurring review (quarterly is a common cadence) and assign owners per rule group.
Verification
- What is the difference between
promtool check rulesandpromtool test rules? - What four blocks make up a
promtool test rulesfixture? - What is the minimum CI step to add to a pull request pipeline for an alert rule change?
- Why does a static check not catch every rule mistake, and what is the role of the human review?
Quiz
Knowledge check · 8 questions
Q1. The command promtool check rules path/to/rules.yml exits non-zero when:
Q2. promtool test rules differs from promtool check rules in that it:
Q3. Rule changes should be reviewed by at least one engineer who is not the author and who is on the current on-call rotation.
Q4. The minimum pre-commit check for an alert rule change in a CI pipeline is:
Q5. Name two blocks that a unit-test fixture for promtool test rules typically defines.
Q6. Which of these are useful checks in a rule-review checklist?
Q7. A dry-run evaluation of a new rule before production should use:
Q8. When a rule has been firing without ever resolving for 30 days, the review cadence should call for:
Passing score: 75%. Answers are checked in this browser.