Diagnosing Missing and Broken Traces
Problem Framing
An engineer pastes a trace ID into the UI and gets “trace not found”. Or worse: the trace is there, but it stops at the third service, and the two services downstream — which definitely ran, because the order was placed — contributed nothing. Either way the investigation stalls, and the usual response is to guess: restart something, bump a queue size, add a log line.
Missing telemetry has a small number of causes and a fixed pipeline in which to look for them. A span has to be created, sampled, exported, accepted, forwarded, and indexed, and every one of those steps has a counter that says whether it happened. This page walks the pipeline in order, because checking it in order is what turns a two-hour guessing session into a five-minute diagnosis.
Prerequisites
- Access to the service’s logs and environment, ideally with the ability to set
OTEL_LOG_LEVEL=debugon one instance. - Access to the Collector’s own metrics endpoint (
:8888/metricsby default). - Query access to the trace backend, including fetch-by-ID — see querying traces with TraceQL and Jaeger search.
- Knowledge of the sampling configuration, because a “missing” trace is frequently a correctly sampled-out trace.
Concept Deep-Dive: Six Places a Span Can Die
Before touching anything, classify what you are looking at. The three shapes have almost disjoint cause sets:
- Absent — nothing from a service, ever. Instrumentation or export configuration.
- Partial — traces exist but some spans are missing, often the deepest or last ones. Queue pressure or sampling.
- Fragmented — every service produces traces, but each is its own root. Context propagation, not export.
Step-by-Step Implementation
Step 1 — Prove the span exists in the process
Take transport out of the picture entirely:
# Restart one instance with the console exporter and issue exactly one request.
OTEL_TRACES_EXPORTER=console \
OTEL_LOG_LEVEL=debug \
OTEL_SERVICE_NAME=checkout-api \
python -m myservice
If spans print, creation works and the problem is downstream. If nothing prints, the cause is almost always one of three things: the SDK initialized after the framework was imported (so nothing was patched), the sampler is always_off or a rate rounding to zero, or the instrumentation package for that library is not installed. OTEL_LOG_LEVEL=debug will name the instrumentations it actually loaded.
Step 2 — Rule out sampling
A trace that was never sampled is not a bug, and confirming this takes seconds:
# What did this process decide?
env | grep -E 'OTEL_TRACES_SAMPLER|OTEL_TRACES_SAMPLER_ARG'
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=0.01 ← 1 in 100 traces is stored
With parentbased_* the upstream decision wins, so a service that looks “unsampled” may simply be honouring a caller that sampled out. Force one trace through instead of arguing about probabilities:
# Send a request with the sampled flag set (last byte 01) and a known trace id
curl -H "traceparent: 00-11111111111111111111111111111111-2222222222222222-01" \
https://api.example.com/checkout/123
# Then fetch that exact id from the backend — no search, no time window.
curl -s http://tempo:3200/api/traces/11111111111111111111111111111111 | jq '.batches | length'
Step 3 — Check the SDK’s export path
# Debug logging names the endpoint and reports failures explicitly
OTEL_LOG_LEVEL=debug ./service 2>&1 | grep -iE 'otlp|export|refused|deadline'
# Failed to export spans: connection refused: otel-collector:4317
The classic misconfigurations, in the order they occur in practice: exporting gRPC (4317) to an HTTP-only receiver (4318) or the reverse; a https:// endpoint against a plaintext receiver; a OTEL_EXPORTER_OTLP_ENDPOINT that includes /v1/traces when the SDK appends it too; and a NetworkPolicy that blocks the collector port while allowing everything else.
Step 4 — Read the Collector’s own counters
The Collector is the only component in the chain that publishes a complete accounting of what it received, refused, dropped, and sent.
curl -s http://otel-collector:8888/metrics | grep -E \
'receiver_accepted_spans|receiver_refused_spans|processor_dropped_spans|exporter_sent_spans|exporter_send_failed_spans|exporter_queue_size'
# otelcol_receiver_accepted_spans{receiver="otlp"} 1284391
# otelcol_receiver_refused_spans{receiver="otlp"} 9822 ← memory_limiter
# otelcol_processor_dropped_spans{processor="filter"} 41200 ← filtered on purpose?
# otelcol_exporter_send_failed_spans{exporter="otlp/tempo"} 118
# otelcol_exporter_queue_size{exporter="otlp/tempo"} 4998 ← near capacity
Read those numbers as a funnel. The gap between what a service emitted and what the backend stored is never a mystery once each drop is attributed:
refused_spans climbing means the memory limiter is shedding load — the fix is capacity or sampling, not a config tweak on the SDK. processor_dropped_spans on a filter processor usually means a filter matched more than its author intended.
Step 5 — Distinguish “not stored” from “not found”
# Fetch by ID bypasses search entirely.
curl -s -o /dev/null -w '%{http_code}\n' \
http://tempo:3200/api/traces/4bf92f3577b34da6a3ce929d0e0e4736
# 200 → stored, so the problem is the search query or the time window
# 404 → never stored, go back to step 4
If fetch-by-ID works but search does not, the cause is one of: a time range that excludes the trace because of clock skew, a service.name that is missing (so the trace is filed under unknown_service), or an attribute filter naming a key nothing sets.
Which spans go missing first, and why that is a clue
Partial traces are not random. The pattern of what survives tells you which stage failed, and reading it correctly saves a step:
- The deepest spans are missing. Queue pressure. The batch processor drops on insert when the queue is full, and the deepest spans are the last created in a request, so they arrive at a queue that is already saturated. Raise
max_queue_size, shortenschedule_delay_millis, or reduce span volume per request. - One service is missing everywhere. Configuration, not load. That service exports to a different endpoint, runs a different sampler, or never had instrumentation applied. Compare its environment against a working peer rather than tuning anything.
- Spans are missing only at high traffic. Capacity. Correlate the gaps with
otelcol_receiver_refused_spansand with the service’s own CPU; a memory limiter shedding load looks exactly like intermittent instrumentation bugs from the UI. - Only long-running requests are truncated. Export timeouts. A request that outlives the exporter’s timeout gets its later spans dropped when the connection is torn down, particularly with streaming or websocket handlers.
- Only the last request before a deploy is incomplete. Shutdown flush. The pod terminated before the processor drained; add a
preStophook with a few seconds of grace, or callshutdown()on SIGTERM.
A useful discipline: before changing any configuration, write down which of these five patterns you are seeing. If the observation does not match any of them, the cause is more likely fragmentation (context loss) than loss, and the next section applies instead.
Diagnosing a fragmented trace
Fragmentation is a boundary problem, so inspect the boundary. Log the inbound header on the callee before anything else touches it:
# Temporary middleware — prove what actually arrived
@app.middleware("http")
async def log_inbound_context(request, call_next):
tp = request.headers.get("traceparent")
logger.info("inbound traceparent=%s tracestate=%s",
tp or "<ABSENT>", request.headers.get("tracestate", "-"))
return await call_next(request)
Four outcomes, four causes:
Verification
You have found the cause, not a symptom, when you can state the stage and show the counter that proves it:
- “Spans die at stage 3 —
queue_sizesits atmax_queue_sizeand the SDK’s dropped counter climbs at 400/s.” - “Spans die at stage 5 —
receiver_refused_spanstracks memory limiter activations one-for-one.” - “Nothing died — the callee logs
<ABSENT>fortraceparent, so the gateway is stripping it.”
Then re-run the exact reproduction from Step 2 and confirm the trace is complete end to end.
Making the next occurrence a five-minute job
Most of the time spent on a missing-trace investigation goes into establishing facts that a small amount of standing instrumentation would have already answered. Three cheap additions pay for themselves the first time they are used.
Publish the pipeline counters on a dashboard nobody has to build during an incident. Accepted, refused, dropped, sent, and failed spans per collector, plus SDK queue depth where the SDK exposes it. The shape of those five lines identifies the failing stage before anyone opens a terminal.
Emit a synthetic trace on a schedule. A tiny job that issues one request per minute through the full request path with a known, forced-sampled context, then queries the backend for it thirty seconds later, converts “traces are missing” from a report into an alert. It also catches the silent failures — an expired TLS certificate on the collector, a NetworkPolicy change, a retention setting adjusted by someone else — that only ever surface when a human happens to look.
Log the inbound traceparent at the edge, permanently, at a low sample rate. One structured log line per sampled request recording whether the header was present, well-formed, and which trace ID it carried costs almost nothing and answers the single most common question in these investigations without a redeploy.
The common thread is that every check in this page is a fact you can capture continuously rather than reconstruct under pressure. A team that has these three in place typically spends the investigation confirming which stage lost the span, not arguing about whether spans are being lost at all.
Edge Cases and Gotchas
- Short-lived processes lose everything. A CLI, a Lambda, or a job that exits without calling
shutdown()discards whatever is queued. Register a shutdown hook and, for serverless, use a simple exporter or an explicit flush before returning. - Two SDKs in one process fight. A Java agent plus a manually configured SDK produces two providers, and only one wins
GlobalOpenTelemetry. Spans go to whichever registered first — often the one you did not configure. - Sampling looks like loss during an incident. Under load-shedding, some setups reduce sample rates automatically. Traces genuinely vanish, correctly, exactly when you want them most.
- The trace exists but is far too big to render. A retry loop that reuses one trace ID can produce a trace with 200,000 spans; the UI times out and reports nothing. Query the span count before assuming absence.
- Retention silently deletes yesterday’s evidence. A 24-hour retention means the incident from last night is already gone. Confirm the retention window before searching for old traces.
- Clock skew hides spans in plain sight. A node ten minutes fast writes spans stamped in the future; a search over “the last five minutes” misses them, though fetch-by-ID finds them immediately.
Performance and Scale Notes
Queue sizing is the most common real fix. max_queue_size defaults to 2048 spans. A service producing 60 spans per request at 300 RPS generates 18,000 spans/second, so the queue turns over every 114 ms — any exporter hiccup drops spans. Size it to at least peak_spans_per_second × schedule_delay_seconds × 1.5.
Debug logging is not free. OTEL_LOG_LEVEL=debug on a busy service can cost more CPU than the tracing itself. Enable it on one instance behind a load balancer, not fleet-wide.
The console exporter is a diagnostic tool, not a fallback. It serializes every span to stdout synchronously and will change the latency profile of the service you are debugging.
Collector metrics are your early-warning system. Alert on otelcol_receiver_refused_spans and otelcol_exporter_send_failed_spans being non-zero; both are silent by default, which is why missing traces are usually discovered by a human days later.
Troubleshooting FAQ
My service produces no traces at all — where do I start?
Console exporter, one request. Spans printing means the problem is transport or backend; nothing printing means instrumentation never ran, usually an initialization-order or sampler problem.
Why do I see the caller’s span but not the callee’s?
Either the header never arrived or the callee exports somewhere else. Log the inbound traceparent at the callee before anything else — that one line splits the problem in half. Detail in debugging traces that never reach the backend.
Traces appear but each service is its own separate trace. Why?
Extraction is failing: mismatched propagators, a malformed header, or context lost across an async boundary in the caller. See fixing broken parent-child links across services.
Some traces are complete and others are truncated. What causes that?
Queue pressure. The batch processor drops what arrives when the queue is full, so long traces lose their tails. Check the dropped-span counter first.
The trace exists in the backend but the UI shows nothing. Why?
Clock skew putting spans outside the searched window, or a missing service.name. Fetch by trace ID to separate a storage problem from a search problem.
Related
- Debugging traces that never reach the backend — the export-path checklist in full
- Fixing broken parent-child links across services — propagator mismatches and lost context at boundaries
- Debugging orphaned spans in async workflows — the in-process version of the same failure
- Handling collector backpressure and queue overflow — what to do when stage 5 is the bottleneck
- Querying traces with TraceQL and Jaeger search — proving a trace is absent rather than merely unfound
↑ Back to Trace Debugging & Signal Correlation