ObservabilityXLVII · Trace QueriesTraceQueries
TraceQL Aggregations
What you'll learn
- Use the count aggregator to keep traces with at least one matching span
- Group spans by an attribute with the by clause to break a result down
- Choose between count, avg, sum, max, and min for the question being asked
- Recognise the cardinality trap when grouping by a high-cardinality attribute
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
\{ resource.service.name = "checkout-api" && status = error \} | count() > 0 returns the trace IDs of every trace whose
checkout path produced at least one error span. Without the
aggregator, the same query returns the error spans themselves;
with by (resource.service.name), the result breaks down to “how
many error traces per service”. This lesson is about the pipeline
stages that turn a selector into a count.
What it is
A TraceQL aggregator is a pipeline stage that collapses a spanset (the spans that survived the prior stages in one trace) into a single value. The current set of aggregators is:
Aggregator Purpose Example
----------- --------------------------------------- --------------------------------
count() Number of spans in the spanset { ... } | count() > 3
avg(field) Average of a numeric attribute { ... } | avg(span:duration) > 100ms
sum(field) Sum of a numeric attribute { ... } | sum(span.bytes)
max(field) Maximum value of a numeric attribute { ... } | max(span:duration) > 2s
min(field) Minimum value of a numeric attribute { ... } | min(span:duration) < 5ms
The aggregator is followed by a comparator and a literal to keep or discard traces:
# Traces with more than 10 spans
{ } | count() > 10
# Traces whose average span duration exceeds 100ms
{ } | avg(span:duration) > 100ms
# Traces whose largest span exceeds 2 seconds
{ } | max(span:duration) > 2s
# Traces that total more than 1 GB of bytes processed
{ } | sum(span.bytesProcessed) > 1000000000
The by clause groups spans by an attribute before the aggregator
runs. The result is per-group: one row per distinct value of the
attribute.
# Count of error traces per service
{ status = error } | by(resource.service.name) | count() > 0
# Average duration per pod
{ resource.service.name = "checkout-api" } | by(resource.k8s.pod.name) | avg(span:duration) > 100ms
Without by, the aggregator produces one value per trace. With
by, it produces one value per group of spans inside the trace.
Why a sysadmin cares
A selector finds spans. An aggregator turns spans into a number. That conversion is the moment TraceQL stops being a search engine and becomes a measurement. Three operational reasons aggregators matter:
- Filtering by quantity, not by presence. “Traces with more than 50 spans” is a different question from “traces that contain this attribute”. Aggregators answer the first.
- Breakdowns by attribute. “How many error spans per
service” is the question every on-call engineer asks during a
multi-service incident. The
byclause answers it in one query without scanning every trace. - Pre-aggregated metrics. A TraceQL query with
by (resource.service.name) | count()produces a per-service count. Combined with a Prometheus recording rule that scans the result periodically, this is a slow but exact span-derived metric that does not require a separate metrics-generator.
How it works
A TraceQL pipeline is a sequence of stages. Each stage takes a spanset and returns either a smaller spanset or a single value:
{ ... } selector keeps matching spans
{ ... } | count() aggregator collapses to one number per trace
{ ... } | by(...) grouping buckets the spanset by attribute
{ ... } | topk(10) selection picks the top K groups
The grouping stage runs before the aggregator. Without by, the
spanset inside one trace is aggregated together. With by, the
spanset is split into one subset per distinct value of the named
attribute, and the aggregator runs once per subset:
{ status = error }
|
v
by(resource.service.name)
|
+-- service=checkout-api --> count() = 12
+-- service=payments --> count() = 8
+-- service=inventory --> count() = 3
|
v
One result row per group
The comparator at the end filters groups, not traces. A query
like by(resource.service.name) | count() > 5 keeps only the
groups whose count exceeds 5.
Under the hood
How to configure it
Aggregators are query-side; no Tempo configuration is required to
use them. The relevant configurations are the same as in lessons
01 and 02: querier.max_query_length and the query-frontend
cache.
One config that affects aggregator behaviour is the search-result cache TTL on the query-frontend. A Grafana panel that groups by service benefits from a longer TTL (60 seconds); an investigation query benefits from a shorter TTL (5 seconds) so the operator sees new data.
# /etc/tempo/tempo.yaml
query_frontend:
search:
# Cache TraceQL search results (including aggregations)
# for 60 seconds. Cache key is the query string plus the
# time window.
cache:
enable_fifo_cache: true
fifocache:
size: 2048
validity: 60s
Severity: CONFIGURATION. The query-frontend must be restarted.
How to validate it
Severity: READ-ONLY. Four checks confirm an aggregation is behaving as expected.
- The aggregator runs and returns a number:
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'query={ resource.service.name = "checkout-api" } | count()' \
--data-urlencode 'limit=1' | jq '.traces[0] | .traceID'
"4bf92f3577b34da6a3ce929d0e0e4736"
The trace ID is the answer; the aggregator runs inside the search and produces a single value per trace.
- The grouping stage produces per-group counts:
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'query={ status = error } | by(resource.service.name) | count()' \
--data-urlencode 'limit=10' | jq '.traces | length'
5
Five groups means five distinct services with error traces in the window. Compare against the ungrouped count to confirm the breakdown makes sense.
- The aggregator comparator keeps or drops the expected traces.
Add
count() > 0and confirm the same five groups return; addcount() > 1000and confirm an empty result:
# Traces with at least one error span (any number)
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'query={ status = error } | count() > 0' \
--data-urlencode 'limit=10' | jq '.traces | length'
# Traces with more than 1000 error spans (likely empty)
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'query={ status = error } | count() > 1000' \
--data-urlencode 'limit=10' | jq '.traces | length'
The first returns 5; the second returns 0. The comparator is filtering as expected.
- The query-frontend cache is engaged. Repeat a query and check the cache-hit metric:
# Repeat the same query twice
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'query={ status = error } | count() > 0' \
--data-urlencode 'limit=10' > /dev/null
curl -s http://tempo-query-frontend:9095/metrics | \
grep query_frontend_search_results_cache_hits_total
A non-zero counter on the second run means the cache served the result without re-scanning blocks.
How it can fail
Six shapes appear when an aggregation returns the wrong answer:
- The comparator is on the wrong side of zero.
count() > 0keeps traces with at least one match;count() >= 0keeps every trace (the count is always non-negative). Symptom: the comparator looks correct but returns every trace. - The
byattribute has unexpected cardinality. Aby(span.http.url)groups by full URL, which can be unique per request. Symptom: thousands of groups, each with a count of 1; the querier runs out of memory. - The aggregator field is wrong.
avg(span.duration)uses the bare name; the canonical intrinsic isspan:duration. Symptom: the aggregator fails to parse or returns 0 for every group because the column is empty. - The grouping produces one group with everything. The
attribute is constant (e.g.
by(instrumentation.name)on a fleet that uses a single SDK). Symptom: the result has one row and the breakdown the operator expected does not appear. - The pipeline order is wrong.
count() | by(...)is not valid syntax. The order isby(...) | count(). Symptom: the Tempo UI shows a red syntax banner. - The time window is too long for the cardinality. A 30-day
window plus
by(resource.k8s.pod.name)on a fleet with 500 pods produces 500 groups per service. Symptom: query times out; querier OOMs.
How to troubleshoot it
Ordered diagnostics, cheapest first:
- Confirm the time window contains traces. Run the selector
alone (
{ ... }) without the aggregator. Zero means the window is empty; the issue is retention or clock skew. - Confirm the aggregator parses. The Tempo UI shows a
syntax error in red. Common mistakes: forgetting the
parentheses (
count > 0), using the bare name for an intrinsic (avg(duration)instead ofavg(span:duration)), or chaining two aggregators (avg() | count()). - Confirm the cardinality of the
byattribute. Pick a single trace, runby(...) | count()on it, and confirm the number of groups is sensible. A count that matches the number of spans means the attribute is per-span unique. - Confirm the comparator is on the right side of the
threshold.
count() > 0means at least one.count() > 100means more than 100. The threshold is per trace, not per time window.
Security implications
- Cardinality as a side channel. A query like
by(span.user_id) | count()returns per-user counts. In a multi-tenant deployment this can leak user-level behaviour across tenant boundaries if theX-Scope-OrgIDheader is misrouted. - Query cost as DoS. A panel that runs
by(span.http.url) | count()against a 30-day window scans every block and produces millions of groups. One user, one panel, can saturate the querier. Cap the time window and bound thebycardinality. - Cache poisoning. The query-frontend cache keys on the query string plus the time window. A query that returns high-cardinality results can fill the cache and evict other panels’ results.
Performance implications
The cost of an aggregation is:
- Selector cost (column reads, see lesson 02).
- Group cost. A
byon a low-cardinality attribute is cheap because the engine allocates one bucket per distinct value. Abyon a high-cardinality attribute is the dominant cost: millions of allocations, millions of comparator evaluations. - Aggregator cost.
count()is O(N) over the spanset;sum(),min(),max()are also O(N);avg()is O(N) with a small constant. None of them are expensive on their own. - Comparator cost. O(G) over the groups; cheap.
- Time window cost. Multiplied by the number of blocks in the window.
The query-frontend cache turns repeat dashboard queries from O(blocks) to O(1). Set the cache TTL to the dashboard refresh interval; longer TTLs serve stale results.
Production guidance
- Always pair an aggregator with a comparator.
count()alone returns a number;count() > 0is the actionable form that keeps traces. - Bound the
bycardinality. Service-name, pod-name, and deployment-environment are sensible. URL, user-id, and request-id are not. - Use
count() > 0to convert a selector into a trace-ID list, and useby(...) | count()to convert it into a breakdown. - Cache aggregations at the query-frontend with a TTL matched to the dashboard refresh interval.
Verification
You should now be able to answer:
- What does
count() > 0do at the end of a TraceQL pipeline? - How does the
byclause change the result of an aggregator? - Why is a
byonspan.http.urla dangerous choice in production? - What is the cost order of
count(),avg(),sum(),max(),min()? - What is the first thing to check when an aggregation returns zero results?
Quiz
Knowledge check · 8 questions
Q1. What does the count() aggregator return when used without by?
Q2. Which selector produces "the trace IDs of error traces grouped by service"?
Q3. A query like { } | by(span.http.url) | count() is dangerous in production because the URL attribute has unbounded cardinality.
Q4. Which of these are valid TraceQL aggregators? (select all that apply)
Q5. Name the pipeline clause that buckets spans by an attribute before the aggregator runs.
Q6. Which comparator converts a selector into a list of trace IDs that contain at least one matching span?
Q7. The aggregator avg(span.duration) returns 0 for every group. What is the most likely cause?
Q8. An aggregation result is cached by the query-frontend for its configured TTL, so a dashboard panel that re-renders every 30 seconds only runs the underlying scan once per TTL window.
Passing score: 75%. Answers are checked in this browser.