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

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}`);
}
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.

    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=true

    The 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.

  2. 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 ff

    The first line is the one to read twice. flags=00 sampled=false is a valid header. Validity and sampling are separate questions, and 01 answers only the second one.

  3. Step 3.

    Parse two headers whose flags are neither 00 nor 01.

    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=false

    Both are valid headers. 09 is sampled because the low bit is set, 02 is not because it is clear, and neither value equals 01.

  4. 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
  5. 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
  6. 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: true and parentSpan: null are 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

Sign: Every malformed header you send comes back 200, so the check looks like it passed.Cause: The recommendation tells a receiver that fails to parse traceparent to create a new one and delete tracestate. Rejecting the context is the specified behaviour, not an error, so nothing appears in the status code or the response body. Step 6 reads the receiver's own log because that is the only place the rejection is visible.
Sign: A parser built on 'split on dashes, require four fields' rejects a header it is supposed to accept.Cause: Four fields is the rule for version 00 only. A receiver that sees a higher version is told to read the trace id, the parent id and the sampled bit by position and ignore whatever follows. The same strict split that correctly rejects the truncated header in step 2 will also reject every future version of the header.
Sign: A request is sampled upstream and treated as unsampled downstream.Cause: trace-flags is a bit field, and the sampled bit is the least significant one. Step 4 shows 09 carrying that bit while a comparison against the string 01 reports it as unsampled. Any receiver that tests flags for equality instead of masking will disagree with its own caller as soon as a second flag is defined.

Thresholds

55 characters is the exact length of a version 00 traceparent, and it is also the minimum a receiver should parse Source: https://www.w3.org/TR/trace-context/#versioning-of-traceparent

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

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.

basic6 minpublished updated Maks Verny