ObservabilityXIV · AggregationAggregation
sum() and avg()
What you'll learn
- Write correct sum() and avg() queries against labelled time series
- Explain why sum() of a raw counter is meaningless but sum(rate()) is correct
- Avoid the "sum of avg" trap when re-aggregating per-instance averages
- Recognise when avg() hides a distribution problem (latency, error rate by host)
- Configure recording rules that aggregate at the right boundary
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 Grafana panel sits at “instance CPU: 38%” and an on-call engineer
asks the obvious question: what is the fleet doing? The operator
opens the same panel scoped to job=api. The number is now 41%.
Two minutes later the alerting rule fires on “fleet CPU above 90%”
and the team starts paging — because someone summed per-instance
gauges with no divisor, and the panel is reporting the sum of
percentage points, not a fleet-wide percentage. The investigation
stalls.
Two aggregators cause this pattern more often than the rest: sum()
and avg(). They are simple to write, easy to misread, and the
boundary between “useful” and “wrong” lives in the metric type, the
label set, and whether the right thing was aggregated first.
What sum() and avg() are
Both are PromQL aggregation operators: functions that take a
vector of time series and return a smaller vector. The shape of
the output is controlled by an optional by or without clause
that names which labels survive the reduction.
sum(http_requests_total) # one series, no labels
sum by (job) (http_requests_total) # one series per job
sum by (instance) (rate(http_requests_total[5m])) # rps per instance
avg(node_load1) # mean across all series
avg by (job) (node_load1) # mean load per job
sum() adds the sample values across the grouped series at each
timestamp. avg() divides the sum by the count. Both preserve the
metric type for the output vector: summing counters gives a counter,
averaging gauges gives a gauge. They cannot change a counter into
a gauge or a histogram into a numeric value.
Why a sysadmin cares
Three production questions get asked over and over and only these two aggregators answer them cheaply:
- “What is the fleet-wide request rate right now?” —
sum(rate(...)) - “Which job is using the most memory per instance?” —
avg by (job) (memory_used_bytes) - “What is the cluster-wide error ratio?” —
sum(rate(errors)) / sum(rate(requests))
All three depend on getting the boundaries right. sum() of a raw
counter is nonsense (counters only make sense as rates). avg() of
gauges across instances with different sample windows is wrong.
sum() of averages, taken naively, produces a number unrelated to
the true total. Each of these traps has cost a team a half hour of
investigation in real incidents.
How it works
The mental model is a two-step pipeline:
vector of series --> grouping by label set --> reduce per group
|
sum() / avg()
|
v
one series per group
The grouping is implicit if you omit by / without: Prometheus
collapses every series into one. With a clause, it partitions the
input vector into buckets that share the listed labels (for by)
or share every label except the listed ones (for without), then
runs the reducer on each bucket.
sum by (instance) (rate(http_requests_total[5m]))
|
v
+----------------------+----------------------+
| instance=web01 | instance=web02 |
+----------------------+----------------------+
| 142 req/s | 137 req/s |
+----------------------+----------------------+
sum() and avg() are pure functions over the current value
at each timestamp. They do not look at history, do not smooth, and
do not respect the time range of an inner range vector. avg() of
a range vector is illegal: you must aggregate over time first
(avg_over_time()), then across series.
How to configure it
sum() and avg() are query-time operators. They are not
configured per se; they are written. The configuration that
matters is the recording rule that precomputes them.
The canonical production pattern is to aggregate at the lowest boundary that has a label set, expose a recording rule at that level, and then aggregate the recording rule further up.
# /etc/prometheus/rules/aggregation.yml
groups:
- name: per-instance
interval: 30s
rules:
# Per-instance request rate. One series per (job, instance).
- record: instance:http_requests:rate5m
expr: sum by (job, instance) (rate(http_requests_total[5m]))
# Per-instance average latency is dangerous. We compute the
# histogram quantile per-instance instead and expose that.
- record: instance:http_request_duration:p99
expr: |
histogram_quantile(
0.99,
sum by (job, instance, le) (rate(http_request_duration_seconds_bucket[5m]))
)
- name: per-cluster
interval: 30s
rules:
# Fleet request rate: roll up the per-instance recording rule.
- record: job:http_requests:rate5m
expr: sum by (job) (instance:http_requests:rate5m)
# Cluster total request rate: no `by` clause, collapses to one.
- record: cluster:http_requests:rate5m
expr: sum(instance:http_requests:rate5m)
The naming convention level:metric:operation is the Prometheus
community standard. The level (instance, job, cluster) is
the boundary of the aggregation; the operation is the reducer; the
metric name is in the middle. Dashboards reference the recording
rule, not the raw metric.
How to validate it
Validate in three places: the rule file, the running Prometheus, and the resulting series.
# 1. Static check of the rule file syntax.
promtool check rules /etc/prometheus/rules/aggregation.yml
# SUCCESS: /etc/prometheus/rules/aggregation.yml
# 2. Confirm the recording rule is loaded.
curl -s http://prometheus:9090/api/v1/rules \
| jq '.data.groups[].rules[] | select(.type=="recording") | .name'
# 3. Confirm the recording rule emitted a series.
curl -s 'http://prometheus:9090/api/v1/query?query=cluster:http_requests:rate5m' \
| jq '.data.result[] | {metric, value}'
# {
# "metric": {},
# "value": [1754918400, "1287.45"]
# }
The third step is the one that matters. A rule that loaded without
error but emits no series is a rule whose expression never matched
the input — for example, a sum by (instance) over a metric that
no longer carries an instance label. The dashboard that consumes
the recording rule will be empty.
How it can fail
The high-frequency failure modes specific to sum() and avg():
- Sum of a raw counter.
sum(http_requests_total)returns the total number of requests since each instance started. The number grows without bound, drops when instances restart, and is not a rate. The dashboard line keeps climbing. The on-call engineer assumes traffic is growing. - Average of an already-averaged latency.
avg by (instance) (rate(...))followed byavg(...)at the cluster level averages averages. A 90th-percentile latency reported as “average latency” is meaningless; the operator looks at the dashboard, sees 80ms, and misses a 4-second tail that affects one in ten requests. The investigation finds the slow requests three hours later in customer reports. - The “sum of avg” trap.
sum(avg by (instance) (x))is not equal tosum(x)when theinstancelabel set differs across calls. If the inneravg by (instance)is computed over ten instances, and the outersumis computed after some instances have been filtered out, the total is the sum of the survivors, not the original. The dashboard under-reports whenever a host is missing. - Average across heterogeneous units.
avg(node_load1)mixes the 1-minute load average across hosts with different core counts. A 32-core host at load 4.0 and a 4-core host at load 4.0 are in different states, butavgreports 4.0 as the answer. The team adds a “normalised load” recording rule that divides bycount(node_cpu_core), and the value moves from 4.0 to 0.7. The dashboard was wrong the entire time. - Label drop on aggregation.
sum by (job) (rate(...))drops theinstancelabel. The result has one row per job, not one per host. The dashboard panel that says “drill down by instance” has nothing to drill down into. The operator investigates the wrong shape. - Empty result silently emitted.
sum(...)over an empty selector returns an empty vector. The dashboard panel renders as “No data” and is indistinguishable from a scrape failure. Alerting rules built on empty vectors areabsent()conditions, notfor:clauses — they fire the moment the selector has nothing to match.
How to troubleshoot it
A disciplined order, applied to any panel that “looks off”:
- Inspect the query. What is being summed or averaged? Is the
inner expression a counter, a rate, a gauge, or a histogram?
Counters must be wrapped in
rate()orirate()first. - Inspect the label set. Run the query without
sum()/avg()and inspect what labels the input carries. Addby (X, Y, Z)to the aggregation and confirm that the resulting series have exactly those labels and no others. - Cross-check against the source. Pick a recent time window.
Sum the raw counter in the database (e.g.,
increase(counter[5m])over the window). Compare tosum(rate(counter[5m])) * 300. The values should match within rounding. - Inspect
prometheus_tsdb_head_seriesand rule evaluation logs. A recording rule that evaluates but emits nothing has a label-mismatch problem, not a syntax problem. - Test the rule offline.
promtool test rulesruns a YAML suite of fixtures against the rule file. Add a fixture for every rule whose result is on a dashboard.
Security implications
Aggregation does not, in itself, change the attack surface. The rules to remember:
- The
/api/v1/queryendpoint that runssum()andavg()is the same endpoint that runslabel_values()and other cardinality-blowing functions. Treat it as an authenticated surface; the security part of this course covers the reverse proxy and basic-auth layers. - Recording rules with weak
byclauses produce series whose label sets are predictable. Do not rely oninstancebeing present in a recording rule if the producer does not guarantee it. Production instrumentation guides the security review of the data model separately. - Aggregation that drops labels can collapse two distinct customers into one series, which leaks cardinality across tenant boundaries. The multi-tenancy part of the course returns to this.
Performance implications
Three knobs matter:
- Cardinality. A
sum by (instance)over a 10,000-host fleet produces 10,000 series. Asum(...)(no clause) produces one. The cost is in the series count, not in the reducer. - Rule evaluation interval. Recording rules that aggregate frequently are recomputed frequently. The default 1-minute interval is fine for most rollups; tighten to 15s for SLO burn-rate panels only.
- Query cost on a dashboard. A panel that sums 100,000 series every refresh is a panel that re-evaluates the full scrape cache slice every refresh. Move the panel to a recording rule; have the dashboard read the precomputed series.
Verification
You should now be able to answer:
- Why is
sum(counter)wrong, and what is the right form? - What does
by (X)do, and what labels survive? - What is the “sum of avg” trap, and how do you avoid it?
- When is
avg()a misleading number, and what should you plot alongside it?
Quiz
Knowledge check · 8 questions
Q1. Which query is correct for "fleet-wide request rate per second"?
Q2. What is wrong with sum(avg by (instance) (x))?
Q3. sum by (instance) (rate(http_requests_total[5m])) drops the job label.
Q4. Which of these are reasons avg() can mislead an operator?
Q5. Name the Prometheus convention for naming a recording rule that rolls up a metric at a boundary.
Q6. A recording rule evaluates successfully but emits no series. What is the most likely cause?
Q7. avg() of a range vector is valid PromQL.
Q8. Which is the right place to aggregate for fleet-wide dashboards?
Passing score: 75%. Answers are checked in this browser.