Fixing Broken Parent-Child Links Across Services
Log the inbound traceparent at the callee before anything else: absent means the caller or an intermediary did not send it, malformed means the caller built it wrongly, and a valid-but-unexpected trace ID means the caller lost context internally before making the call.
Context and when it matters
A fragmented trace is different from a missing one. Every service is producing spans, exports are healthy, and the Collector counters are clean — but instead of one trace spanning five services you have five traces of one service each. Nothing is broken in the export pipeline; the context is simply not travelling.
This is the failure that wastes the most time, because both teams’ evidence says their side works. The caller’s trace is complete up to the point it made the call; the callee’s trace is complete from the moment it received one. Neither can see the boundary, and the boundary is where the answer is.
One log line at the callee resolves it, and the rest of this page is what to do with each of the three answers it can give.
The three answers
# Temporary middleware at the callee. Remove it once the cause is found —
# or keep a sampled version permanently, which is cheaper than re-adding it.
@app.middleware("http")
async def log_inbound_context(request, call_next):
tp = request.headers.get("traceparent")
logger.info("inbound traceparent=%s b3=%s from=%s path=%s",
tp or "<ABSENT>",
request.headers.get("b3") or request.headers.get("x-b3-traceid") or "-",
request.headers.get("user-agent", "-")[:40],
request.url.path)
return await call_next(request)
Logging the B3 headers alongside is deliberate: seeing <ABSENT> for traceparent and a populated x-b3-traceid immediately identifies a propagator mismatch, which is otherwise the hardest of the three to spot.
Cause 1: absent header
Two sub-causes, distinguished by whether the caller emitted it at all.
Propagator mismatch. The caller injects B3, the callee reads W3C, or vice versa. Common when a Spring Cloud Sleuth service talks to an OpenTelemetry one, or when an older Istio installation is in the path. The fix is a composite propagator on both sides, covered in the B3 to W3C migration guide:
export OTEL_PROPAGATORS=tracecontext,baggage,b3multi,b3
Stripped in transit. The caller sent it; something between removed it. Reverse proxies with header allow-lists, API gateways that rebuild requests, CDNs, and service meshes all do this by default in some configurations. Verify by capturing at both ends:
# At the caller's egress
kubectl exec -it deploy/checkout-api -- \
sh -c 'tcpdump -A -s0 -i any port 8080 2>/dev/null | grep -i traceparent | head -3'
# At the callee's ingress — if it appears above and not here, an intermediary ate it.
kubectl exec -it deploy/inventory-api -- \
sh -c 'tcpdump -A -s0 -i any port 8080 2>/dev/null | grep -i traceparent | head -3'
Cause 2: malformed header
The parser rejected it, so the callee behaves exactly as if it were absent. The validation rules and the six shapes this takes are covered in debugging invalid traceparent headers; the summary is that hand-built headers are the cause in nearly every case, and replacing the hand-rolled injection with the SDK’s propagator fixes the entire class.
Cause 3: valid header, unexpected trace ID
This one is the most interesting, because the network is working perfectly. The caller sent a well-formed header — for a different trace than the one it was serving. That means the context was lost inside the caller before the outbound request was made, and the outbound call started a new trace of its own.
The fixes are per-language and covered in depth elsewhere: propagating context across thread pools in Java, and handling async boundaries in Node.js and Python for the runtime equivalents.
Verification
# Send a known trace id through the whole chain and confirm it survives.
TID=$(openssl rand -hex 16)
curl -H "traceparent: 00-${TID}-$(openssl rand -hex 8)-01" \
https://api.example.com/checkout/123
sleep 10
curl -s "http://tempo:3200/api/traces/${TID}" \
| jq -r '[.batches[].resource.attributes[]
| select(.key=="service.name") | .value.stringValue] | unique'
# Expect every service in the path. A short list names the hop that broke.
That list is the whole verification: it tells you not just whether the fix worked but exactly how far the context now travels.
Preventing fragmentation from recurring
Every fix on this page addresses one hop. Keeping the whole fleet connected needs three standing controls, none of which is expensive.
A fleet-wide propagator standard, enforced in configuration rather than documentation. Set OTEL_PROPAGATORS in the base image or the deployment template so a new service inherits it, rather than relying on each team to configure it correctly. The composite list costs nothing on services that only ever see one format and saves the investigation entirely on the ones that see two.
A boundary attribute, recorded permanently at low volume. The propagation.format attribute described in the B3 migration guide doubles as a fragmentation detector: a service suddenly reporting none where it previously reported w3c is a break, visible as a metric rather than as a complaint.
A synthetic end-to-end check. One request per minute through the full path with a known trace ID, followed by a query for that ID, verifies that context still travels every hop. It catches the class of change that nobody thinks of as a tracing change — a new gateway rule, a mesh upgrade, a proxy replacing another — which is precisely the class that causes fragmentation.
The reason these are worth standing up is that fragmentation is introduced by infrastructure changes far more often than by application changes, and infrastructure changes are made by people who have no reason to think about trace context. A check that fails within a minute puts the feedback where it belongs, while the change is still fresh.
Message queues break differently
Everything above assumes an HTTP hop. Across a broker the same three causes apply with one addition: the context has to be written into the message and read back out, and either side can be missing without the other noticing.
A producer that publishes without injecting produces messages that consumers cannot connect to anything, and the consumer looks broken. A consumer that never extracts ignores a context the producer did include, and the producer looks broken. The diagnostic is the same in spirit — inspect the message headers at the consumer before extraction — but the tooling is different, and message headers are easier to inspect than HTTP ones because they are usually visible in the broker’s own tooling. See propagating trace context through Kafka consumers for the injection and extraction details.
Common pitfalls
- Fixing the callee. Making the receiver more permissive — accepting uppercase hex, accepting B3 when the fleet standard is W3C — masks the problem locally and leaves the sender broken for everyone else.
- Assuming the mesh forwards headers. Istio, Envoy, and most gateways have their own propagation configuration, and it is not implied by the application’s.
- Blaming the network for cause 3. A valid header with an unexpected trace ID never involves the network at all.
- Removing the boundary log after the fix. Keeping a sampled version costs almost nothing and makes the next occurrence a one-minute diagnosis.
- Testing with a synthetic request that skips the proxy. If the break is in an intermediary, a direct call to the callee will not reproduce it.
Related
- Diagnosing missing and broken traces — where fragmentation sits among the other failure shapes
- B3 vs W3C traceparent migration guide — the propagator mismatch, in full
- Debugging orphaned spans in async workflows — the in-process version of cause 3
↑ Back to Diagnosing Missing and Broken Traces