← All runbooks in Observability
Runbook: Investigate Missing Traces
1 · Prerequisites
Confirm every item is in place before any state change.
- Missing Traces Anatomy
- Trace Propagation Test
- Tempo Architecture Overview
- Tempo Receivers
- TraceQL Introduction
- Tempo Querying
- Read access to the application logs, to the collector internal-telemetry endpoint, and to the Tempo query API
- The service name as the SDK reports it in the service.name resource attribute, which is frequently not the name humans use for the service
- A contact for whoever owns the ingress proxy or service mesh, because a stripped traceparent header is almost never fixable by the service team alone
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The trace ID is in hand, copied rather than retyped, and is 32 hexadecimal characters. A truncated or transposed ID returns exactly the same not-found as a genuinely missing trace, and it is the single cheapest thing to rule out.
- · The scope is established before any theory: one trace missing, every trace from one service missing, or Tempo empty for every service. The three have different first moves and the first move for one wastes the window for the others.
- · It is established whether this ever worked. A service that has never produced a trace is a deployment problem, not an incident, and it does not belong in an incident channel at 03:00.
- · Tempo itself is confirmed to be answering: /ready on the HTTP port, plus a retrieval of a trace ID known to have been present before the suspect window. Without that second half, a broken query path is indistinguishable from a broken write path.
- · The age of the trace is compared against compactor.compaction.block_retention. A trace deleted by retention policy is not missing; it is expired, and no step in this runbook will bring it back.
- · The age of the trace is also compared against the export path latency. The SDK batch processor and the collector batch processor each add delay, so a trace queried seconds after the request has plausibly not been written yet. Wait before concluding.
- · The service name is confirmed as the SDK emits it, from OTEL_SERVICE_NAME or the resource attributes on a span that did arrive. Searching TraceQL for the name on the org chart returns nothing and proves nothing.
- · Every step in the diagnosis below is read-only. It is worth saying out loud before starting, because the reflex under pressure is to restart the collector, and that destroys the evidence at link D without touching the cause.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Establish the scope first. One trace, one service, or everything. Everything at once points at the collector, at Tempo, or at a shared credential rather than at any application, and starting at an application in that case burns the first twenty minutes.
- 2Link A - confirm the SDK is running inside the application process. Look for the SDK startup banner in the service log and for the OTEL_ environment variables in the running process. No SDK means no span was ever created, and every later link is vacuously healthy. This is the most common answer and it is the cheapest to check.
- 3Link A, second half - confirm the SDK is producing spans, not merely loaded. An SDK that initialises without a span processor attached prints its banner, registers a tracer, and exports nothing. The SDK self-observability scrape separates the two: counters flat at zero with a banner present means the exporter was never wired.
- 4Link B - confirm the traceparent header survives every hop. Send one request through the entry point with a known traceparent and count the spans that come back under that trace ID. A count of one means the first hop after the entry point dropped the header. Check the count, not the presence of the trace.
- 5Link B, second half - identify which hop dropped it. Compare the unique set of service.name values in the returned trace against the expected call path. The first service missing from the set is the boundary to investigate: an ingress proxy header allow-list, a mesh sidecar header policy, or an async queue that carries no context.
- 6Link C - confirm the collector is reachable from the application host or pod. A TCP probe to the OTLP port from inside the client network namespace. Connection refused is the collector; a connection that opens and then fails the gRPC handshake is a protocol or TLS mismatch, which looks like the same symptom and has a different owner.
- 7Link C, second half - confirm the collector is actually accepting the spans. otelcol_receiver_accepted_spans climbing proves that bytes are arriving and being decoded. Flat at zero with the port open means the client is connecting to a receiver that is not configured for the protocol it is speaking.
- 8Link D - confirm the exporter is delivering to Tempo. Read otelcol_exporter_sent_spans against otelcol_exporter_send_failed_spans and otelcol_exporter_queue_size together. Sent flat with failed climbing is a delivery failure; sent flat with queue_size at its capacity is back-pressure, and the two are fixed in different places.
- 9Link D, second half - read the collector log for the reason. The counters say that delivery failed; only the log says why. A certificate signed by unknown authority, a ResourceExhausted on message size, and a DNS failure all produce the same rising counter.
- 10Link E - confirm the sampler kept this trace. Read the sampling decision counters and compare kept against dropped. A tail sampler whose decision_wait is shorter than the slowest span in the trace drops traces that were complete and correct, and it does so consistently rather than randomly, which is what makes it look like a storage problem.
- 11Tempo side - confirm the distributor received and did not drop. tempo_distributor_spans_received_total minus tempo_distributor_dropped_spans_total is the delta that proves Tempo took the spans. A rising dropped counter is a Tempo-side limit or a rejected payload, and it moves the investigation to the ingestion runbook.
- 12Name the link before fixing anything. Write down A, B, C, D or E and the evidence for it. Every fix below restarts a process or changes a shared configuration, and applying one to the wrong link produces a restart, no improvement, and a lost hour.
- 13Apply the fix that matches the link, and re-run the propagation test to prove it. The synthetic request with a known trace ID is the only check that exercises all five links together. Readiness endpoints and healthy counters, in any combination, do not.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓The specific trace ID that started this investigation returns spans from the Tempo trace-by-ID endpoint. Not "traces are appearing" - that one ID, retrieved and counted.
- ✓The span count in the returned trace matches the expected hop count for that request path. A trace that exists with one span is a link-B failure that a presence check reports as success.
- ✓The unique set of service.name resource attributes in the trace matches the expected call path end to end, including the last hop. Verifying at the entry point proves only that the entry point is instrumented.
- ✓otelcol_exporter_sent_spans is climbing while otelcol_exporter_send_failed_spans stays flat, and otelcol_exporter_queue_size is well below its configured capacity rather than pinned at it.
- ✓otelcol_receiver_accepted_spans is climbing for the receiver and transport the client actually uses, checked by label rather than in aggregate.
- ✓tempo_distributor_spans_received_total is climbing and tempo_distributor_dropped_spans_total is not. A non-zero delta between them is the only proof that Tempo accepted what the collector sent.
- ✓The scheduled propagation test passes, with the expected span count, on a run that started after the fix rather than before it.
- ✓The written diagnosis names one link and one boundary. "It was flaky" and "it came back on its own" are not diagnoses and guarantee the same hour is spent again.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Every diagnostic step is read-only and has nothing to undo. The items below are cleanup for fixes applied under pressure, which is where the real risk in this runbook lives.
- ↶If the sampling rate was raised to catch a trace, put it back. A head sampler left at 100 percent quietly multiplies ingest cost, storage cost and collector memory, and nobody notices until the next capacity review.
- ↶If a debug or logging exporter was added to the collector to see spans on the way through, remove it. It writes span attribute values into the collector log, which is the fastest way to move customer data into a log store that was never scoped to hold it.
- ↶If TLS verification was disabled on an exporter to get past a handshake error, re-enable it and fix the trust bundle instead. The disabled setting survives the incident, is invisible in every dashboard, and turns the span path into an unauthenticated one.
- ↶If a traceparent allow-list entry was added by hand at a proxy, get it into the configuration that builds that proxy. Header allow-lists are regenerated on the next deploy and the fix disappears without warning.
- ↶If an application was restarted by hand with the instrumentation agent attached, that restart is not the fix. The fix is the deployment manifest; until then the next rollout removes the instrumentation again.
- ↶If per-tenant or receiver limits were raised to absorb a burst, lower them once the backlog drains and record what the burst actually was. A limit raised during an incident and never reviewed is how the next capacity surprise is built.
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the team that owns the service when the diagnosis is link A. The SDK bootstrap, the agent attachment and the shutdown flush all live in their build and their deployment, and no platform-side change fixes any of them.
- · Escalate to the owner of the ingress proxy or the service mesh when the diagnosis is link B. A header allow-list is a platform control, and the service team cannot see it, let alone change it.
- · Escalate to networking when the collector port is unreachable from the client network namespace but reachable from elsewhere. That is a policy or routing question, and collector configuration changes will only obscure it.
- · Escalate to the platform team when tempo_distributor_dropped_spans_total is the rising counter. At that point the spans reached Tempo and Tempo refused them, which is a limits and capacity question rather than a pipeline one.
- · Escalate to security immediately if reading a trace exposes attribute values that should not be in telemetry - credentials, tokens, full request bodies or personal data. That is a disclosure with its own clock, independent of whether the trace was found.
- · Escalate when every service lost traces at the same moment and the collector checks out. Simultaneity is evidence: many services do not break together, but one shared credential, one certificate or one collector deployment does.
- · Escalate when the missing window covers an incident that is still open. Traces are the signal that ties a request to its dependency graph, and an incident commander needs to know explicitly that the graph is unavailable rather than empty.
A trace_id in a log line is proof that a span existed. The SDK
generated that ID, put it into the logging context, and the request
finished. Tempo answering not-found for the same ID is therefore not a
statement about the application. It is a statement about the distance
between the process that made the span and the block the querier reads.
Five things have to hold across that distance. They fail independently, they present identically, and only one of them is likely.
A B C D E
+-----+ +-----------+ +---------+ +--------+ +-------+
| SDK | --> |traceparent| ->|collector|-->|exporter|--> | Tempo |
+-----+ +-----------+ +---------+ +--------+ +-------+
running survives accepting delivering sampler
in the every hop OTLP to Tempo kept it
process
Walk them in that order. It is the cost order, and it is also the probability order: when the SDK never ran, every link downstream of it is vacuously healthy and will report so, confidently, for as long as you keep asking.
When this runbook applies, and when it does not
It applies when a trace that the application demonstrably emitted cannot be retrieved from Tempo, or when a service that should be producing traces is producing none.
It does not apply when:
- The trace is older than retention. The compactor deleted it by policy. That is a retention conversation, not an incident, and no step here recovers it.
- Tempo itself is down or has lost its block backend. Empty results for every service, including historical ones, is a Tempo recovery problem. Confirm with a trace ID known to have worked yesterday before starting here.
- The query is wrong. A TraceQL filter on a service name that no
span carries returns nothing against a perfectly healthy pipeline.
Check the
service.namethe SDK actually emits first. - The trace was emitted seconds ago. The SDK batch processor buffers, the collector batch processor buffers again, and the ingester holds spans before flushing. Waiting is a legitimate first action and costs nothing.
Blast radius
| Action | Reversible? | What it costs if wrong |
|---|---|---|
| Reading logs, env vars, counters and the Tempo API | n/a | Nothing |
| Sending one synthetic request with a known trace ID | Yes | One request against the entry point |
| Restarting the collector | Yes | The in-flight queue, and the evidence at link D |
| Restarting an instrumented application | Partly | A production restart, plus any spans buffered in the SDK |
| Raising the sampling rate to catch a trace | Yes | Ingest, storage and collector memory, until somebody lowers it again |
| Adding a debug exporter to see spans | Yes | Span attribute values written into the collector log |
| Disabling TLS verification on an exporter | Yes, in principle | An unauthenticated span path that nothing will ever alert on |
The bottom three rows are where this runbook does damage. None of them is destructive; all of them are the kind of change that survives the incident, and two of them move customer data or remove an authentication boundary.
Establish the scope before you pick a link
One question halves the search space, and it is not “what is wrong with the service”.
| Scope | Where the cause almost always is |
|---|---|
| One trace, one service, other traces fine | Sampling, or the trace is younger than the export path |
| Every trace from one service | That service: link A, or its own egress to the collector |
| Every trace from one call path | A boundary on that path: link B at a proxy, mesh or queue |
| Every trace from everything | The collector, Tempo, or a shared credential. Not the applications |
Applications do not break simultaneously. Collectors, certificates and credentials do. When the answer is “everything”, the shortest path to a diagnosis skips links A and B entirely.
Step 1 - Link A, is the SDK running
# Substitute your own values before running:
NS=production
DEPLOY=checkout-svc
kubectl logs "deploy/$DEPLOY" -n "$NS" --since=15m \
| grep -iE 'opentelemetry|otel' | head -5
kubectl exec "deploy/$DEPLOY" -n "$NS" -- printenv | grep '^OTEL_'OTEL_SERVICE_NAME=checkout-svc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpcIllustrative output
Most language SDKs print a banner at INFO on startup. Go is the notable exception: the SDK is silent, so absence of a banner there proves nothing and you fall back to the environment variables and the self-observability counters.
Note what the second command tells you beyond “is it configured”. It tells you the endpoint the SDK is aiming at, and that value is the input to link C. Read it now rather than assuming it later.
Step 2 - Link B, does the header survive every hop
The propagation check is not “is there a trace”. It is “how many spans are in it, and from which services”. A trace that exists with one span is a broken chain that every presence check reports as healthy.
# Substitute your own values before running:
TRACE_ID=00000000000000000000000000000001
PARENT_ID=0000000000000001
ENTRY_URL=http://api-gateway.internal/checkout
TEMPO=http://tempo.observability.svc:3200
curl -fsS -X POST "$ENTRY_URL" \
-H "traceparent: 00-$TRACE_ID-$PARENT_ID-01" \
-H 'Content-Type: application/json' \
-d '{"order_id":"propagation-check"}' >/dev/null
sleep 5
curl -s "$TEMPO/api/traces/$TRACE_ID" \
| jq '[.resourceSpans[].scopeSpans[].spans[]] | length'
curl -s "$TEMPO/api/traces/$TRACE_ID" \
| jq '[.resourceSpans[].resource.attributes[]
| select(.key == "service.name") | .value.stringValue] | unique'7
[
"api-gateway",
"checkout",
"payment-svc",
"postgres-proxy"
]Illustrative output
The sleep 5 is not superstition. Spans are batched twice on the way
in, and querying immediately produces a not-found that means nothing.
The service list is the diagnosis. Compare it against the expected call path: the first service missing from the set is the hop to investigate, and the boundary in front of it is where the header went. Three boundaries account for nearly all of these - an ingress proxy with a request-header allow-list, a mesh sidecar with a header policy, and an asynchronous queue whose messages carry no context at all.
Step 3 - Link C, can the client reach the collector
Test from inside the client’s own network namespace. A probe from a workstation, or from the collector’s own node, answers a different question than the one you have.
# Substitute your own values before running:
NS=production
DEPLOY=checkout-svc
COLLECTOR=otel-collector.observability.svc
kubectl exec "deploy/$DEPLOY" -n "$NS" -- nc -zv "$COLLECTOR" 4317
# The collector's own internal telemetry, read from the collector side:
curl -s http://localhost:8888/metrics \
| grep -E '^otelcol_receiver_accepted_spans'otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 128420Illustrative output
Two failures hide behind one symptom here. Connection refused is the collector: not running, not listening, or listening on an address the client cannot reach. A connection that opens and then fails is a protocol mismatch - a client speaking gRPC to a receiver that has only the HTTP protocol enabled gets its connection accepted by the operating system and its handshake rejected by the receiver.
Read otelcol_receiver_accepted_spans by label, not in aggregate. The
counter being non-zero for transport="http" while your client speaks
gRPC tells you the pipeline works and your client is not in it.
Step 4 - Link D, is the exporter delivering
curl -s http://localhost:8888/metrics \
| grep -E '^otelcol_exporter_(sent_spans|send_failed_spans|queue_size|queue_capacity)'otelcol_exporter_sent_spans{exporter="otlp/tempo"} 6104
otelcol_exporter_send_failed_spans{exporter="otlp/tempo"} 91204
otelcol_exporter_queue_size{exporter="otlp/tempo"} 5000
otelcol_exporter_queue_capacity{exporter="otlp/tempo"} 5000Illustrative output
Read all four or you will misdiagnose. Sent flat with failed climbing is a delivery failure, and the cause is downstream. Sent flat with the queue at capacity is back-pressure, and the cause may be the same thing one step further on, or may be a collector sized below its input.
Neither counter says why. Only the log does, and the three most common reasons produce an identical rising counter:
| Log line | Cause | Owner |
|---|---|---|
x509: certificate signed by unknown authority | The collector does not trust Tempo’s CA | Whoever issues the internal certificates |
code = ResourceExhausted on message size | A span batch larger than Tempo accepts | The service emitting oversized attributes |
| A DNS or dial failure on the exporter endpoint | The endpoint moved, or policy blocks it | Networking |
Step 5 - Link E, did the sampler keep it
Sampling is the link people reach for first and should reach for last, because a sampler that drops your trace is doing what it was told.
curl -s http://localhost:8888/metrics \
| grep -E 'tail_sampling|probabilistic_sampler'otelcol_processor_tail_sampling_count_traces_kept{policy="errors"} 4211
otelcol_processor_tail_sampling_traces_dropped_too_early 41028Illustrative output
There are two distinct shapes here and they need different fixes.
A head sampler at a low probability drops traces at random. Your missing trace is unremarkable, most traces are missing, and the platform is behaving exactly as configured. The fix is a sampling policy conversation, not an incident.
A tail sampler dropping traces too early is the more interesting failure. The sampler waits a fixed period for a trace to be complete and then decides. When that wait is shorter than the slowest span in the trace, spans arrive after the decision and the sampler drops a trace it never saw whole. This is not random: it systematically discards exactly the slow traces you are investigating, and it looks like a storage problem because the fast traces are all present.
Step 6 - The Tempo side of the boundary
TEMPO=http://tempo.observability.svc:3200
curl -s "$TEMPO/ready"
curl -s "$TEMPO/metrics" \
| grep -E '^tempo_distributor_(spans_received_total|dropped_spans_total)'ready
tempo_distributor_spans_received_total 4821004
tempo_distributor_dropped_spans_total 0Illustrative output
A rising tempo_distributor_dropped_spans_total moves this out of the
missing-traces runbook entirely: the spans arrived and Tempo refused
them, which is a limits and capacity question. spans_received_total
flat at zero with a healthy collector exporter means the two are not
talking to each other, and you are back at link D with better evidence.
Common patterns
| Symptom | Likely link | What to check first |
|---|---|---|
| No trace IDs in the application logs at all | A | The agent is not attached in the deployment |
| SDK banner present, no spans anywhere | A | No span processor wired to the tracer provider |
| Traces exist but every one has a single span | B | The first boundary after the entry point |
| Two traces per request, different IDs | B | A proxy or mesh header allow-list |
| Producer and consumer in different traces | B | An async boundary carrying no context |
| Connection refused on the OTLP port | C | The collector, from the client namespace |
| Connection opens, handshake fails | C | Protocol or TLS mismatch on the receiver |
| Exporter queue pinned at capacity | D | Back-pressure; read the log for the reason |
| Only slow traces are missing | E | Tail sampler decision wait shorter than the trace |
| Traces missing only after a deploy | A or B | Whatever that deploy changed, in that order |
| Everything missing at one instant | C, D or Tempo | A shared credential, certificate or restart |
Holding is a legitimate outcome
Not every missing trace is worth chasing to the end during an incident. If the immediate question can be answered from metrics and logs, say so and hold the trace investigation: record the trace ID, the scope and the window, hand it to the owning team with an end time, and go back to the incident. Traces are the third stop in an investigation, and spending the whole window at the third stop is its own failure mode.
Holding needs the same two things any other deferral needs - an owner and an end time. Without them it is not a hold, it is a drop.