Skip to content
Back to Blog
Tutorials

traceparent Header Explained: W3C Trace Context Guide

The traceparent header field by field: what each hex segment means, what makes one invalid, and why traces break between services. Free online decoder.

13 min read

traceparent Header Explained: W3C Trace Context Guide

The traceparent header is the W3C standard for distributed tracing headers: one line of ASCII that carries a request’s identity across every service it touches. For the current version it is exactly 55 characters, and it has four dash-separated fields:

00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│  │                                │                │
│  │                                │                └─ trace-flags (2 hex, 1 byte)
│  │                                └─ parent-id     (16 hex, 8 bytes)
│  └─ trace-id                                       (32 hex, 16 bytes)
└─ version                                           (2 hex, 1 byte)

Two of those fields behave differently as the request moves. The trace-id stays identical on every hop; it is the request’s name, from the edge proxy down to the last database call. The parent-id changes at every hop, because it names the span that called you rather than the request. Confusing the two accounts for a good share of “my traces look wrong” tickets.

The field table is the easy half. It cannot tell you what makes a header invalid, or what a conforming receiver does with a broken one, or where the header quietly disappears between two services that both claim to support tracing. If you have a real header in front of you, paste it into the free traceparent decoder and read along. It splits the fields and expands the flags byte bit by bit; for a broken header it names the rule that was tripped.

The traceparent header at a glance

The traceparent header is a single HTTP header that carries a distributed trace between services. It holds four dash-separated hexadecimal fields (version, trace-id, parent-id and trace-flags), and for the current version it is exactly 55 characters. The trace-id names the whole request; the parent-id names the span that called you.

FieldHex digitsBytesWhat it identifiesChanges per hop?
version21Which format the rest follows. Always 00 todayNo
trace-id3216The whole request, end to endNo
parent-id168The calling span (the span ID of your caller)Yes
trace-flags21An 8-bit field; bit 0 is sampledRarely

Add three dashes to those 52 hex digits and you get 55 characters. That number is worth memorising, because a version-00 header of any other length is invalid, and length is the fastest thing to check by eye.

Everything in the header is lowercase hexadecimal, and lowercase is meant literally rather than as shorthand for case-insensitive. The grammar in the W3C Trace Context recommendation admits 0-9 and a-f and nothing else, which is why an uppercase trace ID with a perfectly correct value still gets thrown away downstream.

Field by field

Each field has its own width, its own invalid values and its own way of going wrong.

version: why it is not always “just 00”

Today the version byte is 00, and it will be 00 for a while. But ff is explicitly forbidden: the specification reserves it as an invalid value, so a header that opens with ff is dead on arrival regardless of what follows.

The rule that catches people out is the one about versions you have never seen. A parser that does if (version !== '00') reject() is wrong, and wrong in an expensive way. The spec asks receivers to attempt the parse when the version is higher and the header is at least as long as the known format: read the fields you recognise and tolerate extra trailing data. Rejecting instead means your service becomes the boundary where the trace stops and a new one begins, the moment anyone upstream upgrades.

// Wrong: makes your service the place traces go to die
if (version !== '00') throw new Error('bad traceparent');

// Right: parse the prefix you understand
if (version !== '00' && header.length >= 55) {
  // read version, trace-id, parent-id, trace-flags; ignore the rest
}

trace-id: 16 bytes, the identity of the whole request

Thirty-two lowercase hex digits, constant for the life of the trace. Whatever service generated it at the start, every hop copies it forward unchanged. When you search your observability backend for a trace, this is the string you paste.

Two rules govern its value: 32 hex digits, and never all zeros. The specification names 00000000000000000000000000000000 as an invalid value and requires receivers to ignore the entire header, so it does not mean “a trace with no data yet”. In practice an all-zero trace ID means an SDK that never initialised, or a middleware inserting a placeholder because it had no real context to forward.

A trace-id is 128 bits, the same width as a UUID, and it is not a UUID. There are no version bits, no variant bits, no dashes, and no structure of any kind: sixteen opaque bytes. You cannot parse a v4 out of it, and a UUID with its dashes stripped is not automatically a valid trace-id either, because the version and variant nibbles make its randomness non-uniform. If you want to see what a UUID reserves inside those 128 bits, what a UUID actually encodes walks through the layout, and the UUID generator shows the version and variant bits in place.

parent-id: 8 bytes, the span that called you

