Skip to main content
RunBook Academy

← All runbooks in Observability

high riskcluster affecting~60 min

Runbook: Investigate Tempo Ingestion Failure

1 · Prerequisites

Confirm every item is in place before any state change.

  • Tempo Receivers
  • The Distributor
  • The Ingester
  • Tempo Storage
  • Tempo Access Control
  • Missing Traces Anatomy
  • Network reach to the Tempo HTTP port (3200 by default) from wherever you are running the checks
  • Read access to the object store bucket Tempo writes blocks to, or the name of the team that has it
  • The tenant header this estate uses, and whether auth_enabled is on - the answer changes every command below
  • A known-good client that can emit a single OTLP span on demand, so "no traces" can be distinguished from "no senders"

2 · Pre-checks

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

  • · Establish that the problem is the whole pipeline and not one trace. A single trace ID that will not resolve is the five-link SDK-to-Tempo chain and belongs to the missing-traces runbook. This runbook is for a pipeline that has stopped ingesting.
  • · Establish when it stopped, from the ingest counters rather than from when somebody noticed. The gap between those two timestamps is usually hours, and it is the first thing the post-incident review will ask for.
  • · Confirm the tracing failure is not a symptom of a wider outage. If the applications are down, they are not emitting; Tempo is behaving correctly and this is the wrong runbook.
  • · Confirm whether spans are still being SENT. A collector or Alloy exporter with a rising failure count proves the senders are alive; a silent exporter means the problem may be upstream of Tempo entirely.
  • · Check the ingester WAL filesystem before anything else, because it is the one failure mode that escalates on its own. If the flush path is broken, the WAL grows until the disk fills and the ingester stops accepting spans - and a full disk turns a degraded pipeline into a host problem.
  • · Note that metric names in Tempo have changed across releases. List the metric family on the running process and read what THIS build exposes rather than pasting a name from a runbook and concluding the value is zero because the query returned nothing.
  • · Establish whether this is one tenant or all of them. A single tenant hitting its own limits is a conversation with that team; every tenant failing is an infrastructure fault, and the two have different owners and different urgency.
  • · Agree the hold position before you start changing configuration. Traces are the third signal in an investigation - if metrics and logs are intact, running with tracing degraded until a maintenance window is a legitimate decision. It needs an owner and an end time, not silence.

