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