Traceparent header format
Split the header on dashes and check four fields: version 00, a 32 character lowercase hex trace id, a 16 character lowercase hex parent id, two characters of flags. Running node tpparse.mjs '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' prints valid. An all zero trace id, an uppercase letter or a missing field makes the header invalid.
Why check this
A malformed traceparent produces no error anywhere. The receiver answers 200, drops the context it was given, starts a fresh trace and carries on. The first symptom reaches you days later as a trace view in which one service is the root of its own trace and its caller is nowhere in the picture.
Run this when a new service joins a request path, when a proxy is added in front of one, and on any release that changes the code writing the header. The failure it prevents: a gateway emits the trace id in uppercase, every receiver behind it treats the header as invalid, and the checkout service appears as a trace root. The incident goes to checkout, the one service that behaved correctly.
Prerequisites
- Node 22 and no packages. Output below came from
v22.23.2. - The W3C Trace Context recommendation of 23 November 2021. Section 3.2.2 holds the field definitions used here.
- A parser that reads the header field by field. Save it as
tpparse.mjs:
const hex = (s, n) => new RegExp(`^[0-9a-f]{${n}}$`).test(s ?? '');
const zero = (s) => /^0+$/.test(s ?? '');
function check(f) {
if (f.length !== 4) return `field count ${f.length}, expected 4`;
if (!hex(f[0], 2) || f[0] === 'ff') return `version "${f[0]}" is not two lowercase hex below ff`;
if (!hex(f[1], 32)) return `trace-id "${f[1]}" is not 32 lowercase hex`;
if (zero(f[1])) return 'trace-id is all zeroes';
if (!hex(f[2], 16)) return `parent-id "${f[2]}" is not 16 lowercase hex`;
if (zero(f[2])) return 'parent-id is all zeroes';
if (!hex(f[3], 2)) return `trace-flags "${f[3]}" is not two lowercase hex`;
return null;
}
for (const h of process.argv.slice(2)) {
const f = h.split('-');
const why = check(f);
console.log(h);
console.log(why === null
? ` valid version=${f[0]} trace-id=${f[1]} parent-id=${f[2]} flags=${f[3]} sampled=${Boolean(parseInt(f[3], 16) & 1)}`
: ` invalid ${why}`);
}
- A receiver, for step 5, so the check covers what a service does with a bad header and not only what a parser says. Three services on ports 19731 to 19733, A calling B calling C. Save as
chain.mjs, start withnode chain.mjs > chain.log 2>&1 &, and change the ports if they are busy.
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}`);
- Stop the chain afterwards. Find the process id with
netstat -ano | grep 19731and stop that id, not every node process on the machine.
Steps
- Step 1.
Parse a header you know is well formed and read the four fields off it.
node tpparse.mjs '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 valid version=00 trace-id=4bf92f3577b34da6a3ce929d0e0e4736 parent-id=00f067aa0ba902b7 flags=01 sampled=trueThe whole header is 55 characters: 2 for the version, 32 for the trace id, 16 for the parent id, 2 for the flags, 3 dashes.
- Step 2.
Run the set of headers a receiver has to reject, plus one valid header with sampling off.
node tpparse.mjs \ '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00' \ '00-00000000000000000000000000000000-00f067aa0ba902b7-01' \ '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01' \ '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01' \ '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7' \ '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra' \ 'ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00 valid version=00 trace-id=4bf92f3577b34da6a3ce929d0e0e4736 parent-id=00f067aa0ba902b7 flags=00 sampled=false 00-00000000000000000000000000000000-00f067aa0ba902b7-01 invalid trace-id is all zeroes 00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01 invalid parent-id is all zeroes 00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01 invalid trace-id "4BF92F3577B34DA6A3CE929D0E0E4736" is not 32 lowercase hex 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7 invalid field count 3, expected 4 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra invalid field count 5, expected 4 ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 invalid version "ff" is not two lowercase hex below ffThe first line is the one to read twice.
flags=00 sampled=falseis a valid header. Validity and sampling are separate questions, and01answers only the second one. - Step 3.
Parse two headers whose flags are neither
00nor01.node tpparse.mjs '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-09' '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-02'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-09 valid version=00 trace-id=4bf92f3577b34da6a3ce929d0e0e4736 parent-id=00f067aa0ba902b7 flags=09 sampled=true 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-02 valid version=00 trace-id=4bf92f3577b34da6a3ce929d0e0e4736 parent-id=00f067aa0ba902b7 flags=02 sampled=falseBoth are valid headers.
09is sampled because the low bit is set,02is not because it is clear, and neither value equals01. - Step 4.
Compare a string test against a bit mask on the same three flag values.
node -e "for (const f of ['01','09','02']) console.log(f, 'equals-01:', f === '01', 'masked:', Boolean(parseInt(f,16) & 1));"01 equals-01: true masked: true 09 equals-01: false masked: true 02 equals-01: false masked: false - Step 5.
Send an invalid header to the running chain and read the status code.
curl -s -o /dev/null -w 'status %{http_code}\n' -H 'traceparent: 00-00000000000000000000000000000000-00f067aa0ba902b7-01' http://127.0.0.1:19731/status 200 - Step 6.
Read what the receiver did with the header it rejected.
grep '"tp","svc":"A"' chain.log{"kind":"tp","svc":"A","in":"00-00000000000000000000000000000000-00f067aa0ba902b7-01","traceId":"8d0ea55f1900aa15161b6240956e882d","parentSpan":null,"span":"e38deebe6490ccf6","restarted":true,"out":"00-8d0ea55f1900aa15161b6240956e882d-e38deebe6490ccf6-01"}restarted: trueandparentSpan: nullare the whole finding. The trace id the caller sent is gone, a new one took its place, and the status code in step 5 said nothing about it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| valid version=00 ... flags=01 | Four well formed fields, sampling on | Nothing. Move on to whether the header survives the next hop. |
| flags=00 sampled=false | A valid header from a caller that is not recording | Leave it. Forcing 01 upstream changes a sampling decision, not a defect. |
| trace-id is all zeroes | The producer emitted a placeholder | Fix the producer. Every receiver discards this and restarts the trace. |
| is not 32 lowercase hex | Uppercase hex or a wrong length | Fix the producer. Case is part of the grammar, not a formatting preference. |
| field count 3 or field count 5 | A truncated or padded header | Check the proxy in front of the service before blaming the application. |
| restarted: true in the receiver log | The receiver threw away the caller's context | Look one hop upstream. The service that logged this is not the broken one. |
Common mistakes
Thresholds
The length is a parsing rule and not only a fact about the current version. A receiver that meets a version number higher than the one it knows is told not to parse a header shorter than 55 characters and to restart the trace instead. That makes 55 the number to assert on in a producer test: anything shorter cannot be read by any conforming receiver, whatever version it claims.
What to check next
- How to check trace propagation between services: a well formed header still fails if the next service forwards it unchanged.
- Traceparent and tracestate: the companion header, and what a receiver does to it when this one fails to parse.
- Trace id in logs: the header is only useful once the trace id reaches the log lines.
- How to check correlation id in logs: the application level id that survives when trace context does not.
FAQ
What does a traceparent header look like?
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. Version 00, then the trace id shared by every span in the trace, then the parent id of the calling span, then the flags. The example values are the ones the W3C recommendation uses.
Which headers does W3C Trace Context define?
Two: traceparent and tracestate. The first carries identity and flags in a fixed format, the second vendor specific data as a comma separated list. A receiver that discards traceparent discards tracestate with it.
Is there a traceparent generator?
randomBytes(16).toString('hex') and randomBytes(8).toString('hex') produce the two identifiers, which is what chain.mjs does. Avoid generators that pad short values with zeroes, since an all zero identifier is invalid.
Does flags 01 mean the header is valid?
No. 01 means the caller recorded the request. Step 2 shows flags=00 on a header valid in every field. Validity comes from the version, the trace id and the parent id, and the flags are read afterwards.
Why does the case of the trace id matter?
The grammar admits 0-9 and a-f only, so an uppercase trace id is invalid and step 2 shows a parser saying so. Receivers written against the grammar discard the header and report nothing.
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.
Related on this site
basic6 minpublished updated Maks Verny