ObservabilityXLV · Tempo ArchitectureTempoArchitecture
The Querier
What you'll learn
- Explain the querier's role in fetching blocks from object storage and executing TraceQL
- Configure TraceQL query limits, concurrency, and the search/lookup paths for production
- Diagnose querier failure modes (bucket reachability, slow TraceQL, ingesters missing recent spans)
- Validate querier health using tempo_querier metrics and the /api/search endpoint
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 platform team deploys a new dashboard that runs a TraceQL query over the last 24 hours every 30 seconds. Each query scans every block in the bucket. The querier’s CPU saturates, dashboard panels time out, and the on-call investigation slows because every Grafana click now returns 504. The team adds a query- frontend with a 5-minute cache; the problem disappears.
This lesson describes the Tempo querier: the read path, the role of the query-frontend, and the cost of unbounded TraceQL queries.
What it is
The Tempo querier is a stateless service that answers two kinds of request:
- Trace-by-id lookup.
GET /api/traces/{id}returns the full trace with all its spans. The querier asks every ingester for the trace (because the trace may still be in a head block) and fetches the relevant block from the bucket (because the trace may have been flushed). - TraceQL search.
GET /api/searchandGET /api/search/tag/{name}/valuesreturn lists of traces and lists of tag values. The querier scans every block that intersects the query window, applies the TraceQL filter, and returns matching traces.
The querier has no local state. It can be restarted, scaled up, or scaled down without loss of data. The only persistent state it touches is the bucket.
Why a sysadmin cares
Three operational pains are specific to the querier:
- Bucket reachability. The querier fetches blocks from the bucket on every query. An IAM misconfiguration, a network change, or a bucket region failover manifests at the querier first.
- TraceQL cost. A TraceQL query that scans 24 hours of blocks is more expensive than one that scans 1 hour. A dashboard that polls a wide query is more expensive than one that polls a narrow query. Without query limits, one Grafana user can saturate the querier pool.
- Read-after-write consistency. A trace that arrived 30 seconds ago may still be in a head block. The querier asks every ingester for the trace and merges the responses with the bucket result. If the ingester search path is broken, recently arrived traces look missing.
How it works
The querier answers two requests with two paths:
GET /api/traces/{id} GET /api/search
| |
v v
+------------------+ +-------------------+
| Query ingesters | | List blocks in |
| for trace_id in | | bucket intersecting
| their head blocks| | query window |
+--------+---------+ +---------+---------+
| |
| v
| +--------------------+
| | Fetch each block |
| | from bucket |
| +---------+----------+
| |
| v
| +--------------------+
| | Apply TraceQL |
| | filter per block |
| +---------+----------+
| |
v v
+------------------+ +-------------------+
| Fetch blocks | | Merge results, |
| from bucket | | return trace IDs |
| for trace_id | +-------------------+
+--------+---------+
|
v
+-------------------+
| Merge head and |
| bucket results, |
| return full trace |
+-------------------+
Two production details to call out:
- Trace-by-id merges head and bucket. The querier always asks every ingester for the trace. If an ingester has the trace in a head block, the response wins. If the ingester has already flushed the trace, the bucket result wins. The two responses are merged and deduplicated.
- TraceQL is a scan. A TraceQL query that asks for all
traces with
status = errorover 24 hours scans every block in the window. The cost is proportional to the number of blocks, not the number of matching traces. The compactor bounds block count; the query-frontend bounds query scope.
How to configure it
A production querier config pins the search limits and the worker pool:
querier:
# Hard limits on the TraceQL search path. Without these, a
# single unbounded query can saturate the querier pool.
frontend_worker:
frontend_address: query-frontend:9095
parallelism: 10
max_concurrent_queries: 20
query_timeout: 30s
# Block fetch concurrency per query. Higher values use more
# bucket bandwidth; lower values increase query latency.
search:
max_concurrent_requests: 20
storage:
trace:
backend: s3
s3:
bucket_name: tempo-traces-prod
endpoint: s3.eu-west-1.amazonaws.com
region: eu-west-1
query_frontend:
# The query-frontend is optional. When present, queriers
# connect to it instead of answering queries directly.
search:
max_concurrent_queries: 20
query_timeout: 30s
results_cache:
backend: redis
redis:
endpoint: redis.internal:6379
ttl: 5m
Three production details to call out:
max_concurrent_queriesandquery_timeoutare the two limits that matter most. A querier pool that accepts 200 concurrent queries with no timeout will saturate on a single wide query.- The query-frontend cache uses a content-addressed key (tenant, query, time window). A 5-minute TTL is the standard recommendation; shorter TTLs reduce cache hit rate, longer TTLs return stale data.
- The querier’s
frontend_addressmust point at the query- frontend if one is running. A querier that points at the query- frontend is an ingester-side worker; a querier that does not point at one is a direct query endpoint.
How to validate it
Six checks confirm the querier is doing its job:
- Confirm the querier is ready and registered:
curl -s http://tempo.internal:3200/querier/ready
# ready
curl -s http://tempo.internal:3200/querier/ring | jq .
# {
# "name": "querier",
# "members": [{"addr": "tempo-querier-0:3200", "state": "ACTIVE"}]
# }
- Confirm a trace-by-id lookup returns the trace:
TRACE_ID=...
curl -s "http://tempo.internal:3200/api/traces/${TRACE_ID}" | jq '.batches | length'
# 1
- Confirm a TraceQL search returns matching traces:
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'q={ resource.service.name = "checkout" && status = error }' \
--data-urlencode 'limit=10' | jq '.traces | length'
# 6
- Confirm the bucket is reachable. The querier logs every
block fetch error; the
tempo_querier_block_fetch_errors_totalcounter should remain at zero under healthy operation:
curl -s http://tempo.internal:3200/metrics \
| grep tempo_querier_block_fetch_errors_total
# (no output means zero)
- Confirm the ingester search path is returning recent traces. A trace that arrived 30 seconds ago should be readable even if it has not flushed:
# Send a trace
otel-cli span export --endpoint tempo.internal:4317 \
--service test --name probe --attrs marker=recent
TRACE_ID=$(otel-cli span ls --limit 1 --format json | jq -r '.[0].TraceId')
# Wait only briefly so the ingester search path must return it
sleep 1
# Read
curl -s "http://tempo.internal:3200/api/traces/${TRACE_ID}" | jq '.batches | length'
# 1
- Confirm a query that previously scanned many blocks now scans
few. The
tempo_querier_blocks_scanned_per_queryhistogram should be dominated by thele="10"bucket under healthy compaction:
curl -s http://tempo.internal:3200/metrics \
| grep 'tempo_querier_blocks_scanned_per_query_bucket' | head -3
# tempo_querier_blocks_scanned_per_query_bucket{le="1"} 8412
# tempo_querier_blocks_scanned_per_query_bucket{le="10"} 8931
How it can fail
Five shapes appear repeatedly:
- Bucket unreachable. An IAM policy change, a region
failover, or a network route change. The querier cannot fetch
blocks. Symptom is
tempo_querier_block_fetch_errors_totalrising and TraceQL queries returning500. - Unbounded TraceQL query. A user runs a query with no
time bound or no result limit. The querier scans every block
in the bucket. Symptom is
tempo_querier_blocks_scanned_per_query_bucketpushed into thele="+Inf"bucket and CPU saturation. - Recent trace not readable. A trace that arrived ten
seconds ago is missing from
/api/traces/{id}. The ingester search path is broken or the distributor has dropped the trace. Symptom istempo_ingester_traces_created_totalrising while the same trace returns empty from the querier. - Query-frontend cache stale. The query-frontend returns cached results that do not include the last few minutes of spans. Symptom is user complaints about “the dashboard is showing old data” despite recent activity.
- Worker pool saturated.
max_concurrent_queriesexceeded. New queries wait or time out. Symptom istempo_querier_concurrent_queriesat the limit and Grafana returning 504.
How to troubleshoot it
The diagnostic order:
- Is the querier ready? Check
/querier/ready. A not-ready querier should not be in the load balancer. - Can the querier reach the bucket? Run
aws s3 lsfrom the querier host using the same credentials. An IAM policy that works for the AWS CLI may not work for Tempo because the SDK uses different endpoints. - Are queries returning? Run a known-good query (small time window, known tag) and check the response. A query that returns empty results is not the same as a query that times out.
- Is the search path reaching ingesters? Check
tempo_querier_ingester_searches_total. A flat counter means the ingester search path is broken; recently arrived traces will be missing. - Is a single query dominating the pool? Check
tempo_querier_blocks_scanned_per_query_bucket. A query in thele="+Inf"bucket is the source of the saturation. - Is the query-frontend caching correctly? Check
tempo_query_frontend_results_cache_hits_totalandtempo_query_frontend_results_cache_misses_total. A miss ratio above 80% means the cache is too small or the TTL is too short.
Security implications
The querier reads from the bucket and serves traces to Grafana:
- Authentication. Tempo does not implement authentication. Front the querier (or the query-frontend) with a reverse proxy that enforces JWT or mTLS.
- Tenant isolation. Multi-tenant deployments must enforce
that one tenant’s query cannot read another tenant’s traces.
Tempo uses the
X-Scope-OrgIDheader to scope every request; the querier never trusts the trace ID alone. - Bucket credentials. The querier reads from the bucket but does not write. A read-only scoped credential is sufficient. A leaked read/write credential is a write attack surface as well as a read attack surface.
Performance implications
The querier is network- and disk-bound on block fetches:
- Network. Each query fetches every intersecting block. A TraceQL query over 24 hours fetches roughly the entire bucket’s metadata. The querier’s bandwidth scales with query count and bucket size.
- Disk. The querier caches block metadata in memory. A bucket with 10 million blocks consumes gigabytes of memory for the metadata cache.
- CPU. The TraceQL filter is cheap per block but expensive in aggregate. A scan of 10,000 blocks consumes more CPU than a scan of 1,000 blocks by an order of magnitude.
- Worker pool.
max_concurrent_queriesbounds the CPU and network cost. A pool of 20 with 30 s timeouts can answer roughly 40 queries per minute per querier pod.
Production guidance
- Run at least two querier pods. One is a single point of failure.
- Add the query-frontend when query load rises. The cache collapses the cost of dashboards that poll wide queries.
- Set
max_concurrent_queriesbased on querier pod size. 20 is a starting point; raise it on bigger pods, lower it on smaller ones. - Set
query_timeoutto the longest legitimate TraceQL query, not to a guess. 30 s is a starting point; raise it for known wide queries, lower it for known narrow ones.
Verification
You should now be able to answer:
- What is the querier’s role in the Tempo read path?
- What is the difference between trace-by-id and TraceQL search?
- How does the query-frontend reduce query load?
- Why must
max_concurrent_queriesandquery_timeoutbe set explicitly? - What is the operational effect of an unbounded TraceQL query?
Quiz
Knowledge check · 8 questions
Q1. Which Tempo component answers trace-by-id and TraceQL search requests?
Q2. Why does the querier ask every ingester for a trace-by-id lookup before fetching from the bucket?
Q3. A querier restart loses data.
Q4. Which of the following are valid reasons to add the query-frontend? (select all that apply)
Q5. A TraceQL query that scans 24 hours of blocks is slow. The most effective first action is:
Q6. Name the metric that indicates how many blocks a single TraceQL query scans.
Q7. What is the operational effect of an unbounded TraceQL query?
Q8. The querier stores traces in memory between requests.
Passing score: 75%. Answers are checked in this browser.