ObservabilityXLVI · Tempo DeploymentTempoDeployment
Tempo Querying
What you'll learn
- Distinguish a TraceQL search from a trace-by-ID lookup
- Write TraceQL queries against spans, attributes, and timing
- Configure the query-frontend for subquery splitting and caching
- Configure the metrics-generator to derive RED metrics from spans
- Diagnose slow-query and metrics-generator drift
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
An on-call engineer opens a Grafana dashboard for a checkout
slowdown incident. The user-impact metric says p99 latency for
POST /checkout has tripled in the last 30 minutes. The
engineer needs to know: which service is the slow one, on which
trace, and at which span. They click “Explore” in Grafana, type
{ resource.service.name = "checkout-api" && status = error }
into the Tempo query bar, and a list of trace IDs appears. They
pick the most recent. The trace shows the parent span
POST /checkout ran for 4.2 s, of which 4.1 s was a child
span called payment-charge against the payments service.
The investigation took 90 seconds because Tempo accepted the
TraceQL query and returned the answer.
The query path that produced that answer is the subject of this lesson.
What it is
Tempo exposes two distinct query shapes:
- Trace by ID.
GET /api/traces/{trace-id}. Returns the full trace. Used by Grafana when the user clicks a trace ID from logs, exemplars, or another trace. - TraceQL search.
GET /api/search?query=...(or the equivalent streaming endpoint). Returns a list of trace IDs matching a TraceQL expression. Used when the user does not know the trace ID and wants to find it.
TraceQL is a SQL-like expression language with two scopes: spans and traces. A span-level filter selects individual spans; a trace-level filter operates on aggregate properties of the whole trace.
Why a sysadmin cares
The two query shapes have different operational profiles:
- Trace-by-ID is a single block read. Latency is bound by the size of one block (a few hundred ms). It scales linearly with concurrent users.
- TraceQL search is a block scan across the time window. Latency is bound by the number of blocks in the window and the size of the time range. A search across 30 days can be several seconds; a search across 30 minutes is sub-second.
The query-frontend exists to make the second shape cheap. The metrics-generator exists to avoid the second shape by pre-computing RED metrics from spans.
How it works
Grafana
|
v
Tempo query-frontend <-- splits subqueries, caches results
|
v
Tempo querier <-- reads blocks, applies TraceQL filter
|
v
Object storage (S3/GCS/Azure)
TraceQL
A TraceQL expression is a filter over the trace tree. The
default scope is spans. A query of
{ resource.service.name = "checkout-api" } selects every span
belonging to that service. To operate at the trace level, wrap
the filter in a {} aggregate (a trace aggregate):
{ resource.service.name = "checkout-api" && status = error }
| count() > 0
The | count() > 0 aggregate returns only the trace IDs that
have at least one matching span. Without the aggregate, the
query returns individual spans.
Two intrinsic fields are available on every span:
status—ok,error, orunset.kind—server,client,producer,consumer,internal.
Plus all attributes set by the SDK (span attributes, resource attributes, events). Examples:
{ resource.service.name = "checkout-api" } # service
{ name = "POST /checkout" && http.status_code >= 500 } # by name
{ duration > 2s } # slow
{ span.http.url =~ ".*/v2/.*" } # URL match
{ resource.k8s.pod.name = "checkout-api-7d4b" } # by pod
Aggregates extend the filter:
{ resource.service.name = "checkout-api" } | count() > 0
{ resource.service.name = "checkout-api" } | avg(duration) by (resource.k8s.pod.name)
{ span.http.status_code = 500 } | count() by (resource.service.name) > 5
Search vs lookup
| Operation | Endpoint | Input | Output |
|---|---|---|---|
| Search | GET /api/search?query=... | TraceQL | List of trace IDs (and a few summary fields) |
| Lookup | GET /api/traces/{trace-id} | Trace ID | Full trace tree |
Search reads the bloom filter of every block in the time window. Lookup reads the single block that contains the trace ID.
Query-frontend
The query-frontend is an optional but production-critical component. It receives the Grafana query, splits it into subqueries by tenant and time range, dispatches the subqueries to querier pods, and merges the results. It also caches TraceQL results for short windows.
Without the query-frontend, every query goes to one querier pod. With it, large queries are split and parallelised, and duplicate queries within a cache TTL hit the cache.
Metrics-generator
The metrics-generator derives RED metrics from spans and
exports them to a Prometheus-compatible endpoint. This turns
“how many traces had an error in the last 5 minutes” from a
TraceQL query (slow, every time) into a Prometheus query
(rate(tempo_metrics_generator_spans_total{status="error"}[5m]),
fast, cached).
The generator runs as a separate process; it tails the ingester ring for new blocks, parses the spans, and emits metrics. It does not replace the source-of-truth block in storage; it produces a derivative signal.
Under the hood
How to configure it
Query-frontend
# /etc/tempo/tempo.yaml
query_frontend:
address: tempo-query-frontend:9095
# Split subqueries across multiple queriers.
split_queries_by_interval: 15m
max_parallelism: 4
# Cache search results for 60 seconds. Useful for repeated
# dashboard queries.
search_results_cache:
cache:
enable_fifo_cache: true
fifocache:
size: 1024
validity: 60s
Severity: CONFIGURATION. The query-frontend must be
deployed alongside the querier; it is its own process in
microservices mode (target: query-frontend).
Querier
querier:
frontend_worker:
frontend_address: tempo-query-frontend:9095
parallelism: 4
# Bound the time range for a single search. Without this, a
# Grafana user can scan the whole retention period.
max_query_length: 168h # 7 days
Severity: CONFIGURATION. Restart the querier to apply.
Metrics-generator
metrics_generator:
registry:
collection_interval: 30s
timeout: 10s
storage:
path: /var/tempo/generator/wal
traces_storage:
path: /var/tempo/generator/traces
processor:
service_graphs:
dimensions: [http.method, http.status_code]
span_metrics:
dimensions: [http.method, http.status_code]
The service_graphs processor produces the data behind the
Grafana “Service Graphs” view; span_metrics produces the
RED metrics (traces_spanmetrics_*).
Severity: CONFIGURATION. The generator reads from the
ingester ring; it must be deployed with target: metrics-generator in microservices mode.
How to validate it
Severity: READ-ONLY.
# 1. TraceQL search — list error traces for a service
curl -sG "http://tempo:3200/api/search" \
--data-urlencode 'query={ resource.service.name = "checkout-api" && status = error }' \
--data-urlencode 'limit=5' | jq .
# 2. Trace by ID lookup
curl -s http://tempo:3200/api/traces/4bf92f3577b34da6a3ce929d0e0e4736 | jq .
# 3. Confirm the metrics-generator is exporting
curl -s http://tempo-metrics-generator:3200/metrics | \
grep tempo_metrics_generator_spans_total
Real output:
$ curl -sG "http://tempo:3200/api/search" \
--data-urlencode 'query={ resource.service.name = "checkout-api" && status = error }' \
--data-urlencode 'limit=2' | jq '.traces[].traceID'
"4bf92f3577b34da6a3ce929d0e0e4736"
"7a1f8b9c0d4e3f2a5b6c7d8e9f0a1b2c"
$ curl -s http://tempo-metrics-generator:3200/metrics | \
grep -E 'tempo_metrics_generator_spans_total\{' | head -3
tempo_metrics_generator_spans_total{service_name="checkout-api",span_name="POST /checkout",span_kind="server",status_code="server",status="ok"} 4321
tempo_metrics_generator_spans_total{service_name="checkout-api",span_name="POST /checkout",span_kind="server",status_code="server",status="error"} 87
How it can fail
-
TraceQL search returns no results despite traces being ingested. The query is wrong (attribute name typo) or the
resourcescope is mismatched. Symptom: empty result set;tempo_querier_search_results_totalnon-zero buttempo_querier_search_result_traces_totalzero. -
Search is slow over a 30-day window. No
max_query_lengthset, or set too high. The querier scans every block in the window. Symptom: 30-second query times for an interactive Grafana panel; user complains. -
Query-frontend splits but the cache misses. The cache size is too small for the dashboard’s query rate. Symptom:
query_frontend_results_cache_hits_totalflat; querier CPU high. -
Metrics-generator drift. The generator’s WAL is on a slow disk and falls behind the ingester. Symptom:
tempo_metrics_generator_processed_spans_totalrate lower than ingest rate; Grafana dashboards show stale data. -
http.methoddimension not set in the generator. The generator emits RED metrics without the dimension; the dashboard cannot break down by method. Symptom: Grafana panel shows a single line; team cannot isolate which method is slow. -
Querier cannot reach the ingester ring. The querier logs
ring error: at least one ingester must be healthy. Symptom: lookup returns “trace not found” even for traces within retention; search returns empty.
How to troubleshoot it
Order of diagnostics, cheapest first:
- Is the query-frontend alive?
curl /readyon the query-frontend pod. If 404, the role is not deployed. - Is the querier alive?
curl /readyon the querier pod. - Is the ring healthy? The querier log mentions the healthy ingester set on every search. Empty == ring is unhealthy; check the ingester pods.
- Is the metrics-generator processing? Compare
tempo_metrics_generator_processed_spans_totalto the ingester’stempo_ingester_spans_received_total. A divergence means the generator has fallen behind.
Security implications
- TraceQL injection. TraceQL is structured; an attacker
cannot break out of the query syntax with attribute values.
But attribute values can be large (a
url.fullattribute might contain untrusted text). Bound the result size withlimiton every Grafana panel. - Auth. TraceQL queries honour the
X-Scope-OrgIDheader in the same way as ingest. A user with aviewerrole in one tenant cannot query another tenant’s traces. - Metrics-generator egress. The generator exports to a Prometheus-compatible endpoint. In multi-tenant mode, ensure the generator’s scrape target is on a private network.
Performance implications
- TraceQL cost. A search across a long time window is
expensive.
max_query_lengthcaps the cost at the querier level;split_queries_by_intervalon the query-frontend parallelises the cost. - Cache. The query-frontend cache helps repeated queries (dashboard panels that re-render every 30 seconds). Tune the cache size to the query rate.
- Metrics-generator CPU. Parsing spans and emitting metrics is roughly 5-10% of the cost of running the same data through a Java tracing agent. The generator can keep up with most production ingests on a single core.
Production guidance
- Deploy the query-frontend in microservices mode. The operational gain is large.
- Set
max_query_lengthto the largest window a Grafana panel is allowed to query. 7 days is a reasonable default. - Run the metrics-generator as its own pod with its own WAL volume. Do not run it as a sidecar of the querier.
- Bound
limiton every Grafana panel. An unbounded limit is a Grafana OOM waiting to happen.
Verification
You should now be able to answer:
- What is the difference between a TraceQL search and a trace-by-ID lookup?
- Which two endpoints expose these operations in Tempo?
- What is the role of the query-frontend?
- What does the metrics-generator produce, and where does it emit it?
- How would you bound the time window for a TraceQL search?
Quiz
Knowledge check · 8 questions
Q1. Which endpoint performs a trace-by-ID lookup?
Q2. What is the role of the query-frontend in microservices mode?
Q3. A TraceQL search across a 30-day window is materially slower than across a 30-minute window because every block in the window is scanned.
Q4. Which of these are intrinsic fields available on every TraceQL span? (select all that apply)
Q5. Name the querier config key that caps the time range of a single TraceQL search.
Q6. The metrics-generator has fallen behind the ingester. Which signal confirms this?
Q7. The metrics-generator writes derived metrics back into the trace store as new blocks.
Q8. Which of these are operational reasons to deploy the query-frontend? (select all that apply)
Passing score: 75%. Answers are checked in this browser.