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)