NAME
Punk::OpenTelemetry::Propagate - trace context across a process boundary
SYNOPSIS
my $ctx = Punk::OpenTelemetry::Propagate::extract(\%headers, $order);
my $span = $tracer->start('GET /x', kind => 2, parent => $ctx);
my $out = Punk::OpenTelemetry::Propagate::inject(
$span->trace_id, $span->span_id, 1, $order);
# { traceparent => '00-...-...-01', b3 => '...' }
DESCRIPTION
Four propagators and a composite.
EVERY BYTE HERE CAME FROM THE CLIENT
A traceparent is a request header: attacker-controlled input, arriving on the hot path of every request. Three rules follow, and they are the reason this is written the way it is.
Nothing allocates on a length the client supplied. Every parse is fixed-width or bounded by a constant.
Nothing croaks. A malformed header yields no context rather than an error - a 500 because somebody sent a bad trace header would be a denial of service with extra steps.
Anything reflected back out is validated on the way out as well as in. Bytes do not become safe by having been seen once already; that is the lesson of CVE-2026-75628 and the Punk markdown 301, both in this ecosystem.
Invalid is absent. An unparseable, wrong-length or all-zero id yields no context at all rather than a partial one, because a span claiming a parent that cannot exist hangs off nothing for ever in every UI, while a root span is correct and legible.
W3C TRACE CONTEXT
traceparent is fixed-shape and 55 bytes at version 00.
A version above 00 is parsed leniently - take the first 55 bytes, ignore a trailing -suffix - rather than rejected. Rejecting an unknown version is how a service becomes the one that breaks every trace the day the ecosystem moves to 01: it would be the only participant dropping context, and the traces would be broken in a way that points at everyone except the offender. Version ff is the one explicit invalid.
Every flag bit is preserved, not only the sampled bit. A bit whose meaning we do not know today is still somebody's information.
tracestate
The member you changed moves to the front; everyone else keeps their relative order. That ordering is how a downstream vendor knows which system touched the trace most recently, and it is the rule implementations get wrong.
A malformed member invalidates that member, not the header: dropping everyone else's state because one vendor emitted something odd is both rude and lossy. Capped at 32 members, dropped from the right - the oldest state.
B3
Both spellings: the single b3 header and the X-B3-* family. The single header wins when both are present.
A B3 trace id may be 64-bit as well as 128-bit, and a short one is left-padded. Get the side wrong and you produce a well-formed id of an entirely different value - worse than a rejection, because it looks correct and joins to nothing.
sampled has five spellings in the wild - 1, 0, true, false and d - and d is debug, a distinct state that implies sampled rather than being a synonym for it. X-B3-Flags: 1 is the multi-header spelling, and it overrides an X-B3-Sampled of 0.
JAEGER
uber-trace-id: {trace}:{span}:{parent}:{flags}.
The trace id is frequently written short, with leading zeroes trimmed, and is left-padded - the same trap as B3. The parent field is deprecated and discarded. Flags is a bitfield: 1 sampled, 2 debug.
The whole value is often percent-encoded, because proxies and client libraries treat it as a URL component and escape the colons. A parser that only accepts the raw form silently drops context from every request that passed through one of those, which presents as an intermittent tracing bug and is really a decoding one. Decoding is unconditional; a value with no escapes comes back unchanged.
BAGGAGE
key=value;prop,key2=value2, percent-encoded, capped at 180 entries, 4096 bytes an entry and 8192 bytes total. Over-limit entries are dropped, not truncated: a truncated value is a different value, and silently changing an application's data is worse than not carrying it.
Baggage is not attached to spans
That is a security decision, not an omission.
Baggage arrives in a request header, so on any public endpoint an attacker chooses its contents. Copying it into every span's attributes - the obvious convenience, and a default in some SDKs - hands that attacker two things at once: a cardinality bomb, because they pick both the key names and the values, and a data-leak path, because whatever they put there lands in a telemetry backend that is usually less guarded than the application and often a third party.
An application that wants baggage on its spans asks for it, by name, for the keys it expects.
THE COMPOSITE
$order is a comma-separated list, default tracecontext,baggage.
Inject emits every configured format, so one request can carry traceparent and b3 together - which is what makes a mixed-fleet migration possible without a flag day.
Extract runs them in order, and a later one that finds a valid context overrides an earlier one. So b3,tracecontext and tracecontext,b3 behave differently, and both are reasonable things to configure.
FUNCTIONS
extract(\%headers, $order)
The context as { trace_id, span_id, sampled, flags, debug, format }, or undef. %headers is keyed by lowercased header name. The result is exactly what "start" in Punk::OpenTelemetry::Tracer's parent option takes.
inject($trace_id, $span_id, $sampled, $order)
A hashref of headers to add to an outbound request.
tracestate($existing, $key, $value)
The mutated tracestate value.
baggage_extract($header) / baggage_inject(\%baggage)
baggage_extract parses a baggage header value into a hashref; baggage_inject renders one back. Both are the W3C encoding, so a value carrying a comma or an equals sign survives the round trip.
Baggage is not attached to spans - see "Baggage is not attached to spans" for why that is a deliberate refusal rather than an omission.
SEE ALSO
Punk::OpenTelemetry::Tracer, which starts the spans this joins up, and Punk::OpenTelemetry::Config for OTEL_PROPAGATORS.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)