Selkie.git | lib/Selkie/ | Trace.rakumod
unit class Selkie::Trace;
# Lightweight runtime tracing for Selkie applications.
#
# The implementation avoids non-core dependencies so Selkie can emit
# Chrome Trace JSON without adding JSON::Fast to the framework itself.
=begin pod
=head1 NAME
Selkie::Trace - runtime span tracing (Chrome Trace JSON / slow-span JSONL)
=head1 SYNOPSIS
=begin code :lang<raku>
Selkie::Trace.init(mode => 'trace', trace-path => $path);
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('widget.render', cat => 'render',
args => %(rows => 4))
!! Nil;
# ... work ...
$span.finish(ok => True) with $span;
Selkie::Trace.shutdown; # drains + writes the JSON tail
=end code
=head1 DESCRIPTION
Three modes: C<off> (default — C<.enabled> is a state-var check and
C<.start> returns the type object), C<slow> (synchronous JSONL of spans
over their threshold — cheap enough for day-to-day use), and C<trace>
(complete Chrome Trace capture — every span, for deliberate profiling
sessions only).
In C<trace> mode, events are enqueued lock-free on a C<Channel> and a
dedicated writer thread owns all JSON serialization and file I/O, so
span capture on hot paths costs an arg snapshot + a channel send, not
a file write. C<shutdown>/C<reset> close the channel, join the writer,
and only then append the JSON tail — the file is complete and valid
once C<shutdown> returns. Producers racing shutdown lose their events
(sends on the closed channel are swallowed), which is the intended
"capture ends now" semantic.
=head2 Span discipline
Create spans B<after> any cheap early-return guard, never before: a
method whose common case is a no-op (an idempotent destroy, an
already-loaded load) must not emit an event that records nothing —
per-frame no-op spans dominated a real capture at 27 events/frame
before this rule.
=end pod
our class Span {
has Str $.name is required;
has Str $.cat = 'app';
has Num $.ts-us is required;
has Int $.pid is required;
has Int $.tid is required;
has Num $.threshold-ms = 0e0;
has %.args;
method finish(*%args) {
Selkie::Trace.finish-span(self, |%args);
}
}
my enum Mode <Off Slow Trace>;
my Mode $active-mode = Off;
my Str $active-trace-path;
my Str $active-slow-path;
my IO::Handle $trace-handle;
my IO::Handle $slow-handle;
my Lock $lock .= new;
my Bool $trace-first-event = True;
my Int $trace-events-written = 0;
my Num $global-slow-ms = 8e0;
my %thresholds;
my Bool $closed = False;
# Trace-mode writer thread. Producers hand fully-snapshotted event
# Hashes to the channel (safe-args/safe-value run on the caller, so
# mutable values are captured at span time); a single dedicated thread
# owns all JSON serialization + file writes. Before this, every span
# serialized + wrote inside the shared lock ON THE CALLING THREAD —
# at ~27 events/frame (2026-07 capture) that was ~3ms of main-thread
# time per frame while tracing. Slow mode stays synchronous: its
# volume is thresholded and per-line flushing is the point.
my Channel $trace-channel;
my Thread $trace-writer;
method init(
::?CLASS:U:
Str :$trace-path,
Str :$slow-path,
Str :$mode = (%*ENV<CANTINA_TIMINGS> // 'off'),
Numeric :$slow-ms = (%*ENV<CANTINA_TIMING_SLOW_MS> // 8).Num,
:%thresholds = %(),
) {
self.reset;
$active-mode = parse-mode($mode);
return if $active-mode == Off;
$active-trace-path = $trace-path;
$active-slow-path = $slow-path;
$global-slow-ms = $slow-ms.Num;
%thresholds = %thresholds.Hash;
$trace-first-event = True;
$trace-events-written = 0;
$closed = False;
if $active-mode == Slow && $active-slow-path.defined && $active-slow-path.chars {
my $parent = $active-slow-path.IO.parent;
$parent.mkdir unless $parent.d;
$slow-handle = $active-slow-path.IO.open(:w, :enc<utf8>);
}
if $active-mode == Trace && $active-trace-path.defined && $active-trace-path.chars {
my $parent = $active-trace-path.IO.parent;
$parent.mkdir unless $parent.d;
$trace-handle = $active-trace-path.IO.open(:w, :enc<utf8>);
$trace-handle.print('{"traceEvents":[');
# Start the writer BEFORE the first instant so a single thread
# owns every event write after the header.
$trace-channel = Channel.new;
$trace-writer = Thread.start(
name => 'selkie-trace-writer',
:app_lifetime,
sub {
for $trace-channel.list -> %event {
write-trace-event(%event);
}
},
);
self.instant('trace/start', cat => 'trace', args => {
mode => 'trace',
pid => $*PID,
});
}
}
#|( Close the channel and join the writer so every queued event is on
disk, leaving the handle ready for the JSON tail. Callers hold
C<$lock>. Idempotent. )
sub drain-trace-writer(--> Nil) {
with $trace-channel { .close }
with $trace-writer { .finish }
$trace-channel = Channel;
$trace-writer = Thread;
}
method reset(::?CLASS:U:) {
$lock.protect: {
drain-trace-writer;
if $trace-handle.defined {
unless $closed {
$trace-handle.print('],"displayTimeUnit":"ms"}');
$trace-handle.flush;
}
$trace-handle.close;
$trace-handle = IO::Handle;
}
if $slow-handle.defined {
$slow-handle.close;
$slow-handle = IO::Handle;
}
}
$active-mode = Off;
$active-trace-path = Str;
$active-slow-path = Str;
$trace-first-event = True;
$trace-events-written = 0;
%thresholds = ();
$closed = False;
}
method shutdown(::?CLASS:U:) {
return if $closed;
# The shutdown marker rides the channel like any other event, so
# it lands after everything already queued; producers racing this
# point lose their events to the closed channel (their sends are
# try-guarded), which is the correct semantic for "capture ends
# now".
record-event({
name => 'trace/shutdown',
cat => 'trace',
ph => 'i',
s => 't',
ts => ts-us,
pid => $*PID,
tid => thread-id,
args => { pid => $*PID },
}) if $trace-channel.defined;
$lock.protect: {
$closed = True;
drain-trace-writer;
if $trace-handle.defined {
$trace-handle.print('],"displayTimeUnit":"ms"}');
$trace-handle.flush;
$trace-handle.close;
$trace-handle = IO::Handle;
}
if $slow-handle.defined {
$slow-handle.close;
$slow-handle = IO::Handle;
}
}
$active-mode = Off;
}
method enabled(::?CLASS:U: --> Bool) { $active-mode != Off }
method trace-enabled(::?CLASS:U: --> Bool) { $active-mode == Trace }
method slow-enabled(::?CLASS:U: --> Bool) { $active-mode == Slow }
method mode(::?CLASS:U: --> Str) { $active-mode.Str.lc }
method trace-path(::?CLASS:U: --> Str) { $active-trace-path }
method slow-path(::?CLASS:U: --> Str) { $active-slow-path }
method start(
::?CLASS:U:
Str:D $name,
Str :$cat = 'app',
:%args = %(),
Str :$lane = '',
Numeric :$slow-ms,
--> Span
) {
return Span unless $active-mode != Off;
my %a = safe-args(%args);
%a<lane> = $lane if $lane.chars;
Span.new(
:$name,
:$cat,
ts-us => ts-us,
pid => $*PID,
tid => thread-id,
threshold-ms => ($slow-ms.defined ?? $slow-ms.Num !! threshold-for($cat)),
args => %a,
);
}
method finish-span(::?CLASS:U: Span $span, *%args) {
return unless $active-mode != Off && $span.defined;
my Num $end-us = ts-us;
my Num $dur-us = $end-us - $span.ts-us;
my Num $dur-ms = $dur-us / 1000e0;
my %args-out = $span.args.Hash;
%args-out{$_} = safe-value(%args{$_}) for %args.keys;
if $active-mode == Trace {
record-event({
name => $span.name,
cat => $span.cat,
ph => 'X',
ts => $span.ts-us,
dur => $dur-us max 0e0,
pid => $span.pid,
tid => $span.tid,
args => %args-out,
});
}
if $active-mode == Slow && $dur-ms >= $span.threshold-ms {
write-slow({
ts => iso-utc(now),
kind => 'span',
name => $span.name,
cat => $span.cat,
dur_ms => round-ms($dur-ms),
pid => $span.pid,
tid => $span.tid,
args => %args-out,
});
}
}
method instant(
::?CLASS:U:
Str:D $name,
Str :$cat = 'app',
:%args = %(),
Str :$lane = '',
) {
return unless $active-mode == Trace;
my %a = safe-args(%args);
%a<lane> = $lane if $lane.chars;
record-event({
:$name,
:$cat,
ph => 'i',
s => 't',
ts => ts-us,
pid => $*PID,
tid => thread-id,
args => %a,
});
}
method counter(
::?CLASS:U:
Str:D $name,
Numeric:D $value,
Str :$cat = 'counter',
:%args = %(),
Str :$lane = '',
) {
return unless $active-mode == Trace;
my %a = safe-args(%args);
%a<value> = $value.Num;
%a<lane> = $lane if $lane.chars;
record-event({
:$name,
:$cat,
ph => 'C',
ts => ts-us,
pid => $*PID,
tid => thread-id,
args => %a,
});
}
sub parse-mode(Str:D $raw --> Mode) {
given $raw.lc.trim {
when 'trace' { Trace }
when 'slow' { Slow }
default { Off }
}
}
sub threshold-for(Str:D $cat --> Num) {
with %thresholds{$cat} { return .Num }
with %thresholds<default> { return .Num }
$global-slow-ms;
}
sub ts-us(--> Num) { now.Num * 1_000_000e0 }
sub thread-id(--> Int) {
try { return $*THREAD.id.Int }
0;
}
sub record-event(%event) {
# Lock-free enqueue; the writer thread owns serialization + I/O.
# A send racing shutdown's channel close throws — swallowed, the
# event is simply outside the capture window.
return if $closed;
with $trace-channel {
try .send(%event.Hash);
}
}
# Runs ONLY on the writer thread — the header and the JSON tail are
# printed directly by init/reset/shutdown strictly before the writer
# starts / after it is joined, so the handle is single-writer and
# needs no lock here.
sub write-trace-event(%event) {
return unless $trace-handle.defined;
my $prefix = $trace-first-event ?? '' !! ',';
$trace-handle.print($prefix ~ json(%event));
$trace-first-event = False;
$trace-events-written++;
$trace-handle.flush if $trace-events-written %% 256;
}
sub write-slow(%event) {
return unless $slow-handle.defined;
$lock.protect: {
$slow-handle.say(json(%event));
$slow-handle.flush;
};
}
sub safe-args(%args --> Hash) {
%args.map(-> $p { $p.key => safe-value($p.value) }).Hash;
}
sub safe-value($value) {
return Any unless $value.defined;
given $value {
when Bool { $value.Bool }
when Numeric { $value.Num }
when Str {
my $s = $value;
$s.chars > 180 ?? $s.substr(0, 177) ~ '...' !! $s;
}
when Blob { { type => $value.^name, bytes => $value.bytes } }
when Associative {
my %out;
for $value.keys.sort.head(16) -> $k {
%out{$k.Str} = safe-value($value{$k});
}
%out<__truncated> = True if $value.keys.elems > 16;
%out;
}
when Positional {
my @items = $value.list.head(16).map({ safe-value($_) }).Array;
@items.push({ __truncated => True, count => $value.elems })
if $value.elems > 16;
@items;
}
default {
my $s = $value.gist;
$s.chars > 180 ?? $s.substr(0, 177) ~ '...' !! $s;
}
}
}
sub json($value --> Str) {
return 'null' unless $value.defined;
given $value {
when Bool { $value ?? 'true' !! 'false' }
when Numeric {
my Num $n = $value.Num;
($n.isNaN || $n == Inf || $n == -Inf) ?? 'null' !! $n.Str;
}
when Str { '"' ~ json-escape($value) ~ '"' }
when Associative {
'{' ~ $value.keys.sort.map(-> $k {
json($k.Str) ~ ':' ~ json($value{$k})
}).join(',') ~ '}';
}
when Positional {
'[' ~ $value.list.map({ json($_) }).join(',') ~ ']';
}
default { json($value.gist) }
}
}
sub json-escape(Str:D $s --> Str) {
$s.trans(
['\\', '"', "\n", "\r", "\t", "\b", "\f"]
=>
['\\\\', '\"', '\n', '\r', '\t', '\b', '\f']
);
}
sub round-ms(Num:D $ms --> Num) {
($ms * 1000e0).round / 1000e0;
}
sub iso-utc(Instant $i --> Str) {
my $dt = $i.DateTime.utc;
sprintf '%04d-%02d-%02dT%02d:%02d:%02d.%03dZ',
$dt.year, $dt.month, $dt.day,
$dt.hour, $dt.minute, $dt.second.Int,
(($dt.second - $dt.second.Int) * 1000).Int;
}