NAME
Punk::Observe::Trace - spans, trace assembly and the service graph
SYNOPSIS
use Punk::Observe::Trace;
my @spans = ({ trace_hi => 1, trace_lo => 2, span_id => 10,
start => 1_000, end => 501_000, service => 1 },
{ trace_hi => 1, trace_lo => 2, span_id => 11, parent => 10,
start => 2_000, end => 400_000, service => 2 });
my $a = Punk::Observe::Trace::analyse(\@spans);
printf "%d traces, %d edges\n", $a->{traces}, scalar @{ $a->{edges} };
my $s = Punk::Observe::Trace::slower_than(\@spans, 500_000_000);
DESCRIPTION
A trace is never complete, so nothing waits for one.
The spans of a single trace arrive from many processes, in many batches, out of order, across a window bounded only by the longest span. A backend that assembles traces when they are written has to buffer them, decide when a trace is finished, and be wrong.
So spans are stored individually and a trace is assembled when it is read. Ingest never buffers, a span arriving an hour late still joins its trace, and there is no trace timeout for anyone to misconfigure.
Rootness is decided after assembly
A span with no parent identifier is not necessarily a root - its parent may simply be in another segment. Deciding at write time makes every trace appear to have several roots. A span whose named parent is absent is counted as an orphan rather than hidden, and that count is how an incomplete trace is distinguished from a genuinely shallow one.
Broken instrumentation can produce a cycle in the parent chain. Assembly is depth-bounded and reports cycles rather than recursing on them.
The service graph is accumulated at seal
Edges are computed when a segment is sealed, not per query, so the service map does not scan every span on every page load. The table is services-squared rather than span-sized: six hundred spans across four services produce four edges.
A span whose parent belongs to a different service is an edge. A call within one service is not - counting it would make every service a self-loop. A span whose caller is absent gets an edge from a synthetic root, reported as *, because traffic arriving from something uninstrumented is a finding rather than a gap to hide.
THE SPAN SPEC
Every function taking spans takes an arrayref of hashrefs:
trace_hi the trace id, high 8 bytes
trace_lo the trace id, low 8 bytes
span_id the span id
parent the parent span id, absent or 0 for none
start start time, unix nanoseconds
end end time, unix nanoseconds
service the service symbol number
name the span name symbol number
status the OTLP status code
Services and names are symbol numbers rather than strings, because a segment stores them interned. See "intern_strings" in Punk::Observe::Segment.
An end before its start is clamped to a zero duration and counted, never stored as the enormous positive number that subtracting them in a uint64_t would produce.
FUNCTIONS
span_size
my $bytes = Punk::Observe::Trace::span_size();
The size of one stored span, in bytes.
analyse
my $out = Punk::Observe::Trace::analyse(\@spans);
Runs the whole pipeline in one call: add, seal (which sorts), index, summarise and graph.
{
spans => 600, traces => 100,
slots => 2048, clamped => 0,
any_error => 1,
t_min => ..., t_max => ..., dur_max => ...,
by_duration => [ ... ],
edges => [ ... ],
tree => [ ... ],
roots => 1, cycles => 0, orphans => 0,
}
by_duration is one entry per trace, slowest first, each carrying trace_hi, trace_lo, duration, spans, errors and root_service.
edges is the service graph: caller (a service symbol, or * for the synthetic root), callee, count, errors and dur_max.
tree is the assembled tree of the first trace only, one entry per span with span_id, parent (an index into tree, or -1) and depth. roots, cycles and orphans describe that tree.
any_error and dur_max are the footer statistics a query prunes segments on.
index_probe
my $out = Punk::Observe::Trace::index_probe(\@spans, [ $hi, $lo, ... ]);
Builds the trace-id index, then looks up each (hi, lo) pair in the flat list.
{
found => 1000, missing => 100,
counts => [ 6, -1, 6 ],
slots => 2048, distinct => 1000,
probes => 1530, lookups => 1000,
}
counts is positional: the number of spans for a trace that was found, or -1 for one that was not.
probes over lookups is the average probe count, and it is the assertion that the table has not degenerated into a scan. Measured at 1.53 over a thousand traces.
The slot stores the full sixteen bytes of the identifier. A sixty-four-bit comparison would eventually merge two unrelated traces into one waterfall, which is the most confusing thing this system could do.
slower_than
my $out = Punk::Observe::Trace::slower_than(\@spans, $min_duration_ns);
Every trace at or above a duration, by binary search into the ordinal array sorted by duration.
{ durations => [ ... ], from => 940, total => 1000 }
from is the ordinal where the range starts, which is what makes this a contiguous range rather than a filtered scan.
seg_may_match
my $bool = Punk::Observe::Trace::seg_may_match(
$t_min, $t_max, $dur_max, $any_error,
$from, $to, $min_duration, $want_error);
Whether a segment can possibly answer a trace query, from its footer alone. The first four arguments are the segment's statistics and the last four are the query's. False means the segment is skipped without being opened: outside the time range, holding no trace slower than the threshold, or holding no error span when the query wants errors.
SEE ALSO
Punk::Observe, Punk::Observe::Flame, Punk::Observe::Map, Punk::Observe::SegIO