Backends: Jaeger & Tempo
Backends: Jaeger & Tempo
After your OpenTelemetry Collector receives spans from instrumented services, those spans must land somewhere that supports efficient storage and querying. Two backends dominate production deployments in 2025: Jaeger (the battle-tested CNCF-graduated project) and Grafana Tempo (the cloud-native, cost-optimised alternative). Choosing the wrong backend — or misconfiguring the right one — leads to either runaway storage costs or traces that vanish exactly when you need them to debug a P0 outage. This lesson covers both systems in depth: architecture, storage engines, query mechanics, and how to wire traces to logs and metrics for unified observability.
Jaeger: Architecture & Storage
Jaeger was open-sourced by Uber in 2017. Its original architecture used separate collector, query, and agent binaries, but modern deployments collapse these into the all-in-one binary (for dev) or a collector + query pair backed by an external store (for production). Jaeger's native wire format is still Thrift over UDP, but since v1.35 it accepts OTLP gRPC/HTTP natively — meaning your OTel Collector can export directly without the legacy Jaeger agent.
For storage, Jaeger supports three options that matter in production:
- Elasticsearch / OpenSearch — the default at most companies. Traces are stored as JSON documents indexed by
traceID,serviceName, andstartTime. Retention is handled via ILM (Index Lifecycle Management). Drawback: ES index overhead balloons storage costs at high throughput (>50 k spans/s). - Cassandra — Uber's original backend. Excellent write throughput; TTL-based retention is trivial. Operationally heavier than ES for most teams.
- Badger (embedded) — local disk, development only. Never use in production.
The Jaeger Query service exposes a UI on port 16686 and a gRPC API on 16685. The UI lets you search by service, operation, tags, duration range, and time window. The underlying query is a tag-indexed lookup against your storage backend, not a full-text scan.
jaeger-span-YYYY-MM-DD). At >10 M spans/day, set --es.use-aliases=true and --es.rollover-on-create=true so ILM controls rollover rather than calendar boundaries. This avoids the "Monday morning shard explosion" where the weekend's index grows unchecked.
Grafana Tempo: Architecture & Storage
Tempo was built with one goal: make trace storage as cheap as object storage. It writes spans directly to S3, GCS, or Azure Blob as Parquet-formatted blocks, with a tiny in-memory / local-disk index containing only traceID → block location. That index is why Tempo's query model is fundamentally different from Jaeger's: you must know the traceID to retrieve a trace. This seems limiting, but it is by design — Tempo offloads span attribute search to Prometheus metrics and log queries, keeping storage costs an order of magnitude lower than ES-backed Jaeger.
Since Tempo 2.0, the TraceQL query language enables attribute-based search without knowing the traceID upfront, backed by a new columnar index called the Tag Value Index. This closes most of the UX gap with Jaeger.
Querying Traces: TraceQL vs Jaeger UI
The Jaeger UI query model is simple: pick a service, pick an operation, set a time range and tag filter, click Find Traces. The backend translates this to an ES multi-term query against the tag index. For ad-hoc debugging this is fast, but it only returns traces that contain the matching span — there is no language for expressing cross-span conditions like "the db.query span took > 500 ms AND the parent HTTP span returned 200".
TraceQL fills that gap. It uses a pipeline syntax inspired by LogQL:
Trace-to-Logs Correlation
The most valuable debugging workflow in a distributed system is trace → span → correlated log lines. This works because OTel propagates a traceID and spanID in every context, and your logging library injects them into each log record. In Grafana, a derived field in the Loki datasource converts the raw log field into a clickable link that opens the matching trace in Tempo:
On the application side, your logger must emit trace_id and span_id as structured fields. With OpenTelemetry's opentelemetry-instrumentation-logging (Python) or the OTel Log Bridge API (Java/Go), this happens automatically when you use the OTel context. If you are using a custom logger, extract the IDs manually:
Trace-to-Metrics Correlation
Tempo's service graph feature generates Prometheus metrics (request rate, error rate, duration histograms) from span relationships — specifically from root spans and their children. It exposes these as traces_service_graph_request_total, traces_service_graph_request_failed_total, and traces_service_graph_duration_seconds. Wire these into Grafana's service map view and you get a live dependency graph of your entire system with RED metrics derived purely from traces — no Prometheus instrumentation required on each service.
Choosing Between Jaeger and Tempo
For greenfield deployments in 2025, Tempo is the default choice at most companies running on Kubernetes with existing Grafana stacks — the object storage backend eliminates the operational burden of running Elasticsearch or Cassandra, and native Grafana integration makes trace/log/metric correlation seamless. Jaeger remains the right call when your organization already runs a large Elasticsearch cluster for other workloads (shared operational cost), when you need the mature Jaeger UI for teams unfamiliar with Grafana, or when you require Cassandra's write throughput at extreme scale (>100 k spans/s per node).
Both backends accept OTLP natively, so the instrumentation layer is identical regardless of your choice — and you can run both in parallel during a migration without touching application code.