Skip to main content
RunBook Academy

← All runbooks in Observability

critical riskdata loss risk~90 min

Runbook: Recover Tempo

1 · Prerequisites

Confirm every item is in place before any state change.

  • Tempo Architecture Overview
  • Tempo Storage
  • Tempo Backup
  • Tempo Loss
  • Tempo Validation
  • Tempo Receivers
  • Admin access to the Tempo deployment and to every bucket the trace pipeline writes to, not only the trace-block bucket
  • The Tempo configuration in version control, with the currently deployed commit identified before the incident rather than during it
  • Object-store credentials that are known to work, tested from a Tempo host with Tempo permissions rather than with your own broader admin role
  • A second operator. Several steps here are one-way doors and are not safe to walk through alone at 03:00

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The loss shape is named before any action: WRITE PATH STALLED, INGESTER, OBJECT STORE, or CONFIG. Each has a different first move, and the first move for one destroys the evidence the others need.
  • · It is established whether the platform is losing traces right now or lost them in the past. A stalled write path has a deadline - the write-ahead log fills the ingester disk - and a past loss does not. Those are different urgencies.
  • · The compactor is confirmed STOPPED before anything touches an object store. It is the only Tempo component that deletes, and it will apply retention to a half-restored bucket without hesitating.
  • · Every bucket the pipeline writes to is enumerated, not just the trace-block bucket. Search data lives in its own store, and a recovery that restores blocks alone gives back trace-by-ID lookup while leaving TraceQL search empty.
  • · The object store is confirmed reachable using the credentials Tempo itself uses. An operator whose own role works proves nothing about the ingester service account.
  • · The trace-block prefix is listed and a recent block ID is written down, so that "the bucket is back" can later be checked against something specific rather than against a feeling.
  • · The recovery point objective is stated out loud as a number of minutes or hours of traces the organisation has already agreed it can lose. Several decisions below trade time against data and need that number first.
  • · The suspect window is recorded with a UTC start, because the gap boundary is the deliverable of this runbook and it cannot be reconstructed afterwards.
  • · A second operator is present and has agreed to the plan before any bucket is repointed or any write-ahead log segment is moved aside.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Classify the loss shape before touching anything. Read /ready per role, the ingester log, and the flush counters. Spans arriving but no new blocks in the bucket is WRITE PATH STALLED. An ingester that will not start or is replaying is INGESTER. Historical queries empty with recent ones fine is OBJECT STORE. Nothing arriving and nothing lost is CONFIG. Do not proceed until one of the four is named.
  2. 2STOP THE COMPACTOR if any object store is involved in any way - restored, repointed, partially available, or merely suspected. Scale it to zero replicas or stop its unit. This is the cheapest irreversibility control in the runbook and it precedes every bucket action below.
  3. 3Capture the evidence before restarting any process. Save the ingester and compactor logs, the output of the status endpoint, and the flush and drop counters. Counters are cumulative since process start, so a restart zeroes exactly the numbers that would have told you how long this had been happening.
  4. 4WRITE PATH STALLED - treat it as a bucket problem, not a Tempo problem. Rising tempo_ingester_failed_flushes_total with disk filling on the ingester is almost always a credential, permission or endpoint failure on the object store. Read the ingester log for the storage error verbatim; AccessDenied, a redirect and a dial failure need three different owners.
  5. 5WRITE PATH STALLED - buy time on the ingester disk deliberately, and say what it costs. The write-ahead log grows until the flush succeeds or the disk fills, and a full disk turns a recoverable stall into lost spans. Expanding the volume is the reversible option; dropping the ingest rate is the other; doing neither and waiting is a decision to lose the tail.
  6. 6INGESTER - let the write-ahead log replay finish before concluding anything. A replaying ingester is correctly not ready, and replay time scales with the unflushed window set by max_block_duration and the flush interval. Not-ready during replay is expected behaviour, and killing the pod to make the alert stop restarts the replay from the beginning.
  7. 7INGESTER - decide explicitly about the unflushed window. Whatever was in memory and not yet in the write-ahead log is gone. Whatever is in the write-ahead log returns only if the volume survived. State which of the two you are in, because it is the difference between minutes of loss and none.
  8. 8OBJECT STORE - verify the restored bucket contains the window you need BEFORE pointing Tempo at it. List the trace-block prefix for the affected period. A bucket restored from a stale snapshot is present and incomplete, and the recovery point is the snapshot age, not zero.
  9. 9OBJECT STORE - restore the search store as well, and verify it separately. Trace blocks give back retrieval by trace ID; the search store is what makes TraceQL find a trace when nobody has the ID. A recovery that stops at the block bucket passes every check an operator runs and fails the first investigation that needs it.
  10. 10OBJECT STORE - confirm credentials, region, endpoint and prefix against what Tempo is configured to use. Reads succeeding while writes are denied is an IAM policy written against the query path only. That failure is invisible until the next flush and looks like a Tempo bug when it surfaces.
  11. 11CONFIG - validate the file before starting the binary, and deploy from version control rather than from memory. Tempo ignores unknown keys instead of rejecting them, so a receiver stanza with a misspelled key starts cleanly, reports ready, and binds nothing. Validate, then deploy the known-good commit, then diff the running configuration against it.
  12. 12Bring the platform up in dependency order: object store reachable, then ingesters, then queriers and the query frontend, then the compactor LAST. The compactor is the last thing to start and the first thing to stop, for the same reason in both directions.
  13. 13Prove the write path and the read path together with one synthetic trace. Push a span with a known trace ID, wait for the export path, and retrieve it. No combination of readiness endpoints proves this, because a role can be registered and ready while its receiver port was never bound.
  14. 14Prove the historical read path separately, with a trace ID from before the incident. Recent traces can be served from the ingester, so a query for something new succeeds while the bucket path is still broken. This check is the only one that exercises the object store.
  15. 15Establish and record the gap boundaries: start, end, tenants, and lost versus merely delayed. This is the deliverable. A recovered Tempo with an undocumented gap will be queried during the next incident by someone who reads an empty result as an absence of events.
  16. 16Restart the compactor and watch its first cycle complete. The first compaction after an outage is the most dangerous one, because it is the first time retention is applied to whatever shape the bucket is now in. Watch it, do not assume it.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓The readiness endpoint returns ready for every role that should be running, checked per role rather than once at the front door. A single 200 from the HTTP port says nothing about whether the ingester finished replay or the compactor acquired its lock.
  • ✓A synthetic span pushed over OTLP is retrievable by its trace ID within the export path latency. This is the only check that proves receiver, distributor, ingester and querier at once.
  • ✓A trace ID recorded from BEFORE the incident is retrievable. Without this, a broken bucket path stays hidden behind traces that the ingester is still holding in memory.
  • ✓A TraceQL search by service name returns results, not just a trace-by-ID lookup. These read different stores and only the search proves the search store came back.
  • ✓tempo_ingester_failed_flushes_total has stopped rising, and new block objects are appearing under the trace-block prefix with timestamps after the recovery rather than before it.
  • ✓tempo_distributor_spans_received_total is climbing and tempo_distributor_dropped_spans_total is flat. A rising drop counter after a recovery usually means a limit that was lowered during the incident and never restored.
  • ✓Ingester write-ahead log disk usage is falling rather than growing. A stall that has been fixed drains; a stall that merely paused does not.
  • ✓The running configuration matches the version-controlled commit that was deployed, compared field by field for the storage, retention and receiver blocks. A hand edit that survived the incident is a defect, not a fix.
  • ✓The compactor has completed a full cycle since being restarted, watched rather than inferred, with no blocks left in a partially compacted state.
  • ✓The data gap is written down and published: start, end, tenants, and whether the traces were lost or delayed. "Tempo is back" and "no traces were lost" are two separate claims and only one of them has been demonstrated.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Classification and evidence capture are read-only. Everything from the compactor stop onward changes state, and the step order above exists so that the reversible actions come first.
  • ↶To undo the compactor stop: start it again. That is the intended end state, but only after the bucket contents have been verified - never as a reflex to clear an alert about blocks accumulating.
  • ↶To undo a configuration deploy: redeploy the previous commit, restart the affected roles, and diff the running configuration again. Never end an incident with the repository and the running config disagreeing.
  • ↶A write-ahead log segment moved aside cannot be meaningfully un-moved: the ingester has already started without it. Keep the file, record the window it covered, and treat that window as lost.
  • ↶A compactor that has run against a bucket mid-restore cannot be rolled back. Those deletions are real. This is exactly why stopping it is step 2 rather than step 9.
  • ↶A restore that overwrote newer objects with older ones is not reversible either. Restore into a separate prefix or bucket and verify there before repointing Tempo, whenever the object store offers the option.
  • ↶If retention was shortened to relieve bucket pressure during the incident, that change deletes data on the next compaction cycle. Restore the previous value before the compactor starts, not afterwards.
  • ↶If limits were raised to absorb a replay burst, lower them once the backlog drains and record what the burst actually was. A limit raised under pressure and never reviewed is how the next capacity surprise is built.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate before moving any write-ahead log segment aside. It is a deliberate, irreversible data-loss decision and it needs a second operator and an owner who can accept the gap.
  • · Escalate to whoever owns the object store the moment the bucket, its credentials, its region, its lifecycle policy or its replication configuration is implicated. No Tempo configuration change fixes a storage-side problem, and trying will obscure it.
  • · Escalate to the platform owner before any restore that could overwrite newer objects with older ones. The recovery point is a business decision, not an on-call one.
  • · Escalate to security if a bucket was deleted, emptied, or had its policy changed by something nobody can account for. A store holding every retained trace is a compliance event before it is a durability event, because spans carry request payloads and query strings.
  • · Escalate to the incident commander when recovery will exceed the stated recovery time objective. Restoring the recent read path first and continuing the historical restore in the background is often the better answer, and that is a decision for the commander rather than the operator running the commands.
  • · Escalate to the application teams as soon as the gap boundaries are known. They are the people who will otherwise draw conclusions from an empty trace view during their own incident next week.
  • · Escalate to compliance if the lost window covers a system under a retention obligation. That gap has its own clock and its own reporting duty, independent of the technical recovery.