3 · Procedure

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

  1. 1Read the two counters, twice, thirty seconds apart. tempo_distributor_spans_received_total on the distributor and the ingester-side received or traces-created counter. The pair of deltas classifies the failure into one of four before you change anything: nothing arriving, arriving and refused, accepted and not reaching the ingesters, or accepted and not flushing.
  2. 2Branch A - nothing arriving. The distributor counter is flat. Confirm the receiver is bound (ss -tlnp for 4317, 4318, 14250 or 9411), confirm the receiver stanza parsed by reading the log at startup, and confirm the client can reach the port at all. A receiver stanza that did not parse is silently absent, not an error.
  3. 3Branch A, second cause - protocol mismatch. An OTLP exporter pointed at the gRPC port when only the HTTP receiver is enabled gets a connection and then a handshake failure. The client log names it; the server side looks like no traffic. Check which protocols are actually configured, not which ones you expect.
  4. 4Branch B - arriving and refused. The distributor counter moves and a drop or refusal counter moves with it. Read the client-side error: 429 is a rate limit, ResourceExhausted is a batch larger than max_recv_msg_size_mib, and a validation rejection is a malformed or oversized trace. The four per-tenant caps are ingestion_rate_limit_bytes, ingestion_burst_size_bytes, max_traces_per_user and max_bytes_per_trace.
  5. 5Branch B, check the tenant first. If spans are landing under the tenant anonymous, the per-tenant override is not being applied and everything is sharing the global limits. That is a header problem - X-Scope-OrgID missing or the wrong case - and raising a limit will not fix it.
  6. 6Branch C - accepted but not reaching the ingesters. The distributor is receiving and the ingester side is not. Read the ingester ring: every member should be ACTIVE, and the count should match the number of ingesters you run. A ring short of replication_factor members cannot satisfy the write quorum and the distributor fails the append.
  7. 7Branch D - accepted, held, and not flushing. Counters move on both sides, traces from the last few minutes are queryable, and nothing older is. Look for failed flushes and for the last block written to the bucket. This is the dangerous branch: it is invisible for the first flush interval and it fills the WAL disk if it is not caught.
  8. 8Branch D - prove the bucket, not the config. List the block prefix for the tenant and check the timestamp of the newest object. A flat object count across hours with a healthy-looking Tempo means writes are not landing, and the usual cause is a credential that lost s3:PutObject in a policy change rather than anything inside Tempo.
  9. 9Before any restart, check the WAL filesystem. df -h on the WAL path and the size of the WAL directory. The WAL is what makes an ingester restart survivable; a restart with a full or failing WAL disk is how unflushed traces are lost for good.
  10. 10Restart only what is stateless, and only after the cause is known. The distributor holds nothing and can be restarted freely. An ingester holds unflushed spans; stop it gracefully so it flushes and replays, never kill it, and never restart it while the flush path is the thing that is broken.
  11. 11Apply the narrowest fix that addresses the branch you identified. Bind the missing receiver, correct the tenant header, fix the bucket credential, or - if the cause is genuinely a tenant sending more than the platform sized for - talk to that tenant about sampling before raising a limit that exists to protect the ingesters.
  12. 12Prove ingestion end to end with a synthetic span emitted through the same path a real client uses, then fetch it back by trace ID. A metric that moved is evidence; a trace you can retrieve is proof.
  13. 13Record the interval and the cause: when ingestion stopped, when it was noticed, which branch it was, and what the detection gap was. Tracing failures are noticed late by default, and the detection gap is usually the finding rather than the fault itself.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓tempo_distributor_spans_received_total is advancing for every tenant you expect, sampled twice at least thirty seconds apart. A single reading proves the counter exists; two readings prove ingestion is happening.
  • ✓The ingester-side counter advances in step with the distributor-side counter. The two moving together is what separates "spans are arriving" from "spans are arriving and being stored".
  • ✓The append-failure counter is flat across the same interval. A counter that stopped rising is not the same as one that never rose - take two readings of this one too.
  • ✓curl -s http://tempo:3200/ingester/ring shows every expected ingester in state ACTIVE, and the member count is at least the replication factor. Anything less means writes cannot reach quorum even if everything else is healthy.
  • ✓The failed-flush counter is zero or flat, and the newest object under the tenant block prefix in the bucket has a timestamp inside the last flush interval. The bucket is the only durable copy; a healthy Tempo writing nothing to it is the failure this check exists to catch.
  • ✓df -h on the WAL path shows the usage falling or stable rather than climbing. A WAL that is still growing after the fix means flushing has not resumed, whatever the counters say.
  • ✓A synthetic span emitted through the normal client path is retrievable by trace ID from /api/traces, and a TraceQL search for its service returns it. This exercises receiver, distributor, ingester and query path in one action.
  • ✓The client side agrees: the collector or Alloy exporter's failure counter has stopped advancing and its queue has drained. Tempo accepting spans while the senders are still failing means the fix landed somewhere other than the fault.
  • ✓Traces from the affected window are either present or explicitly written off. Spans refused during the incident were not queued anywhere - "it is working now" is not the same claim as "nothing was lost".

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Most of this runbook is read-only, and its rollback is simply to stop. Every diagnostic step above reads state; nothing before the fix changes behaviour, which is what makes the classification affordable to do properly.
  • ↶A configuration change to the distributor rolls back by reverting the file and restarting it. The distributor is stateless: it holds no spans, and restarting it costs only the in-flight batches, which the senders will retry.
  • ↶POINT OF NO RETURN: killing an ingester with unflushed spans. A graceful stop flushes and the WAL replays on start; a kill leaves the WAL as the only copy, and a kill onto a full or failing WAL disk leaves nothing. There is no undo, so establish the state of the WAL filesystem before the restart, not after.
  • ↶A raised rate limit is reversible but not free. The limit exists to protect the ingesters and the bucket from a client that is misbehaving; raising it moves the failure downstream to memory pressure and object-store cost, and lowering it again will drop spans from a tenant that has come to depend on the higher ceiling.
  • ↶Reducing sampling at the source is reversible and is usually the cheaper direction under overload. It also changes what future investigations can see, so it is a decision with an owner rather than a knob to turn during triage.
  • ↶If the fix does not work, the safe fallback is the hold position: leave the pipeline degraded, tell the people who use traces that they are missing, and continue in daylight. Tracing is the third signal - a degraded tracing pipeline is an investigation cost, and it is preferable to an improvised change to a stateful service at 03:00.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the object-store or cloud owner when the bucket is refusing writes. A credential that lost s3:PutObject in a policy refactor is not fixable from inside Tempo, and every minute it continues is WAL growth on the ingesters.
  • · Escalate to the network or platform team when the receiver is bound and healthy but clients cannot reach it. Tempo being correct and Tempo being reachable are separate claims with separate owners.
  • · Escalate to the tenant's service team when one tenant is exceeding its limits. The fix is their sampling configuration or their instrumentation, and raising the limit to make the alert stop is a platform decision made on their behalf without their knowledge.
  • · Escalate to the platform owner before raising any global ingestion limit. The limits are a capacity decision about the ingesters and the bucket, and changing them under incident pressure is how the next incident is created.
  • · Escalate immediately if the ingester WAL filesystem is above 80 percent and still climbing. That is a countdown to an ingester that stops accepting spans, and possibly to a host-level disk problem affecting more than Tempo.
  • · Escalate to the security owner if the receivers are exposed beyond the service network. The receiver ports accept unauthenticated traffic by default, and an unexplained ingest spike from an unknown source is an incident rather than a capacity event.