Sixteen hex digits, rewritten at every hop. The name causes more confusion than the field deserves: the W3C spec calls it parent-id, OpenTelemetry calls the same 8 bytes a span ID, and they are the same thing seen from two directions. From your service’s point of view it is the parent; from the caller’s point of view it is the ID of the span it just created for the outbound request.

So when service A calls service B, A puts its own span ID in the parent-id slot. B then creates a child span, and when B calls C it puts B’s span ID there instead. The trace-id is untouched throughout. That is the entire propagation algorithm.

All-zero parent-ids are invalid too, for the same reason as trace-ids: 0000000000000000 means the caller did not supply a real span, and the header should be discarded rather than half-honoured.

trace-flags: it looks like a boolean, but it is eight bits

Almost every header you will ever see ends in 01, so it is natural to read the field as a yes/no. It is a byte, and the bits are assigned:

  • bit 0, mask 0x01: sampled
  • bit 1, mask 0x02: random-trace-id, added in Trace Context Level 2
  • bits 2–7: reserved; ignore them on receipt and clear them on outbound requests

The combinations decode like this:

HexBinarysampledrandom-trace-idDoes flags === 0x01 hold?
0000000000falsefalsefalse
0100000001truefalsetrue
0200000010falsetruefalse
0300000011truetruefalse ← the bug

Read the last row again. A trace with flags 03 is sampled. Any code that compares the whole byte against 01 reports it as unsampled, silently, and only for the subset of traffic where the Level 2 flag happens to be set. That is a nasty failure shape, because it looks like a sampling-rate problem rather than a parsing bug.

const flags = parseInt(traceFlags, 16);

// Wrong: treats a bit field as an enumeration
const sampled = traceFlags === '01';

// Right
const sampled       = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;

What does random-trace-id promise? That at least the right-most 7 bytes of the trace-id were generated with uniform randomness. That sounds academic until you consider consistent sampling: if a downstream system wants to keep 1% of traces and needs every service to independently agree on which 1%, it can take those bytes modulo something instead of hashing the ID first. The flag is the upstream’s promise that this is safe.

What makes a traceparent invalid

A conforming receiver rejects a version-00 header for any of these reasons:

SymptomRuleResult
00-4BF92F35...-01Grammar admits lowercase hex onlyInvalid (the value is right, the header is still rejected)
ff-...Version ff is forbidden by the specInvalid
trace-id is 00000000000000000000000000000000All-zero trace-id is a named invalid valueInvalid
parent-id is 0000000000000000All-zero parent-id is a named invalid valueInvalid
trace-id is not 32 hex digitsFixed widthInvalid
parent-id is not 16 hex digitsFixed widthInvalid
trace-flags is not 2 hex digitsFixed widthInvalid
Header is not exactly 55 characters, version 00Trailing data is legal only under a future versionInvalid
Any character outside 0-9a-f and the dashesNot hexadecimalInvalid

What that receiver does next is the part people miss:

A conforming receiver does not repair an invalid traceparent header and does not pass it along. It discards the header and starts a brand-new trace with a freshly generated trace-id.

So what lands on your screen is two disconnected short traces: one that ends abruptly at the service that emitted the bad header, and one that appears to begin, out of nowhere, at the service that received it. Nothing is marked as an error anywhere, and both traces look healthy in isolation. People spend afternoons looking for the missing link between them when the answer is that a middleware uppercased a hex string, or a hand-built header came out 54 characters long.

Length and case are the two failure modes you cannot see by staring. Paste the header into the decoder and it names the exact rule that was tripped, rather than making you count digits.

tracestate: the companion header everyone gets wrong

traceparent carries the standard identity. The tracestate header carries whatever each vendor wants to add alongside it, as comma-separated key=value members:

tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

An implementation that does not recognise a key must forward it untouched. That is the whole design goal: vendors can piggyback proprietary state on a standard trace without every hop needing to understand it.

The grammar has teeth, though, and three of its rules explain real production symptoms.

The ceiling of 32 list-members sits in the grammar itself: list = list-member 0*31( OWS "," OWS list-member ). A tracestate with 33 members is an invalid header rather than a slightly long one, and receivers are entitled to discard the whole thing. This is the answer to a symptom that otherwise looks like magic: vendor data that is present at the edge, present two hops in, and completely gone by hop five. Every hop was appending its own member, the list crossed 32, and from then on the entire header was dropped rather than trimmed.

Values run from 1 to 256 characters and can never be empty. The value production ends with a mandatory non-blank character, so vendor= is a syntax error rather than a key with an empty value. Printable ASCII only, and never a comma or an equals sign inside the value.

The key grammar changed between Level 1 and Level 2. Level 1 defined keys through a tenant@vendor production, where @ was a structural separator. Level 2 replaced that with a flat character class: a key starts with a lowercase letter or a digit and continues with a-z, 0-9, _, -, *, / and @. Under Level 2, @ is an ordinary character, keys may begin with a digit, and a@b@c is a perfectly legal key that the Level 1 production would reject. If you have a proxy validating against Level 1 and a service emitting Level 2 keys, one side accepts what the other rejects, and the header goes missing at exactly one hop.

Two more rules worth knowing. Duplicate keys are invalid outright. And when you modify the traceparent’s parent-id, you must move your own tracestate entry to the front of the list, which is ordered most-recent-first. Skipping the move-to-front step leaves stale vendor state sitting where a reader will treat it as current.

One rule works in your favour: empty list-members are legal. When a middlebox removes an entry it often leaves the comma behind, producing rojo=1,,congo=2. The spec explicitly allows this, so a parser should drop the empty member and continue rather than declare the header malformed. The tracestate view in the decoder lists every member with per-member validation and a running count against the 32-member limit, which is usually faster than counting commas.

How distributed tracing headers travel: one request, four hops

Follow one request through an edge proxy, an API service and two downstream services:

Client
  │  (no traceparent — the edge is the root)

Edge proxy      generates trace-id 4bf9…4736, span 00f0…02b7
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

API service     reads it, creates span a1b2c3d4e5f60718
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01

Orders service  reads it, creates span 9f8e7d6c5b4a3928
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01

Inventory service

Every hop does the same three things: read the inbound header, replace the parent-id with its own span ID for each outbound call, and forward the trace-id and flags unchanged. When there is no inbound header at all, as with the client above, the receiving service is the root: it generates a trace-id and makes the sampling decision for everything downstream.

You can inject a header by hand to test a chain end to end:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  -H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
  https://example.com/api

Replay a header captured from production against staging and you can watch the same trace-id show up in your backend. The curl command builder assembles the flags for you if you are adding auth or a body, and the curl cheat sheet covers the header and verbose options you will want while debugging.

To see what a service actually received rather than what you think you sent, run a throwaway echo server and point one hop at it:

python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer

class Echo(BaseHTTPRequestHandler):
    def do_GET(self):
        for name, value in self.headers.items():
            print(f"{name}: {value}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok\n")

HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY

Then curl -H 'traceparent: …' http://127.0.0.1:8080/ and read what came out the other side. Half the “the proxy is eating my header” investigations end here.

trace-flags sampled: an upstream decision, not a receipt

A trace-flags sampled bit of 1 means the upstream service decided to record this trace. It does not promise the data reached your backend.

Head-based sampling makes that decision at the root, before anything has happened, and propagates it down. That is cheap and consistent across services, but it is also blind: it cannot know that the request was about to fail. Tail-based sampling buffers spans until the trace completes and then decides, so it can keep every trace that contains an error, at the cost of holding spans in memory and needing every service’s spans to land in the same collector.

Under tail-based sampling a trace can arrive flagged 01 at every hop and still be dropped at the end. Rate limits and export quotas can drop it too. So 01 at the edge and no trace in the UI is not necessarily a propagation bug; check the collector’s own drop metrics before you go looking at headers.

The reverse case matters more day to day. If the inbound flags are 00, the caller ran its sampler and chose not to record. Nothing in your service is misconfigured, and auditing your own sampler is wasted time; the question is which upstream service is deciding against sampling.

Converting between propagation formats

W3C Trace Context won, but plenty of systems still speak something older, and gateways translate between them. The same traceparent example looks like this in four formats:

FormatHeader(s)Value for our example
W3Ctraceparent00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
B3 singleb34bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1
B3 multiX-B3-TraceId, X-B3-SpanId, X-B3-Sampled4bf92f3577b34da6a3ce929d0e0e4736, 00f067aa0ba902b7, 1
Datadogx-datadog-trace-id, x-datadog-parent-id, _dd.p.tid tag11803532876627986230, 67667974448284343, 4bf92f3577b34da6
AWS X-RayX-Amzn-Trace-IdRoot=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

Datadog: the high/low 64-bit split

Datadog’s identifiers predate 128-bit trace IDs, and the compatibility shim is where most conversions go wrong. x-datadog-trace-id carries the lower 64 bits as a decimal string. The higher 64 bits travel separately, as hexadecimal, in the _dd.p.tid tag, which itself rides in the x-datadog-tags header.

const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');

const datadogTraceId  = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex     = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);

console.log('x-datadog-trace-id:',  datadogTraceId);  // 11803532876627986230
console.log('x-datadog-tags:',      higher64Hex);     // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343

The classic mistake is converting all 128 bits into one decimal number:

BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 — matches nothing in the UI

The arithmetic is right; the quantity is wrong. That is why the mistake survives review and then quietly matches zero traces.

The second trap is numeric precision. A 64-bit identifier exceeds Number.MAX_SAFE_INTEGER, which is 9007199254740991, so any code path that lets a trace ID become a JavaScript number corrupts its low digits. Keep trace IDs as strings and reach for BigInt only when you have to do arithmetic; an ID that arrives unquoted in JSON is already damaged by the time you see it.

AWS X-Ray: the timestamp that isn’t there

An X-Ray trace ID looks like 1-{8 hex}-{24 hex}, and the leading 8 hex digits are the creation time in epoch seconds. Converting from W3C is mechanical:

const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex     = traceId.slice(0, 8);              // 4bf92f35
const epochSeconds = parseInt(epochHex, 16);           // 1274621749
const xrayId       = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

new Date(epochSeconds * 1000).toISOString();           // 2010-05-23T13:35:49.000Z

Look at that date. The specification’s example header decodes to May 2010, which is obvious nonsense, and that is the point: a W3C trace-id contains no timestamp. Sixteen random bytes will happily produce a plausible-looking epoch when you read the first four of them as one, and the number means nothing unless the identifier genuinely originated in X-Ray. Decoding a time out of an arbitrary trace-id is reading a random number and believing it.

When the ID really did come from X-Ray, the conversion is useful: drop those eight hex digits into the Unix timestamp converter to get a readable date, and the epoch guide covers the seconds-versus-milliseconds and timezone traps that follow.

B3: the Zipkin lineage

B3 came from Zipkin and is the format you meet in older service meshes. The single-header form is traceId-spanId-sampled, where the sampled field is 1 or 0 rather than a hex byte, so the Level 2 random-trace-id bit has nowhere to go and is lost in translation. The multi-header form splits the same values across X-B3-TraceId, X-B3-SpanId and X-B3-Sampled.

The historical wrinkle is width. B3 trace IDs may be 64-bit, meaning 16 hex digits instead of 32. Converting a 64-bit B3 ID to W3C means left-padding with zeros to reach 32 digits, and converting back means deciding whether to truncate. Left-padding is safe; truncating is not, because two traces that differ only in their high bytes collapse into one.

Where traceparent gets lost in production

Everything above assumes the header arrives. Often it does not. These are the four places it goes missing.

The browser drops it on cross-origin calls

Symptom: frontend traces exist, backend traces exist, and nothing links them. Or the cross-origin request fails outright with a CORS error.

Cause: traceparent is a custom header, so adding it makes the request non-simple and triggers a preflight OPTIONS. If the server’s preflight response does not list the header in Access-Control-Allow-Headers, the browser blocks the real request. Separately, OpenTelemetry’s browser instrumentation refuses to inject trace headers into cross-origin requests unless you tell it which origins are allowed.

Fix: on the server, return Access-Control-Allow-Headers: traceparent, tracestate for the preflight. In the browser SDK, set propagateTraceHeaderCorsUrls to a pattern matching your API origins. Both are needed; either one alone leaves you with the same symptom. A preflight that comes back with an unexpected status is worth checking against the HTTP status codes cheat sheet before assuming the header is the problem.

Proxies, WAFs and load balancers strip unknown headers

Symptom: the header is present when you curl the service directly and absent when the same request goes through the gateway.

Cause: allowlist-based forwarding. Plenty of proxy configurations, WAF rulesets and managed load balancers forward only headers they recognise, and traceparent is not on the default list. Some meshes also rewrite the header, generating their own trace-id and discarding yours.

Fix: bisect with the echo server from earlier: put it behind each hop in turn and see which layer drops the header. Then explicitly allow traceparent and tracestate in that layer’s forwarding rules. If the proxy is nginx, note that the block handling a route decides which headers it passes, and which block handles a route is not always the one you expect; the nginx location priority rules explain why a header config can appear to be ignored entirely.

Message queues have no HTTP headers

Symptom: the trace ends the moment a request becomes a background job.

Cause: there is no HTTP request across that boundary, so there is nothing to propagate the header on. Kafka has record headers, SQS has message attributes, and neither is populated for you by HTTP instrumentation.