“Recover Tempo” is not one procedure. Tempo has fewer moving parts than a log store - there is no index to rebuild, and blocks are immutable once written - but that simplicity comes with an unforgiving property: the object store is the only long-term copy of anything. Everything in this runbook is either about getting back to that store, or about not destroying it while trying.

   distributor        <- validates, rate limits, no durable state
        |
        v
   ingester ---> write-ahead log on local disk   <- the unflushed
        |                                            window, minutes
        v
   trace blocks ---> object store   <- canonical. Everything older
        |
        +--------> search store     <- a SEPARATE bucket. TraceQL
        |                              finds nothing without it
        v
   compactor       <- the ONLY component that deletes anything

Two lines in that diagram do most of the damage in practice. The compactor has delete authority over the canonical store. And the search store is a second bucket that a “restore the Tempo bucket” instruction silently omits, which is discovered later, by an investigator who has a service name and no trace ID.

Classify first: which layer was lost

ShapeWhat is happeningWhat it looks likeFirst move
WRITE PATH STALLEDSpans accepted, flush to the bucket failingIngester disk climbing; no new blocks appearing; queries fineRead the storage error in the ingester log
INGESTERThe unflushed window is gone, or is replayingRecent traces missing; historical fine; ingester not readyLet replay finish; read the log first
OBJECT STOREThe bucket is gone, unreachable, or repointedHistorical queries empty; the last few minutes still answerStop the compactor, then verify the bucket
CONFIGThe binary is running, and running the wrong thingNothing arriving, nothing lost; everything reports readyValidate the config; check what is bound

The first row is the one with a deadline. A stalled flush does not stop ingest immediately - the ingester keeps accepting spans and keeps writing to the write-ahead log - so the platform looks healthy while the disk fills. When the disk fills, the loss starts, and it starts at the newest data. Everything else on this list has already happened; that one is still happening.

The last row is the one people misdiagnose most confidently. Tempo ignores configuration keys it does not recognise rather than rejecting them, so a receiver block with a misspelled key produces a process that starts, registers its role, answers ready, and binds no port. Nothing was lost. Nothing is arriving. Every health check is green.

When this runbook applies, and when it does not

It applies when Tempo cannot return traces that were ingested, or cannot accept traces that should be ingestible, because a durability layer was lost or damaged.

It does not apply when:

  • The traces never arrived. If the distributor never received the spans, there is nothing to recover here. That is the missing-traces path, and it starts in the application process.
  • The window is outside retention. The compactor deleted it by policy. That is a retention decision to revisit, not an incident.
  • The query is wrong. An empty TraceQL result against a healthy cluster is a query problem, and running a recovery procedure against it makes things worse for no reason.
  • Tempo is merely slow. A querier scanning a wide time range across many small blocks is a compaction and capacity problem. Restarting components under that load turns slow into unavailable.

Blast radius

ActionReversible?What it costs if wrong
Reading readiness, status, counters and logsn/aNothing
Stopping the compactorYes - start it againRetention pauses; block count grows. Cheap
Expanding the ingester write-ahead log volumeYesStorage cost, and time you did not have
Restarting an ingesterPartlyReplay time, plus anything not yet in the write-ahead log
Moving a write-ahead log segment asideNoEvery trace in that segment, permanently
Repointing Tempo at a restored bucketDependsA stale snapshot silently sets your recovery point
Restoring in place over newer objectsNoThe newer objects
Running the compactor mid-restoreNoRetention applied to gaps. Permanent deletion
Shortening retention to relieve pressureNo, once compaction runsData deleted by policy, on purpose, by you

