NAME
Punk::OpenTelemetry::Tracer - the trace SDK
SYNOPSIS
my $tracer = Punk::OpenTelemetry::Tracer->new(
resource => { 'service.name' => 'maat' },
scope_name => 'Punk::OpenTelemetry',
scope_version => '0.01',
ratio => 0.1,
);
my $span = $tracer->start('GET /users/:id', kind => 2);
if ($span) { # undef when the trace is not sampled
$span->attr('http.route' => '/users/:id');
$span->event('cache miss');
$span->status(2, 'upstream refused');
$tracer->enqueue($span);
}
if (my $payload = $tracer->drain) {
my $bytes = Punk::OpenTelemetry::Encode::traces_protobuf($payload);
}
DESCRIPTION
The tracer and its spans. A span is a struct, not a blessed hash, and an unsampled span is never built at all.
SAMPLING
The ratio decision is derived from the trace id, not from a random draw per service. That is the single most important property here.
With a coin flip per service, a request crossing three services that all sample at 10% keeps the whole trace one time in a thousand. What a backend receives is a stream of one-span fragments with dangling parents, while the dashboard reports 10% sampling. Every service having flipped a fair coin is no comfort at all.
Deriving the decision from the trace id makes it the same decision everywhere the trace goes, with no coordination: the id is already propagated, and every service computes the same answer from it. 10% sampling then means 10% of traces, complete.
ParentBased wraps it: a trace that already carries a decision inherits it, including a decision not to sample, because a service that re-decides mid-trace produces a trace with holes in the middle. Only a root span consults the ratio. always_on and always_off override outright.
An unsampled span returns undef
That is the success path, not an error. At a 1% ratio the other 99% of requests must allocate nothing at all - no struct, no hash, no id, no timestamp - which is the entire reason sampling is worth having. Callers cope with undef.
IDS AND CLOCKS
Ids come from getentropy(2) where it exists and a cached /dev/urandom descriptor where it does not, reopened after a fork. An all-zero id is never generated and never accepted: one arriving in a header is treated as absent, because a span claiming a parent that cannot exist is worse than a root span.
The wall clock is read once, at start, to place the span in time. The duration is measured on a monotonic clock and the end derived from it. Two wall-clock reads can straddle an NTP step, a manual clock set or a VM resume, and a span that ends before it began is one collectors variously drop, clamp, or draw with a negative length - all of which happen to the trace that was interesting enough to look at.
LIMITS
128 attributes, 128 events and 128 links, each with a dropped count that reaches the payload. Both halves matter, and the second more: a span that quietly loses its 129th attribute looks complete, and somebody spends an afternoon working out why the attribute they added is missing. A span that says "1 dropped" answers before the question is asked.
Overwriting an existing attribute is not adding one: it neither counts against the limit nor increments the dropped count, so a loop that updates one attribute does not look like a span shedding hundreds.
THE QUEUE
Bounded at 2048 ended spans, dropping the oldest when full, counting what it dropped. Every part of that is deliberate:
Bounded, because an unbounded queue in front of an unreachable collector is not a queue, it is a memory leak with a schedule. The failure it produces is a web server dying of memory exhaustion some hours after a collector went down, which is a far worse outage than the missing telemetry.
Drop-oldest, because when a system is in trouble the interesting spans are the recent ones.
Counted, because a telemetry layer that cannot report its own losses is asking to be trusted for no reason. A gap with a number beside it is a diagnosis; a gap without one is a mystery.
The queue belongs to the process that filled it: a child that inherited one starts empty, or every worker would export the parent's spans as its own, once per worker.
METHODS
new(%opt)
resource (a hashref of attributes), scope_name, scope_version, schema_url, sampler (parent_ratio, always_on, always_off), ratio.
start($name, %opt)
A span, or undef when the trace is not sampled. kind is the numeric SpanKind; parent is a { trace_id, span_id, sampled } hashref from an extracted inbound context.
enqueue($span)
End the span and put it on the export queue. The queue takes ownership.
drain($max)
Up to $max queued spans as an OTLP payload ready for Punk::OpenTelemetry::Encode, or undef when there is nothing to send.
queued / stats
queued is the queue depth. stats returns started, ended, dropped, sampled_out and queued.
SPAN METHODS
attr, event, link, status (all chainable), end, trace_id, span_id, to_hash, counts.
Only the application should set an OK status. Instrumentation sets ERROR or leaves the status unset, because a layer with no opinion about whether an operation succeeded must not claim one.
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)