Somebody has opened a trace view and found nothing recent. That single symptom is produced by four separate failures with four different owners, four different urgencies, and one of them on a timer.

The reflex is to restart Tempo. It is the wrong first move in three of the four cases and actively destructive in the fourth.

When to use this runbook

  • Trace search returns nothing recent, across services rather than for one trace.
  • Collectors or Alloy report export failures to Tempo.
  • The ingest-rate panel has been flat since a deploy, a certificate change, or a cloud policy change.
  • An ingester is reporting disk pressure on its WAL filesystem.

It does not apply when:

  • One trace ID will not resolve. That is the five-link chain from SDK to Tempo - instrumentation, propagation, collector, exporter, sampling
    • and it belongs to the missing-traces runbook. Most of those links are upstream of anything Tempo can see.
  • Traces are arriving but queries are slow or empty. The write path is working; the read path is the problem, and restarting ingesters will not help it.
  • The bucket itself has been lost. That is Tempo recovery, not ingestion. Ingestion can be perfectly healthy while every historical block is gone.
  • The applications are down. They are not emitting. Tempo is behaving correctly and this is the wrong page.

Blast radius

Tempo ingestion failing is not, on its own, a customer-facing outage. No request fails because a span was dropped. What is lost is investigation capability, and it is lost silently: the traces for the window are simply not there when somebody comes looking, usually during a different incident.

Two ways it escalates into something larger:

  • The WAL disk. If spans are being accepted and cannot be flushed, the ingester keeps writing to its write-ahead log. That grows until the filesystem fills, at which point the ingester stops accepting spans - and a full filesystem is rarely a problem confined to one process.
  • Back-pressure into the senders. A distributor returning 429 makes collectors retry and buffer. Their queues grow, their memory grows, and on a shared agent that memory is being taken from whatever else the agent is doing.

The remediation has a blast radius of its own. Restarting an ingester that is holding unflushed spans risks those spans. Raising an ingestion limit moves the pressure to the ingesters and the bucket, which is what the limit was protecting.

Step 1: The reading that classifies the failure

Read-only / Safetwo readings, thirty seconds apart
TEMPO=http://tempo.internal:3200

# List the families rather than one name. Metric names have changed
# across Tempo releases; read what THIS build exposes.
curl -s "$TEMPO/metrics" | grep -E '^tempo_(distributor|ingester)_' | sort > /tmp/t1
sleep 30
curl -s "$TEMPO/metrics" | grep -E '^tempo_(distributor|ingester)_' | sort > /tmp/t2

diff /tmp/t1 /tmp/t2 | grep -E 'spans_received|bytes_received|traces_created|append_failures|failed_flushes'