Four rows are one-way doors. All four appear in the escalation path, because none of them should be walked through by one tired operator acting alone.

Step 1 - Classify, without changing anything

Read-only / Safethe reads that name the loss shape
# Substitute your own values before running:
TEMPO=http://tempo.observability.svc:3200
NS=observability

curl -s "$TEMPO/ready"

for role in distributor ingester querier compactor; do
printf '%s: ' "$role"
curl -s -o /dev/null -w '%{http_code}\n' "$TEMPO/$role/ready"
done

kubectl logs -n "$NS" -l component=ingester --tail=200 \
| grep -iE 'flush|wal|replay|storage|denied|error' | tail -20
ready
distributor: 200
ingester: 503
querier: 200
compactor: 200

Illustrative output

Readiness is role-aware and that is the point. The ingester is not ready until its write-ahead log replay finishes; the compactor is not ready until it has its lock. A check that treats any HTTP 200 from the front door as healthy misses both, and a 503 from the ingester during replay is correct behaviour rather than a fault.

Read-only / Safeis the write path stalled, and is anything arriving
TEMPO=http://tempo.observability.svc:3200

curl -s "$TEMPO/metrics" | grep -E '^tempo_ingester_(failed_flushes_total|local_blocks)'

curl -s "$TEMPO/metrics" | grep -E '^tempo_distributor_(spans_received_total|dropped_spans_total)'
tempo_ingester_failed_flushes_total 4821
tempo_ingester_local_blocks 39
tempo_distributor_spans_received_total 128420551
tempo_distributor_dropped_spans_total 0

Illustrative output

That combination is the signature of the first row of the table. Received climbing means spans are still arriving. Failed flushes climbing means none of them are reaching the bucket. Local blocks accumulating means the ingester is holding what it cannot flush. The diagnosis is not “Tempo is broken”; it is “the object store is refusing writes”, and the log carries the reason verbatim.

Step 2 - Stop the compactor, before anything touches a bucket

Service impact possiblethe cheapest irreversibility control in this runbook
# Substitute your own values before running:
NS=observability

kubectl scale -n "$NS" deployment/tempo-compactor --replicas=0

kubectl get pods -n "$NS" -l component=compactor

The cost of stopping the compactor is that retention pauses and the block count grows. Small blocks accumulating make queries slower. That is the whole bill, and it is trivially reversible.

Step 3 - WRITE PATH STALLED: read the storage error, then buy time

The stall is almost never inside Tempo. The three storage errors that produce an identical rising flush counter have three different owners:

Log lineCauseOwner
AccessDenied on a putThe service account lost write permission, usually in a policy refactorWhoever owns the bucket policy
A redirect on every requestThe configured region or endpoint does not match the bucketWhoever provisioned the bucket
A dial or DNS failureNetwork policy, endpoint change, or a private-link path that movedNetworking
Read-only / Safeprove the bucket with Tempo permissions, not with yours
# Substitute your own values before running. Run this from a Tempo host
# or an exec into a Tempo pod, so it uses the same credentials Tempo has.
BUCKET=tempo-traces-prod
PREFIX=blocks/single-tenant/

aws s3 ls "s3://$BUCKET/$PREFIX" --recursive | tail -5

df -h /var/tempo/wal

Listing succeeding while a put fails is the shape to look for. It is the signature of an IAM policy written against the read path only, and it passes every check an operator runs today while breaking the flush that happens next.

Step 4 - INGESTER: let replay finish, and be explicit about the window

