How to check trace propagation between services

Send one request through the chain with a known traceparent, then compare the log lines each service wrote. The trace id must be identical at every hop and the parent id must change at every hop. A service that forwards the header unchanged leaves two spans claiming the same parent.

Why check this

Propagation is the half of trace context that goes untested, because the header looks right on the wire at every hop. Two services can pass a valid traceparent between them and still produce a trace no backend can draw, and the symptom is a missing service rather than a bad header.

Run this after a change to the HTTP client a service uses, after a framework or middleware upgrade, and whenever a new service is inserted into an existing path. The failure it prevents: the order service forwards the header it received instead of minting its own span id, so the payment service it calls is drawn as its sibling rather than its child. An engineer reading that trace concludes order never called payments.

The chain below runs on one machine, so none of this measures latency or network behaviour. It measures what each service does to the header, which is the part that breaks.

Prerequisites

import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';

const PORT = { A: 19731, B: 19732, C: 19733 };
const TP = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;

function parseTraceparent(h) {
  const m = typeof h === 'string' && TP.exec(h);
  if (!m) return null;
  if (/^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
  return { traceId: m[1], parentId: m[2], flags: m[3] };
}

function mutateTracestate(raw, key, value) {
  const kept = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean)
    .filter((e) => e.split('=')[0] !== key);
  const list = [`${key}=${value}`, ...kept];
  return { header: list.slice(0, 32).join(','), dropped: list.slice(32) };
}

function service(name, port, next) {
  const key = 'svc' + name.toLowerCase();
  createServer(async (req, res) => {
    const tpIn = req.headers.traceparent ?? null;
    const tsIn = req.headers.tracestate ?? null;
    const ctx = parseTraceparent(tpIn) ?? { traceId: randomBytes(16).toString('hex'), parentId: null, flags: '01', restarted: true };
    const span = randomBytes(8).toString('hex');
    const ts = mutateTracestate(ctx.restarted ? null : tsIn, key, span);
    let tpOut = null;
    const body = { svc: name };
    if (next) {
      tpOut = process.env.FORWARD_UNCHANGED === '1' && name === 'B' ? tpIn : `00-${ctx.traceId}-${span}-${ctx.flags}`;
      const r = await fetch(`http://127.0.0.1:${next}/`, { headers: { traceparent: tpOut, tracestate: ts.header } });
      body.downstream = await r.json();
    }
    console.log(JSON.stringify({ kind: 'tp', svc: name, in: tpIn, traceId: ctx.traceId, parentSpan: ctx.parentId, span, restarted: Boolean(ctx.restarted), out: tpOut }));
    console.log(JSON.stringify({ kind: 'ts', svc: name, in: tsIn, out: ts.header, dropped: ts.dropped }));
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify(body));
  }).listen(port, '127.0.0.1');
}

service('C', PORT.C, null);
service('B', PORT.B, PORT.C);
service('A', PORT.A, PORT.B);
console.log(`A ${PORT.A} -> B ${PORT.B} -> C ${PORT.C}`);

