ObservabilityXVII · Recording RulesRecordingRules
Naming Recording Rules
What you'll learn
- Apply the level:metric:operations naming convention to a new recording rule
- Identify when the rule name implies a different aggregation level than the expression actually produces
- Recognise and avoid level explosion in deeply nested recording rule stacks
- Use the rule name to spot recursive dependencies between rules in code review
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
At 09:30 the on-call engineer opens the per-job dashboard. A panel
that should be one series per job reads one series per instance —
a thousand series where there should be twelve. The dashboard
freezes for thirty seconds. The cause is a recording rule whose
name says job:... but whose sum by retains instance. The
naming convention would have caught this in code review. The team
does not have a convention.
A recording rule’s name is a contract with its consumers. The
name tells the consumer what level of aggregation to expect, what
metric to look for, and what operations have been applied. A team
that documents the convention and enforces it in code review gets
the right division of labour for free. A team without one
accumulates a graveyard of rules named metric_a_v2 that no one
can decode at 03:00.
What it is
The convention used by every production Prometheus deployment
that survives more than two teams is level:metric_name:operations.
The Prometheus documentation calls this the level-metric-label
form. The RunBook Academy version extends it to four components
with a fixed rate-window suffix; the mnemonic is the 1-5-15
rule:
job : http_requests_total : rate5m : 1m
^ ^ ^ ^
| | | |
1 level 5 metric name 15 ops rate window
1level — the highest label in thesum byclause, or the original metric when no aggregation is performed. Common values:job,instance,pod,node,cluster,tenant.5metric name — the underlying metric the rule reads. The number 5 is a mnemonic, not a length limit; the metric name may be longer. The intent is: one source metric per rule.15operations — the aggregation functions and their windows, in canonical order. Common suffixes:rate5m,irate1m,sum,count,max,p99,sum_rate5m,count_over_time_5m. The number 15 is again a mnemonic; the suffix is bounded by what consumers can read.- rate window — the trailing
:1mis optional but recommended. It is the time window used by the rule’s outer aggregation. Useful when the rule is one layer in a recursive stack; the consumer can read the freshness from the name.
The canonical example:
job:http_requests_total:rate5m
Means: the per-job rate, over a five-minute window, of the
http_requests_total counter.
A rule whose expression keeps the instance label would be
named:
instance:http_requests_total:rate5m
The naming and the expression agree. A consumer that reads
job:http_requests_total:rate5m and gets an instance label
back has hit the convention violation.
Why a sysadmin cares
The naming convention is the contract between the rule and its consumers. Three operational consequences follow.
- Recursive rule behaviour is legible. A rule that consumes
another rule’s output is named by adding a layer to the
front of the metric name. The stack
cluster:job:instance:http_requests_total:rate5mreads as: “the cluster-level sum of the per-instance rate ofhttp_requests_total”. A reviewer can read the stack and verify the layers match the expressions. - Stack visibility in dashboards. Grafana’s panel query inspector lists the recorded metric names in use. A team that uses the convention reads the dashboard and knows what aggregation level each panel is using. A team without the convention reads a wall of metric names and has to click through to find out.
- Code review catches aggregation drift. A PR that adds a
rule named
job:http_requests_total:rate5mwith the expressionsum by (job, instance) (...)is rejected on review because thesum byretains a label the name does not advertise. Without the convention, the PR passes and the dashboards break.
A team that adopts the convention spends an hour documenting it and saves a year of incident-time confusion.
How it works
The convention has four parts. Each part corresponds to a specific piece of the rule’s expression.
job : http_requests_total : rate5m : 1m
| | | |
| | | +-- rate window (optional)
| | +-- operations (aggregation + window)
| +-- source metric name
+-- aggregation level (the highest label in `sum by`)
The level maps to the sum by (...) clause (or, when there is
no aggregation, to the original metric’s label set):
| Expression | Level |
|---|---|
rate(http_requests_total[5m]) | job and instance (untouched) |
sum by (job) (rate(http_requests_total[5m])) | job |
sum by (instance) (rate(http_requests_total[5m])) | instance |
sum by (cluster, job) (rate(http_requests_total[5m])) | cluster (highest) |
The metric name is the underlying metric, unmodified. Do not abbreviate, do not singularise. The operations are listed in canonical order: outer function first, then inner functions and their windows.
Examples
groups:
- name: api-recording
interval: 30s
rules:
# Per-job request rate over 5 minutes
- record: job:http_requests_total:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
# Per-instance request rate over 5 minutes
- record: instance:http_requests_total:rate5m
expr: sum by (job, instance) (rate(http_requests_total[5m]))
# Cluster-level aggregation of per-job rate
- record: cluster:job:http_requests_total:rate5m:sum
expr: sum by (cluster) (job:http_requests_total:rate5m)
# Per-job p99 latency over 5 minutes
- record: job:http_request_duration_seconds:p99
expr: |
histogram_quantile(
0.99,
sum by (job, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
# Per-route error ratio over 5 minutes
- record: job:http_requests:errors:ratio_rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
Each rule’s name tells the consumer what aggregation level to
expect, what metric to look for, and what operations have been
applied. A consumer can read the stack
cluster:job:http_requests_total:rate5m:sum and verify the
expression does what the name says.
The level-explosion warning
The convention encourages recursion: a rule that aggregates the
output of another rule by adding a layer to the front of the
metric name. The recursion has a ceiling. Each layer that adds a
new label dimension multiplies the output series count by the
cardinality of that label. A rule stack that goes
cluster:zone:node:pod:container:http_requests_total:rate5m
produces series per (cluster, zone, node, pod, container)
tuple. At twenty clusters, ten zones per cluster, fifty nodes
per zone, twenty pods per node, and three containers per pod,
the stack produces sixty thousand series for a single metric.
The level-explosion failure is silent. The stack is well-named; each layer is a valid rule; the expressions are correct. The TSDB absorbs the series. Weeks later, the team hits an OOM and the recording rules are the last place anyone looks because they were “obviously fine”.
How to configure it
The configuration is the rule file. The convention is enforced in review, not in the file.
# /etc/prometheus/rules/api.recording.yml
#
# Naming convention: level:metric:operations:rate_window
#
# Owners: team-payments (this file)
# Source metric docs: https://runbook.example.com/metrics/http
# Consumers:
# - dashboard: latency-overview (panels 3, 5)
# - alert: HighErrorRate
# - SLO rule: job:api:errors:ratio_rate5m (lesson 06)
groups:
- name: api-recording
interval: 30s
rules:
- record: job:http_requests_total:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
- record: job:http_request_duration_seconds:p99
expr: |
histogram_quantile(
0.99,
sum by (job, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
- record: job:http_requests:errors:ratio_rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
A header comment at the top of the file documents:
- The convention used (
level:metric:operations:rate_window). - The team that owns the file.
- The source metric contract (which metrics this file expects to exist and how they should be labelled).
- The consumers (which dashboards, alerts, and SLO rules depend on this file).
Lesson 05 covers the file-organisation discipline in depth.
How to validate it
Three checks.
1. promtool validates the file.
promtool check rules /etc/prometheus/rules/api.recording.yml
A passing check confirms the YAML parses and every expression is valid PromQL. It does not check the naming convention; that is the linter’s job.
2. The convention is enforced by a linter in CI.
A simple check that catches the most common violation (name claims one level, expression retains another):
# Example shell-based linter. Replace with a proper parser in
# production. (CONFIGURATION)
for f in /etc/prometheus/rules/*.yml; do
python3 - "$f" <<'PY'
import sys, yaml, re
path = sys.argv[1]
with open(path) as fh:
doc = yaml.safe_load(fh)
for group in doc.get("groups", []):
for rule in group.get("rules", []):
if "record" not in rule:
continue
name = rule["record"]
expr = rule["expr"]
# Extract the level prefix (first colon-separated token).
claimed = name.split(":")[0]
# Extract the labels retained in `sum by (...)`.
m = re.search(r"sum\s+by\s*\(([^)]*)\)", expr)
kept = [l.strip() for l in (m.group(1) if m else "").split(",") if l.strip()]
highest = kept[-1] if kept else "<untouched>"
if claimed != highest and not (claimed == "instance" and "instance" not in kept):
print(f"{path}: {name} claims level {claimed} but expression keeps {kept}")
PY
done
A clean run produces no output. Any line that prints is a convention violation.
3. The rule appears under the expected name.
curl -s http://localhost:9090/api/v1/rules \
| jq -r '.data.groups[].rules[] | select(.type=="recording") | .name' \
| grep '^job:http_requests_total:rate5m$'
A matching line confirms the rule loaded under the expected name. A missing line means the file failed validation or the rule crashed on load.
How it can fail
Six failure modes, in the order they appear in code review.
- Name claims a coarser level than the expression keeps. A
rule named
job:http_requests_total:rate5mhas the expressionsum by (job, instance) (...). The dashboards that expect a per-jobpanel now see one series per(job, instance)tuple. Symptom: a dashboard that worked yesterday renders a thousand panels today. - Name claims a finer level than the expression has. A rule
named
instance:http_requests_total:rate5mhas the expressionsum by (job) (...). The recorded metric collapses all instances into thejobdimension. Symptom: per-instance dashboards show one line per job — a flat line that hides the bad instance. - Level explosion through deep recursion. A team builds a
stack
region:zone:cluster:job:instance:http_requests_total:rate5m:sum:sum:sum:sumthat produces one series per(region, zone, cluster, job, instance)tuple. Symptom: the TSDB series count climbs without bound; OOM in weeks. - Name collision between teams. Two teams add a rule named
job:http_requests_total:rate5min different files. Only one survives; Prometheus logs a duplicate-name error. The losing team’s dashboards and alerts read the winner’s expression — possibly with the wrong semantics. - Rate window missing. A rule named
job:http_requests:ratehas the expressionrate(http_requests_total[1m]). The window is not in the name. Consumers cannot tell from the name what window the rate covers; changing the window in the expression is a silent behaviour change. - Operations out of canonical order. A rule named
job:http_requests:5m_rateputs the window before the function. The name still parses; the convention does not match. Reviewers spot it; grep for it later is harder.
How to troubleshoot it
When a recorded metric does not match what the consumer expects, the diagnosis order matters.
- Confirm the name and expression agree. Open the rule
file. Read the name. Read the
sum byclause. The highest label insum byshould equal the level prefix in the name. - Confirm the output labels match the name’s level.
Expected:curl -s 'http://localhost:9090/api/v1/query?query=job_http_requests_total_rate5m' \ | jq '.data.result[].metric | keys'["job"]. Anything else is a violation. - Confirm the rule’s stack is bounded. List the recorded
metrics with their prefix lengths:
A rule with more than five colon-separated layers is a review candidate.curl -s http://localhost:9090/api/v1/rules \ | jq -r '.data.groups[].rules[] | select(.type=="recording") | .name' \ | awk -F: '{print NF-1, $0}' | sort -rn | head - Confirm the rate window is in the name. For rate-based rules, the window should appear in the operations suffix.
- Confirm no two rules share a name. The linter in the
validation step above catches this; a manual check is
grep -rh '^ - record:' /etc/prometheus/rules/ | sort | uniq -d. - Confirm cardinality is bounded. Check
prometheus_tsdb_head_seriesafter the rule is loaded; a sudden jump is a level explosion.
Security implications
The naming convention does not introduce a new attack surface.
The rule name is stored in the TSDB alongside the series and
exposed through /api/v1/rules and /api/v1/series. Three
considerations apply.
- Names leak through
/api/v1/rules. Anyone with HTTP access to the Prometheus API can read the rule names and expressions. A naming convention that uses internal codenames (internal_payment_v3) leaks more than a convention that uses the source metric (http_requests_total). Use the source metric name. - Names leak through
/api/v1/label/{name}/values. A query for__name__values returns every metric name in the TSDB, including recording rule outputs. The convention does not change this; it makes the leak more uniform. - Naming consistency aids review. A team that adopts the convention can review a PR for naming violations quickly. A team without one cannot, and security-relevant label changes slip through.
Performance implications
The naming convention is free at evaluation time; the cost is at TSDB-write time and storage time.
- TSDB write: one write per output series per evaluation. Naming does not change the count; it changes only the index key.
- TSDB storage: proportional to series count and retention. A level-exploded stack with sixty thousand series writes sixty thousand samples per evaluation.
- Query performance: a recorded metric whose name reflects its aggregation level is easier to grep for in dashboards and rules, which means review cycles are faster. Naming itself does not change query cost.
The level-explosion failure mode is the performance hazard. The convention does not prevent it; it makes the stack visible enough that a reviewer can catch it.
Production guidance
- Document the convention in the team’s instrumentation guide. The first paragraph should be the convention; the rest should be examples.
- Enforce it with a linter in CI. A shell script that
compares the level prefix to the
sum byclause is enough to catch the most common violation. Replace it with a proper PromQL parser if the rule set grows. - Bound the level depth at review. A rule stack with more than four or five colon-separated layers is a candidate for splitting into a separate metric. The convention makes the depth visible.
- Use the source metric name unmodified. Do not
abbreviate, do not singularise. The metric
http_requests_totalis a contract with the exporter; renaming it in the recorded metric breaks the contract. - Include the rate window in the operations suffix. A name that includes the window makes it visible to reviewers and consumers.
- Document consumers in the file header. A rule whose consumers are listed is easier to deprecate when the rule is no longer needed.
Verification
You should now be able to answer:
- What are the four components of the level:metric:operations convention, and what does each component mean?
- How does the level prefix relate to the
sum byclause in the expression? - What is the level-explosion failure mode, and how does the convention make it visible?
- How does the naming convention help reviewers spot recursive dependencies between rules?
Quiz
Knowledge check · 8 questions
Q1. Which of the following is the correct name for a rule with the expression `sum by (job) (rate(http_requests_total[5m]))`?
Q2. A rule is named `job:http_requests_total:rate5m` but its expression is `sum by (job, instance) (rate(http_requests_total[5m]))`. What is the operational problem?
Q3. The naming convention is enforced by the Prometheus server, which rejects rules whose name does not match their `sum by` clause.
Q4. A team builds a rule stack with six colon-separated layers: `region:zone:cluster:job:instance:http_requests_total:rate5m:sum:sum:sum:sum`. What is the production hazard?
Q5. Which part of the naming convention records the rate window used by the rule?
Q6. Which of the following help a team enforce the naming convention? (Select all that apply.)
Q7. Two teams both add a rule named `job:http_requests_total:rate5m` in different files. What happens?
Q8. A recorded metric named `instance:node_cpu_seconds_total:rate5m` has the expression `sum by (job) (rate(node_cpu_seconds_total[5m]))`. Which label set will the output actually carry?
Passing score: 75%. Answers are checked in this browser.