The counters that matter are the distributor’s received counter, the ingester-side received or traces-created counter, the distributor’s append-failure counter, and the ingester’s failed-flush counter. Read them as a set:

Distributor receivedIngester sideAppend failuresFailed flushesBranch
flatflatflatflatA - nothing arriving
risingflat or laggingflatflatB - arriving and refused
risingflatrisingflatC - ring cannot take the write
risingrisingflatrisingD - not flushing to the bucket

Two readings, not one. A counter with a large value proves the pipeline worked at some point in the past, which is exactly the fact that misleads people at 03:00.

Branch A: Nothing is arriving

Read-only / Safeis the front door open, and to which protocol
# Is anything bound? OTLP gRPC 4317, OTLP HTTP 4318,
# Jaeger gRPC 14250, Zipkin 9411.
ss -tlnp | grep -E '4317|4318|14250|9411'

# Did the receiver stanza parse at all? A misspelled key is skipped
# silently - there is no error, the receiver simply does not exist.
journalctl -u tempo --since '1 hour ago' | grep -iE 'receiver|listen|error'

# Tempo's own readiness.
curl -s http://tempo.internal:3200/ready

Three causes, in the order they are worth checking:

The receiver is not configured. A tempo.yaml whose distributor.receivers block was never enabled, or was enabled on the wrong role in a microservices deployment, gives clients a connection refused. In microservices mode the receivers belong to the distributor; a config copied onto a querier binds nothing.

The protocol does not match. An OTLP exporter aimed at 4317 when only the HTTP receiver is enabled establishes a TCP connection and then fails the gRPC handshake. From the server the traffic is invisible; from the client the error is explicit. Read the client’s log - it is the better evidence here.

The network path is broken. A policy change, a certificate, a NAT. The receiver is bound and healthy and nothing reaches it. Test from a client host rather than from the Tempo host, because testing from the Tempo host proves only that Tempo can talk to itself.

Branch B: Arriving and refused

Read the client error before touching the server, because it names the cause precisely:

  • 429 - a per-tenant ingestion limit. The four caps are ingestion_rate_limit_bytes, ingestion_burst_size_bytes, max_traces_per_user and max_bytes_per_trace.
  • ResourceExhausted - the OTLP batch is larger than max_recv_msg_size_mib on the receiver. One noisy service drops everything while the rest of the fleet is fine.
  • A validation rejection - a trace without a usable ID, a timestamp far outside the acceptable range, or an attribute value large enough to be a bug rather than data.

The instinct at this point is to raise the limit. Resist it for one minute and ask what changed: a new SDK release that double-emits, a sampling policy that was turned off, a retry loop. The limit did its job

  • it kept one tenant from taking the ingesters down for everyone. Raising it moves the failure to the ingesters and the bucket, where it is more expensive and harder to reverse.

Where the cause is genuinely more traffic than the platform was sized for, the cheaper direction is usually sampling at the source. That is the tenant’s change, with the tenant’s knowledge.

Branch C: The ring cannot take the write

Read-only / Safeingester ring membership
curl -s http://tempo.internal:3200/ingester/ring | jq '.'
curl -s http://tempo.internal:3200/ingester/ready
{"name":"ingester","members":[
{"addr":"tempo-0:3200","state":"ACTIVE"},
{"addr":"tempo-1:3200","state":"ACTIVE"},
{"addr":"tempo-2:3200","state":"ACTIVE"}]}

Illustrative output

Every ingester should be present and ACTIVE. With replication_factor: 3, the distributor forwards each trace to three ingesters and needs a quorum to acknowledge; a ring two members short cannot satisfy that, and the distributor fails the append no matter how healthy the surviving ingesters look individually.

A ring that is churning rather than short usually means the lifecycler or the memberlist KV store, and a rolling restart in progress is the commonest benign explanation. Confirm nobody is mid-deploy before treating it as a fault.

Branch D: Accepted, held, never flushed

This is the branch that hides. Spans are accepted, so the counters look right. Recent traces are queryable, because they are still in the head block. Only traces older than the flush interval are missing, and nobody notices until they go looking for something from this morning.

Meanwhile the WAL grows.