A restarting ingester replays its write-ahead log before serving. Replay time scales with the unflushed window, which is governed by max_block_duration and the flush interval. A long flush interval means a long write-ahead log, which means a long recovery - which is where your recovery time objective is actually decided, at configuration review, not during the incident.

Two distinct quantities are at stake and they are worth separating out loud:

  • In memory, not yet in the write-ahead log. Gone on any process loss. Measured in seconds.
  • In the write-ahead log, not yet flushed. Returns if the volume survived; gone with the volume if it did not. Measured in whatever max_block_duration is set to.

If the ingester refuses to start and logs a corruption error against a segment, you are at a one-way door. Move the segment aside rather than deleting it: the ingester starts with a truncated log, every trace in that segment is lost, and the file is the only artefact that can later answer what was in the gap.

Step 5 - OBJECT STORE: verify before you repoint, and restore both stores

A restored bucket is not a recovered bucket. Verify that objects covering the affected window are actually present, under the prefix and in the region Tempo is configured to use.

Read-only / Safedoes the restored bucket contain the window you need
# Substitute your own values before running:
BUCKET=tempo-traces-dr
PREFIX=blocks/single-tenant/

aws s3 ls "s3://$BUCKET/$PREFIX" --recursive --summarize | tail -3

Restore into a separate prefix or bucket and verify there, whenever the object store offers that option. Restoring in place over newer objects is not reversible, and the newer objects are the ones from the window you care about most.

Step 6 - CONFIG: validate before you start the binary

Read-only / SafeTempo ignores keys it does not recognise
tempo-cli validate-config /etc/tempo/tempo.yaml

# What is actually bound, which is a different question:
ss -lntp | grep -E '3200|4317|4318'
config is valid: /etc/tempo/tempo.yaml

Illustrative output

The validator checks that the YAML is structurally valid. It does not check that the bucket is reachable, that the credentials work, or that a port is free. Those are three separate questions and the second command answers only the last of them.

Deploy the known-good commit from version control rather than hand-editing the running configuration, then diff the two. A hand edit made during an incident is the drift that causes the next one.

Step 7 - Bring it back in order, then prove it twice

Object store reachable, then ingesters, then queriers and the query frontend, then the compactor last.

Read-only / Safetwo different proofs, and you need both
# Substitute your own values before running:
TEMPO=http://tempo.observability.svc:3200
OLD_TRACE_ID=4bf92f3577b34da6a3ce929d0e0e4736

# 1. The write path and the read path together, on new data.
otel-cli span export --endpoint tempo.observability.svc:4317 \
--service recovery-check --name "post-recovery synthetic"

# 2. The historical read path, which is the only one that touches
#    the object store. Use an ID recorded BEFORE the incident.
curl -s "$TEMPO/api/traces/$OLD_TRACE_ID" \
| jq '[.resourceSpans[].scopeSpans[].spans[]] | length'

# 3. Search, which reads the search store rather than the blocks.
curl -sG "$TEMPO/api/search" \
--data-urlencode 'q={ resource.service.name = "checkout" }' \
--data-urlencode 'limit=10' | jq '.traces | length'

These three prove different things and none substitutes for another. The synthetic trace proves receiver, distributor, ingester and querier. The historical lookup proves the block bucket. The search proves the search store. Stopping after the first is the most common way a “finished” Tempo recovery is discovered to be half-finished.

Step 8 - Write down the gap, then restart the compactor

The gap boundary is the deliverable. Start, end, tenants, and whether the traces were lost or merely delayed - those are four separate facts and the fourth is the one people assume rather than establish.

Then restart the compactor and watch its first cycle complete. That cycle is the first time retention is applied to whatever shape the bucket is now in, and it is the last chance to notice that the shape is wrong while the mistake is still recoverable.

Holding is a first-class option

Not every Tempo recovery should be finished at 03:00. If the block bucket is intact and only the search store is missing, the platform can serve trace-by-ID lookups immediately while the search restore runs in daylight. If the write path is stalled and the volume has been expanded, ingest is safe and the bucket permissions can wait for the team that owns them.

Holding needs the same two things every deferral needs: a named owner and an end time. It also needs the gap published, because a partially recovered Tempo that nobody has described is worse than an obviously broken one - people trust it.

References

  1. Tempo: operations
  2. Tempo: storage backends
  3. Tempo: blocks and the object store layout
  4. Tempo: compaction
  5. Tempo: HTTP API
  6. Tempo: TraceQL
  7. Tempo: architecture