Observability · Tracing
The traceparent Header: Field Widths and Version Negotiation
The exact byte layout of the W3C traceparent header, why its fields are fixed width, what the flags byte means, and how a version-00 parser must treat unknown versions.
A tracing header has an unusual set of requirements for something that rides in an HTTP request. It must be cheap enough to parse on every request in a high-volume service, unambiguous enough that a proxy can rewrite it without breaking the meaning, stable enough that a decade of implementations agree on it, and extensible enough that the format can change without a flag day. The W3C Trace Context specification answers all four with a fixed-width, hyphen-delimited, versioned string, and the constraints on each field follow directly from those requirements.
Four fields and fifty-five characters
The traceparent header value has exactly four dash-separated fields:
| Field | Width | Value |
|---|---|---|
| version | 2 hex chars | 00 |
| trace-id | 32 hex chars | 16 bytes, non-zero |
| parent-id | 16 hex chars | 8 bytes, non-zero |
| trace-flags | 2 hex chars | 01 sampled, 00 not sampled |
The first field is the version, two hexadecimal characters. The second is the trace identifier, 32 lowercase hexadecimal characters. The third is the parent span identifier, 16 lowercase hexadecimal characters. The fourth is the trace flags, two hexadecimal characters. Version 00 therefore produces a 55-character value: 2 + 1 + 32 + 1 + 16 + 1 + 2.
Fixed width is the central design decision. A parser knows before it reads a single byte where every field starts, so it can validate with length checks and slice operations rather than a tokeniser. There is no escaping, no quoting, no optional field ordering, and no ambiguity about where the trace identifier ends. A general-purpose parser that splits on hyphens and checks the length of each piece is about as fast as parsing gets, and a hand-written parser can index directly into the buffer. Compare this with a format that embeds variable-length fields, such as a key-value syntax: the parser must scan for delimiters, handle quoting, and decide what to do with a malformed field, all before it can answer the only question that matters on the hot path, which is whether the request should be sampled.
Lowercase hexadecimal is required rather than merely conventional. The specification says an implementation must reject values with uppercase letters, because otherwise two services could emit the same identifier in different cases and a comparison would depend on which side normalised. Rejecting is also cheaper than normalising: the parser validates each byte against the character class instead of transforming the string. This is a rare case where a strict parser is both faster and more interoperable.
Both identifiers must be non-zero. An all-zero trace identifier means the caller had no valid context, and treating it as a real identifier would cause every unsampled or untraced request to be recorded under the same trace. The same applies to the span identifier. A conforming parser rejects both.
The version field is a negotiation, not a feature flag
Version 00 is the only version defined by the recommendation, and the field exists so that later versions can add fields without breaking the parsers already deployed.
The rule is asymmetric, and the asymmetry is the interesting part. A parser that implements version 00 must accept a traceparent whose version is higher than 00, provided the rest of the value parses as version 00. Concretely, the first 55 characters must be well-formed, and whatever follows must be either the end of the value or a dash introducing extension fields that the old parser ignores. A higher version may append fields, but it may not change the meaning or the position of the four fields already defined.
A parser must reject version ff. That value is reserved and explicitly invalid, so that a future version cannot be confused with the sentinel. Values that are not valid two-character lowercase hexadecimal, including the empty string, are also rejected.
This is forward compatibility without a central registry: an old service in the middle of a call chain can pass through a header it does not fully understand, because the parts it does understand are in fixed positions and the unknown tail is self-delimiting. The cost is that a version bump cannot fix a mistake in the existing fields. If the flags byte turned out to be underspecified, a new version could add a second flags field but could not redefine the first, and every implementation would have to carry both forever. The design accepts that rigidity in exchange for the ability to deploy new versions incrementally.
What the flags byte actually says
Two hexadecimal characters, eight bits, and exactly one bit currently defined: bit 0, the sampled flag. A value of 01 means the caller recorded this trace; 00 means it did not, and the callee should not record it either unless it has an independent reason to. Bits 1 through 7 are reserved and must be zero in a valid version-00 header.
The sampled flag is a hint with a specific semantics that is easy to get wrong. It does not mean “this trace is interesting”. It means the upstream party has made a sampling decision and the downstream party should honour it. A service that receives 01 records its spans even if its own sampling configuration would have dropped the trace, because dropping half of a sampled trace produces a broken tree, and a broken tree is worse than no trace: a reader sees a parent with missing children and cannot tell whether the child failed, was never called, or was simply not recorded. A service that receives 00 may still record if its own rules say so, which happens in tail-based sampling where the decision is deferred.
The remaining bits are reserved. The specification defines no behaviour for them and requires a sender to set them to zero, so a header with one of them set is outside what version 00 describes: there is no defined meaning to attach to it, and since the value indicates a sender expecting semantics the receiver does not implement, restarting the trace is the conservative reading. Those bits are also the escape hatch that the version field is not. A later version can define bit 1 for a second sampling mechanism without changing the position or meaning of the four fields already in use, and a version-00 implementation that ignored the unknown bit would be right for the wrong reason, since ignoring works only until the new bit carries a meaning that changes what should be recorded.
tracestate carries what the fixed fields cannot
traceparent is deliberately minimal, so vendor-specific and system-specific data travels in a second header, tracestate. It is a comma-separated list of key-value entries, with the most recently written entry first, capped at 32 list-members. Entries are key=value, and key and value are each restricted to a defined character set and a bounded length. On size, the specification asks a vendor to propagate at least 512 characters of the combined header, and requires that truncation remove whole entries rather than cut one in half, preferring to drop any entry longer than 128 characters before dropping the rest.
The ordering rule is a stack discipline. A system that wants to attach state prepends its entry, so the leftmost entry is the one written by the most recent participant. When a system reads the header it can find its own entry, update it, and move it back to the front. When the list exceeds the entry limit, the entries at the end, which are the oldest, are dropped. This gives a bounded header that degrades by forgetting old state rather than by growing without limit.
tracestate is optional and is only meaningful alongside a valid traceparent. A receiver that gets tracestate without traceparent must discard it, because the state is keyed to a trace that the receiver cannot identify. The pairing rule matters for proxies: a proxy that strips traceparent for privacy must strip tracestate too, or it leaves a fragment of cross-service state attached to nothing.
Parsing, and the failure mode that matters
A parser that follows the specification is small. The important behaviour is what happens on failure: the receiving service must start a new trace rather than propagate a malformed header, and it must not treat a malformed header as an error condition that fails the request.
// ParseTraceparent validates a version-00 traceparent value.
// Returns the trace ID, parent span ID, sampled flag, and ok.
func ParseTraceparent(v string) (traceID, spanID string, sampled bool, ok bool) {
if len(v) < 55 || v[2] != '-' || v[35] != '-' || v[52] != '-' {
return "", "", false, false
}
ver := v[0:2]
if !isLowerHex(ver) || ver == "ff" {
return "", "", false, false
}
if ver == "00" && len(v) != 55 {
return "", "", false, false // v00 has no extension fields
}
// Higher versions: the first 55 characters must still parse as v00.
// What follows is either nothing or an extension introduced by a dash.
if ver != "00" && len(v) > 55 && v[55] != '-' {
return "", "", false, false
}
traceID, spanID, flags := v[3:35], v[36:52], v[53:55]
if !isLowerHex(traceID) || !isLowerHex(spanID) || !isLowerHex(flags) {
return "", "", false, false
}
if traceID == strings.Repeat("0", 32) || spanID == strings.Repeat("0", 16) {
return "", "", false, false
}
// Only bit 0 is defined; senders must zero the rest. A receiver that sees
// a reserved bit set is looking at a value version 00 does not describe.
if flags[1] != '0' {
return "", "", false, false
}
return traceID, spanID, flags[0] == '1', true
}
The distinction between “malformed” and “absent” is where implementations diverge in practice. Absent means the caller does not participate in tracing, which is normal and expected for health checks, static assets, and third-party clients. Malformed means a caller tried to participate and got the format wrong, usually because of a hand-rolled header builder or a proxy that truncated the value. Both result in a new trace, but only the malformed case is worth counting, because a rising rate of malformed headers is a signal that some component in the path is corrupting the value.
The most common corruption is whitespace. HTTP header values may carry optional whitespace around the field value, and a parser that does not trim it sees a 56-character string starting with a space and rejects a perfectly valid header. The second most common is case folding by an intermediary that normalises header names and, less carefully, values. Since the specification requires lowercase, a normalising proxy that uppercases hex is indistinguishable from a broken sender.
Once the header is parsed, the receiving service still has to decide whether to keep the trace, which is a separate question from whether the header is valid; head-based sampling decision points covers where that decision belongs. The header is also where the identifiers that a span record is keyed by are first minted or carried forward. The header only carries the upstream answer, and the format deliberately gives it no way to express the reason.
References
- Trace Context — W3C Recommendation
- RFC 9110: HTTP Semantics — IETF
- OpenTelemetry Context Propagation specification — OpenTelemetry project