Steps

  1. Step 1.

    Send one request into the front of the chain with a trace id you will recognise.

    curl -s -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' http://127.0.0.1:19731/
    
    {"svc":"A","downstream":{"svc":"B","downstream":{"svc":"C"}}}

    All three services answered, so the request reached the end of the chain. That is the part a status code can tell you, and it is the last thing it can tell you.

  2. Step 2.

    Read the traceparent line each service logged.

    grep '"tp"' chain.log
    
    {"kind":"tp","svc":"C","in":"00-4bf92f3577b34da6a3ce929d0e0e4736-23da5af9de835a5e-01","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","parentSpan":"23da5af9de835a5e","span":"5daecad284d465a0","restarted":false,"out":null}
    {"kind":"tp","svc":"B","in":"00-4bf92f3577b34da6a3ce929d0e0e4736-15b335cfa1b1e607-01","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","parentSpan":"15b335cfa1b1e607","span":"23da5af9de835a5e","restarted":false,"out":"00-4bf92f3577b34da6a3ce929d0e0e4736-23da5af9de835a5e-01"}
    {"kind":"tp","svc":"A","in":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","parentSpan":"00f067aa0ba902b7","span":"15b335cfa1b1e607","restarted":false,"out":"00-4bf92f3577b34da6a3ce929d0e0e4736-15b335cfa1b1e607-01"}

    C logs last because it answers first: the chain unwinds from the far end.

  3. Step 3.

    Reduce the three lines to the identifiers that decide the verdict.

    grep '"tp"' chain.log | sed -E 's/.*"svc":"([A-C])".*"traceId":"([0-9a-f]+)","parentSpan":("[0-9a-f]+"|null),"span":"([0-9a-f]+)".*/\1 trace=\2 parent=\3 span=\4/'
    
    C trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="23da5af9de835a5e" span=5daecad284d465a0
    B trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="15b335cfa1b1e607" span=23da5af9de835a5e
    A trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="00f067aa0ba902b7" span=15b335cfa1b1e607

    Read it bottom up. A's span is 15b335..., which is B's parent. B's span is 23da5a..., which is C's parent. One trace id, three distinct spans, each one pointing at the one above it.

  4. Step 4.

    Stop the chain, start it again with FORWARD_UNCHANGED=1 node chain.mjs > chain.log 2>&1 &, and send the same request.

    curl -s -o /dev/null -w 'status %{http_code}\n' -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' http://127.0.0.1:19731/
    
    status 200
  5. Step 5.

    Render the parent chain again and compare it with step 3.

    grep '"tp"' chain.log | sed -E 's/.*"svc":"([A-C])".*"traceId":"([0-9a-f]+)","parentSpan":("[0-9a-f]+"|null),"span":"([0-9a-f]+)".*/\1 trace=\2 parent=\3 span=\4/'
    
    C trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="93409645b3cc6c7e" span=330f6874572587c0
    B trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="93409645b3cc6c7e" span=0da8e214509a067e
    A trace=4bf92f3577b34da6a3ce929d0e0e4736 parent="00f067aa0ba902b7" span=93409645b3cc6c7e

    The trace id is still the same at all three hops. B and C now name the same parent, 93409645b3cc6c7e, which is A's span. Nothing in the log points at B's span at all, so a backend draws B and C as siblings under A and the call from B to C vanishes.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | One trace id, a different span at each hop, each parent matching the span above | Propagation is correct | Nothing. This is the shape in step 3. | | One trace id, two hops naming the same parent | A service forwards the header unchanged | Fix the client in the service whose span nobody points at. It is the one that did not mint a span. | | A different trace id after one hop | The receiver rejected or ignored the header | Read the header it received. restarted: true says it rejected it, and the fix is upstream. | | parentSpan: null on a service that was called | The header never arrived | Look at what sits between the two services. A proxy that strips headers produces this. | | Trace ids match but the backend still shows broken traces | Span ids collide or are reused | Check that each outgoing request gets a fresh parent id, not one per process or per connection. |

Common mistakes

Sign: The trace id is identical at every hop, so propagation is signed off as working.Cause: Trace id equality is the assertion most teams write, and step 5 passes it while the tree is wrong. Comparing trace ids proves only that the header was not dropped. The parent id is the field that carries the call structure, and it is the one a forwarding bug leaves untouched.
Sign: A middle service is missing from the trace even though its own logs show it handled the request.Cause: When it sends the parent id it received instead of its own span id, no downstream span points at it. Backends build the tree from parent links, so a span nobody names becomes a leaf next to its own child rather than above it.
Sign: Adding a proxy in front of a service breaks the trace, and the proxy's access log looks clean.Cause: A proxy that normalises or allow lists request headers can remove traceparent without logging anything, and the service behind it then starts a new trace and answers 200. Step 2 separates the two cases: a stripped header logs no incoming value, a rejected one logs the value and restarts.

What to check next

FAQ

Does OpenTelemetry handle propagation automatically?

Its instrumentation does it for the HTTP clients it patches. A hand written fetch call, a queue publish, or a client the agent does not recognise still sends nothing. Auto instrumentation covers most calls rather than all of them, which is why this check exists.

What must change between hops and what must not?

The trace id must not change. The parent id must, on every outgoing request, to the id of the span making it. The flags may change if the service takes its own sampling decision.

Is matching trace ids enough to prove propagation?

No, and step 5 is the counterexample. Both runs carry one trace id across three services. Only the parent ids tell them apart, so the assertion has to cover the span structure.

What should a service do when no traceparent arrives?

Create a trace id and a span id of its own and become the root. Correct for an entry point, a defect anywhere else, which is why parentSpan: null on an internal service points at whatever sits in front of it.

Can I check this without a tracing backend?

Yes. Everything above reads log lines. A backend renders the tree more comfortably, but the parent links it draws from are already in the logs, and reading them directly removes the backend's own sampling from the picture.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny