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

One ingest path, two backends, two query paths Services export to the Collector. The Collector fans out identical spans to both Jaeger with Elasticsearch and Tempo with object storage. Engineers query Jaeger through its own UI and Tempo through Grafana. The write path and the read path are cut over separately. Cut the write path first, the read path second Services unchanged Collector two exporters Jaeger + ES retire after 14d Tempo + S3 new home Jaeger UI old queries Grafana TraceQL Nothing on the service side changes — which is what makes rollback a one-line Collector edit.

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.

Jaeger search idioms and their TraceQL equivalents Four rows. A Jaeger service filter becomes a resource dot service dot name equality in TraceQL. A tag filter becomes a span attribute equality. A minimum duration becomes a span duration comparison. An operation name becomes a span name equality. A fifth row notes that Jaeger has no equivalent for TraceQL structural operators. Rewrite the runbooks before you move the UI Jaeger TraceQL Service: checkout-api {resource.service.name="checkout-api"} Tags: error=true {span:status = error} Min Duration: 2s {span:duration > 2s} Operation: GET /orders {span:name = "GET /orders"} (no equivalent) {A} >> {B} · | by() | count() Everything Jaeger could ask, TraceQL can ask — plus the structural questions in the last row.

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.

A five-week migration with reversible steps A timeline. Week one enables dual export, reversible by removing one exporter. Week two runs parity checks with no user-visible change. Week three moves dashboards and runbooks to Grafana, reversible by switching the UI back. Week four is a soak with both paths live. Week five removes the Jaeger exporter and then the cluster, the first irreversible step. Nothing is removed until its replacement has soaked dual export on week 1 revert: drop exporter parity checks week 2 no user impact move queries week 3 revert: switch UI back soak week 4 both paths live retire Jaeger week 5 first one-way door Stretch week 4 to match your retention window if you want full historical coverage in the new backend before the old one goes. Nothing here requires a service redeploy, which is why the whole plan is a sequence of Collector and dashboard edits.

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

↑ Back to Trace Storage Backend Comparison: Jaeger vs Tempo