Fix: inject the context into the message on the producer side and extract it on the consumer side. Every OpenTelemetry SDK exposes inject and extract for exactly this, and the wire format is the same W3C string; only the carrier changes from an HTTP header map to message metadata. The OpenTelemetry propagators documentation covers the carrier interface per language.

Case, and what HTTP/2 actually lowercases

Symptom: confusion in code review about whether Traceparent is acceptable.

Cause: two separate rules get merged into one. HTTP/1.1 header names are case-insensitive, and HTTP/2 requires them to be encoded lowercase on the wire. That is about the name. Independently, the hexadecimal in the header value must be lowercase, because the W3C grammar says so, and no protocol version will fix that for you.

Fix: send the name as traceparent and never uppercase the value. A gateway that normalises header names will not normalise your hex digits, and an uppercase trace-id sails through every transport layer before being rejected by the application that finally parses it.

Should you trust an inbound traceparent?

A traceparent that arrives from the public internet is user-controlled input: a string an anonymous client chose, which most services accept without a second thought.

Three concrete risks follow. First, trace splicing: an attacker who sends a trace-id observed elsewhere gets their request stitched into an existing trace, which pollutes the graph and can expose internal timing to whoever can read that trace. Second, quota burning: hard-coding 01 forces sampling on every request, and a modest flood turns into a very large ingestion bill or, worse, evicts the traces you actually needed. Third, cross-tenant correlation: reusing one trace-id across requests from different tenants links records that your tooling then treats as one logical operation.

The pragmatic stance is to accept at the edge but not to trust. Validate the grammar and reject malformed headers rather than passing them inward. For unauthenticated traffic, re-run your own sampling decision instead of honouring the inbound flag, so no external client can pin your sampler to “always record”. For authenticated traffic, honouring the caller’s decision is usually fine, because you know who they are.

And treat the trace-id as public. It is not a secret and never was: it shows up in logs, in error pages, in response headers, and in screenshots pasted into support tickets. Never encode a user ID, a tenant name or anything else meaningful into one, and never use it as an authorisation key. It is a correlation identifier, and that is all it should ever be.

FAQ

What is the difference between traceparent and tracestate?

traceparent carries the standardised identity (trace-id, parent-id and the sampling flags), and every implementation must understand it. tracestate carries vendor-specific state that unfamiliar implementations forward untouched. The two are linked: when the traceparent is invalid, the spec requires the tracestate to be ignored as well.

Why does my trace start over halfway through the call chain?

A trace starts over mid-chain almost always because one hop received a header that failed the grammar, discarded it and generated a fresh trace-id. Uppercase hex, an all-zero trace-id and a header that is not exactly 55 characters all cause this. If the header is well-formed, the next suspects are a proxy stripping it and a cross-origin preflight failing.

Do I need to configure CORS to send traceparent from a browser?

Yes, CORS configuration is required. traceparent is a custom header, so it makes the request non-simple and triggers a preflight; the server must list traceparent in Access-Control-Allow-Headers. OpenTelemetry’s browser instrumentation additionally needs propagateTraceHeaderCorsUrls configured, because it will not inject trace headers cross-origin by default.

How do I propagate trace context through Kafka or SQS?

Write the traceparent value into a Kafka record header or an SQS message attribute on the producer side, and read it back on the consumer side to restore the context. OpenTelemetry SDKs expose inject and extract for this in every language. The format is unchanged; only the carrier differs from an HTTP header map.

Is a trace ID safe to expose in logs or responses?

Yes, a trace ID is safe to expose. It is a random identifier with no embedded identity and no authorisation power. It does correlate records across systems, so never encode a user ID or tenant name into one, and never accept it as proof of anything. Treat it as a public correlation key and logging or returning it is safe.

Who generates the traceparent header?

The first service to handle a request that arrives without one. That is usually an edge proxy, an API gateway or a browser SDK, and it becomes the root of the trace: it generates the trace-id, creates the first span, makes the sampling decision, and every hop after it only rewrites the parent-id.

Is the traceparent header required?

No. It is optional at the protocol level, and a request without one is perfectly valid; the receiving service simply becomes the root of a new trace. It is only required in the practical sense that without it, work done across a service boundary cannot be correlated into one trace.

Does traceparent add measurable overhead?

Not meaningfully. A traceparent is 55 bytes, and a tracestate typically adds a few hundred more, which is negligible beside a TLS handshake or any real payload. The cost of tracing sits in exporting and storing sampled spans, not in carrying distributed tracing headers on the wire.

Tags: distributed-tracing opentelemetry observability http-headers w3c

Related Articles

View all articles