Migrating from Jaeger to Tempo Without Losing Traces
Add Tempo as a second exporter so both backends receive identical spans, run them in parallel for at least one full retention window, migrate the query path and saved searches while both are live, and only then stop exporting to Jaeger — no trace data ever needs to be copied.
Context and when it matters
Trace backends are unusual among data stores: you almost never need to migrate the data. Traces are short-lived by design, so if both backends ingest the same stream for a couple of weeks, the old one ages out naturally and the new one is already complete. The migration is therefore about the pipeline and the humans, not about moving bytes.
That framing is what makes the whole exercise low-risk. The failure modes people actually hit are elsewhere: a query path cut over before anyone rewrote their saved searches, a dashboard hard-coded against the Jaeger API, a per-tenant setting that silently changed, or a retention window that expired before the team noticed the new backend had a gap.
Teams usually make this move for cost — see sizing trace storage and retention costs for the arithmetic, where the Elasticsearch index multiplier usually dominates — or for TraceQL’s structural queries.
The parallel-run architecture
Implementation
Step 1 — Fan out in the Collector
# collector-config.yaml — identical spans to both backends
exporters:
otlp/jaeger:
endpoint: jaeger-collector:4317
tls: { insecure: true }
sending_queue: { queue_size: 5000 }
otlp/tempo:
endpoint: tempo-distributor:4317
tls: { insecure: true }
sending_queue: { queue_size: 5000 }
retry_on_failure: { enabled: true, max_elapsed_time: 300s }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
# Fan-out: the batch is delivered to both, independently.
exporters: [otlp/jaeger, otlp/tempo]
Two operational details matter here. The exporters have independent queues, so a Tempo outage does not block Jaeger — but both share the same memory_limiter, so a persistent failure on either eventually applies backpressure to the whole pipeline. Watch otelcol_exporter_queue_size for each exporter separately during the first days, as covered in handling collector backpressure.
Step 2 — Verify the two backends agree
Before trusting the new backend, prove it holds the same data:
# Pick recent trace ids from Jaeger, fetch each from Tempo, compare span counts.
for tid in $(curl -s -G "http://jaeger-query:16686/api/traces" \
--data-urlencode 'service=checkout-api' --data-urlencode 'limit=50' \
| jq -r '.data[].traceID'); do
j=$(curl -s "http://jaeger-query:16686/api/traces/${tid}" | jq '.data[0].spans | length')
t=$(curl -s "http://tempo:3200/api/traces/${tid}" \
| jq '[.batches[].scopeSpans[].spans[]] | length')
[ "$j" = "$t" ] || echo "MISMATCH ${tid}: jaeger=${j} tempo=${t}"
done
Persistent mismatches in one direction usually mean an exporter queue is dropping under load rather than a data-model difference. A handful of mismatches on very recent traces is normal — the two backends do not become queryable at the same instant.
Step 3 — Translate the saved searches
This is the part that takes real time, and doing it while Jaeger is still live is what makes the migration safe.
Step 4 — Move the read path, keep the write path
Point dashboards and the trace-to-logs correlation at Grafana while both backends still ingest. If something is missing, switching back is a UI change rather than a data-recovery exercise. This is also when you discover the integrations nobody documented: a log pipeline building Jaeger deep links, an alert annotation containing a Jaeger URL, a support tool that queries the Jaeger API directly.
# grafana datasource — trace-to-logs continues to work after the move
apiVersion: 1
datasources:
- name: Tempo
type: tempo
uid: tempo-prod
url: http://tempo-query-frontend:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki-prod
# The correlation key is unchanged by the backend swap.
tags: [{ key: 'service.name', value: 'service_name' }]
filterByTraceID: true
serviceMap:
datasourceUid: prometheus-prod
Step 5 — Retire Jaeger
Once Jaeger’s retention window has fully elapsed after the query path moved, every trace it holds also exists in Tempo. Remove the exporter, then the deployment, then the Elasticsearch cluster — in that order, with a pause between each, so a surprise surfaces while the previous step is still reversible.
A schedule that keeps rollback cheap
The order and the gaps matter more than the total duration. Each step should be reversible until the next one has proven itself, which in practice means never removing anything in the same week you add its replacement.
The reason to resist compressing this is that the failures are delayed by design. A dashboard that nobody opens until month-end, a runbook used only during incidents, an alert that fires quarterly — none of them surface in a three-day migration, and all of them surface in a five-week one. The extra weeks cost you a second exporter’s worth of egress and nothing else.
Verification
- Span-count parity holds across a sample of traces for several days, not just at cutover.
- Every saved search has a TraceQL equivalent that returns comparable results on the same window.
- No dashboard, alert, or runbook still links to the Jaeger host — grep the configuration repositories for the hostname rather than trusting memory.
- Ingest metrics for both exporters are flat and equal; a diverging pair means one queue is shedding.
What changes for the people using it
The technical migration is straightforward; the change in daily workflow is the part that generates support requests, and naming it in advance is worth more than any runbook.
Jaeger’s search is a form: pick a service, pick an operation, add tags, set a duration. Tempo’s is a query language. For someone who opens the trace UI twice a month, that is a real step up in friction, and the common reaction — “the new thing is worse” — is about the form disappearing rather than about capability. Two things defuse it. First, ship a set of ready-made queries as Grafana dashboard panels or saved explorations, so the routine questions stay one click away. Second, put the three or four query shapes people actually need on a single page: filter by service, filter by service plus error, filter by service plus duration, and the structural query that Jaeger never had.
The second workflow change is that trace-to-logs and trace-to-metrics correlation move into Grafana, which is usually an improvement — the pivot from a slow span to the logs of that exact request becomes a click rather than a copy-paste. Configure it before the cutover, not after, because the first week is when people form their opinion of the new setup.
Finally, expect a period where both systems hold partial answers: Tempo has everything since the parallel run started, Jaeger has everything until it is retired. Say that explicitly rather than letting someone conclude data is missing when they search a window that predates the migration.
Decision criteria
- Is your motivation cost? Confirm the index multiplier is what dominates your bill before migrating; if span volume is the driver, the same volume costs a lot in either backend.
- Do you need structural queries? That is the strongest functional reason to move — Jaeger cannot express relationships between spans at all.
- Do you depend on Jaeger-specific features? Deep-dependency graphs and some archive workflows have no direct equivalent; check them explicitly rather than discovering the gap after cutover.
- Is your retention long? A ninety-day window means a ninety-day parallel run for full coverage, or accepting that old traces stay only in Jaeger until they expire.
Common pitfalls
- Cutting the write path and the read path together. Then a query problem looks like a data-loss problem, and rollback is ambiguous. Separate them by at least a few days.
- Assuming attribute names carry over unchanged. They do, but Jaeger’s UI displays some fields differently, and a saved search written against a display label rather than the attribute key will not translate.
- Forgetting the sampling story. If Jaeger sat behind a sampling proxy that Tempo does not, volumes differ and the parity check fails for a reason that has nothing to do with the migration.
- Turning off Elasticsearch too early. Its retention is what protects you during the transition; keep it until the parallel window has genuinely elapsed.
Related
- Jaeger vs Tempo vs Zipkin decision guide — deciding whether the move is worth making
- Querying traces with TraceQL and Jaeger search — the query vocabulary you are migrating to
- Sizing trace storage and retention costs — quantifying the saving before committing