Read-only / Safeis the bucket actually receiving blocks
# The newest object under the tenant prefix. A timestamp older than
# the flush interval means writes are not landing.
aws s3 ls s3://tempo-traces-prod/blocks/single-tenant/1/ --recursive \
| sort | tail -3

# Tempo's own view: failed flushes, and blocks flushed.
curl -s http://tempo.internal:3200/metrics \
| grep -E 'tempo_ingester_(failed_flushes|blocks_flushed)_total'

# What the process says when the write fails.
journalctl -u tempo --since '1 hour ago' \
| grep -iE 'AccessDenied|SlowDown|flush|s3|bucket'

The usual cause is not inside Tempo at all. A credential that lost s3:PutObject during a policy refactor produces exactly this shape: the ingester retries with backoff, reports success on the accept path, and nothing lands. AccessDenied in the log is the confirmation.

The other shapes worth recognising: SlowDown (or ServerBusy, or a 429 from the object store) means the bucket is throttling and the fix is rate rather than permissions; a connection refused means endpoint or DNS; and a lifecycle policy that has archived recent blocks produces read failures rather than write failures, which is a different problem wearing similar clothes.

The restart decision

The distributor is stateless. It holds nothing, and restarting it costs only the in-flight batches, which the senders retry. If a distributor change is needed, make it.

The ingester is not. It holds spans that exist nowhere else until they are flushed. Stop it gracefully so it can flush and so the WAL is consistent for replay; never kill it; and never restart it while the object store is the broken component, because the flush it needs to perform is the thing that cannot happen.

If the pressure is genuinely intolerable and the bucket is still broken, that is an escalation, not a restart.

Verification

Read-only / Safea synthetic span, end to end
# Emit through the same path a real client uses - not straight at the
# ingester, which would prove nothing about the receiver.
otel-cli span --service runbook-check --name ingest-probe \
--endpoint tempo.internal:4317

# Then fetch it back. Substitute the trace ID the command printed:
TRACE_ID=4bf92f3577b34da6a3ce929d0e0e4736
curl -s "http://tempo.internal:3200/api/traces/$TRACE_ID" | jq '.batches | length'

# And confirm it is searchable, which exercises the query path too.
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'q={ resource.service.name = "runbook-check" }' \
--data-urlencode 'limit=5' | jq '.traces | length'

Then re-read the counters. A moving metric plus a retrievable trace plus a draining WAL is the complete claim; any one of them on its own is not.

Finally, look at the senders. Tempo accepting spans while the collectors are still failing to export means the fix landed somewhere other than the fault.

Common patterns

SymptomLikely branchResolution
Connection refused from every clientAReceiver not bound, or configured on the wrong role
Client reports a gRPC handshake failureAOTLP aimed at a protocol that is not enabled
Client reports 429BPer-tenant ingestion limit; check the tenant before the limit
Client reports ResourceExhaustedBBatch larger than max_recv_msg_size_mib
Everything counted against tenant anonymousBX-Scope-OrgID missing or wrong case; overrides never match
Distributor receiving, append failures risingCRing short of members; check for a stuck rolling restart
Recent traces fine, older ones gone, WAL growingDFlush path broken; check the bucket credential before Tempo
Bucket returning SlowDownDThrottling, not permissions; reduce rate or spread the prefix
Ingestion fine after a restart, back within an hourD, unresolvedThe restart cleared the symptom; the flush path is still broken

Escalation

Escalate when:

  • The bucket is refusing writes. Every minute of that is WAL growth.
  • The receiver is healthy and clients cannot reach it - that is the network team’s evidence to read, not yours.
  • One tenant is over its limits: their sampling, their decision.
  • A global limit would have to change. That is capacity planning, not triage.
  • The WAL filesystem is above 80 percent and climbing.
  • Ingest is spiking from a source nobody recognises. The receiver ports accept unauthenticated traffic by default, and that makes an unexplained spike a security question.

References

  1. Tempo configuration reference (distributor, ingester, storage)
  2. Tempo: HTTP API
  3. Tempo: operations guide (monitoring, ingester and compactor behaviour)
  4. Tempo: per-tenant overrides and ingestion limits
  5. Tempo: storage backend configuration
  6. OpenTelemetry Collector: OTLP exporter
  7. OpenTelemetry Protocol specification