@zakkster/lite-trace
v1.1.0
Published
Zero-dependency nested-span tracer: preallocated struct-of-arrays span pool with stack-based begin/end, counters, instants, async events, ring-buffer mode, and Chrome Trace Event export for chrome://tracing / Perfetto.
Maintainers
Readme
@zakkster/lite-trace
A zero-dependency nested-span tracer. The other profilers in this ecosystem report flat buckets -- total time in a phase, GC counts, draw calls. This one captures the structure of a frame: which span ran inside which, and how much time each one owns by itself. You get a flamegraph, a flat profile, and Chrome-trace JSON you can drop straight into Perfetto -- with counters, point-in-time instants, and async spans that cross frame boundaries all on the same timeline.
How it works
Spans live in a preallocated struct-of-arrays pool (parent, start, end, depth,
tagId, plus args, type, asyncId, counterVal for the v1.1 entry kinds). begin()
opens a span beneath whatever is currently open; end() closes the most recent one. Tags
are interned to integer ids the first time they're seen, so repeated tags cost a Map.get
and nothing more -- begin/end allocate nothing in steady state. Counters, instants,
and async begin/end share the same pool and the same reset() / drop / ring semantics as
spans; they just carry a different type byte. The output methods build arrays and
objects, but they run when you read results, not on the hot path.
Install
npm i @zakkster/lite-traceNo dependencies. No peers.
Trace a frame
import { Tracer } from '@zakkster/lite-trace';
const tr = new Tracer(2048);
function frame() {
tr.reset();
tr.begin('frame');
tr.span('update', update);
tr.begin('render');
tr.span('cull', cull);
tr.span('draw', draw);
tr.span('post', post);
tr.end();
tr.end();
drawFlamegraph(tr); // see below
requestAnimationFrame(frame);
}span(tag, fn) is the safe form -- the matching end runs in a finally, so a throw inside
fn can't desync the stack. Prefer it unless you need the raw begin/end pair.
Read the results
Flat profile (summary)
const s = tr.summary();
// s.byTag.render -> { count, totalMs, selfMs, maxMs, avgMs }selfMs excludes time spent in child spans. A render span that mostly waits on cull +
draw + post shows a small self time; the children show where the frame actually went.
s also carries spans, overflow, maxDepth, and wallMs. Counters, instants, and
async entries are excluded from summary and forEach -- they're span-only views.
Flamegraph (forEach)
forEach walks spans in record order with no allocation, giving you everything a bar needs:
function drawFlamegraph(tr) {
const t0 = /* earliest start */;
tr.forEach((id, tag, start, end, dur, depth, parent) => {
const x = (start - t0) * pxPerMs;
const y = depth * rowHeight;
const w = dur * pxPerMs;
ctx.fillRect(x, y, w, rowHeight - 1); // x = when, y = depth, w = duration
});
}Nested tree (tree)
const roots = tr.tree();
// roots[0] -> { tag:'frame', durMs, children:[ { tag:'render', children:[...] } ] }Nodes include an args field when the corresponding span was opened with args.
Chrome trace / Perfetto (toChromeTrace)
The one that plugs into existing pro tooling. toChromeTrace() returns the Chrome Trace
Event format: X (complete) for spans, i (instant) for markers, C (counter) for
sampled values, and b/e (nestable async begin/end) for cross-frame operations --
all in microseconds.
const blob = new Blob([JSON.stringify(tr.toChromeTrace())], { type: 'application/json' });
// download it, then drag the .json into https://ui.perfetto.dev or chrome://tracingYou get a real, zoomable flamegraph rendered by Perfetto -- from a tracer that added zero dependencies and no allocation to your frame.
Beyond span pairs
The additions below are the v1.1 surface. The v1.0 API (begin / end / span / reset /
forEach / summary / tree / toChromeTrace) is unchanged and remains fully
backward-compatible.
Args on spans
Attach any small key-value object to a span; Perfetto shows it as a tooltip when you hover
the bar, and tree() surfaces it as an args field on the node.
tr.begin('draw', { batches: 128, mode: 'instanced' });
// ...
tr.end();
tr.span('physics', step, { bodies: 4096 });Consumers that don't pass args pay nothing -- the SoA column stays null.
Ring-buffer mode
Default mode drops new spans when the pool fills. Ring mode overwrites the oldest instead:
const tr = new Tracer(2048, { mode: 'ring' });Now begin() never returns -1, and at any point you can dump the last N events. That's
what "something bad just happened, give me the last four seconds" looks like in production.
Counters (counter)
Numeric samples at the current timestamp; Chrome Trace C events. In Perfetto they become
a chart track above the flamegraph -- a running plot alongside the spans.
tr.counter('frameTimeMs', 14.3);
tr.counter('activeSignals', graph.count);
tr.counter('drawCalls', gl.stats.draws);Counters don't appear in forEach or summary (both are span-only). Reach them via
forEachRaw if you want to render them yourself.
Instants (instant)
Point-in-time markers with no duration; Chrome Trace i events. Optional args.
tr.instant('gc-pause', { reason: 'scavenge', pauseMs: 1.4 });
tr.instant('input', { key: 'w' });Rendered as vertical lines in Perfetto and in the demo's flamegraph canvas.
Async events (beginAsync / endAsync)
Operations that cross frame boundaries -- asset loads, streaming queries, WebSocket
round-trips. beginAsync returns a monotonic integer asyncId; pass it back to endAsync
when the operation completes.
const id = tr.beginAsync('asset-load', { url: 'level.bin' });
// ... later, possibly many frames later ...
tr.endAsync(id, { status: 'ok', bytes: 82531 });The id is a plain number, not an object, so nothing allocates for the correlation. A
mismatched endAsync produces garbage in Perfetto but never throws -- this is visual
debugging, not runtime validation.
forEachRaw -- every entry
forEach walks spans only. forEachRaw walks every entry (span, counter, instant, async
begin/end) in record order, and hands you the type, args, asyncId, and counterVal
alongside the usual fields.
tr.forEachRaw((id, tag, start, end, type, depth, parent, args, asyncId, counterVal) => {
// type: 0=span, 1=instant, 2=counter, 3=asyncBegin, 4=asyncEnd
});Chrome trace metadata
toChromeTrace({ metadata: true }) prepends M (process_name, thread_name) events using
options.name (default 'main'). Multi-track traces from several tracers become readable
in Perfetto without a custom viewer.
const tr = new Tracer(4096, { name: 'game-loop' });
// ...
const json = JSON.stringify(tr.toChromeTrace({ pid: 1, tid: 1, metadata: true }));Off by default so pre-1.1 consumers see identical output.
Balance and overflow
Every begin needs one end. Two rules keep that safe:
- On overflow in drop mode (more than
capacityspans), abeginstill returns -- as-1-- and still consumes oneend, so the open-stack never desyncs. Dropped spans are counted intr.overflowed. In ring mode overflow never happens; the oldest entry is overwritten instead. - A stray
end()with nothing open is ignored, not an underflow.
capacity is rounded up to a power of two.
Testing
npm test # node --test37 tests across four files, driven by an injected deterministic clock. Covers begin/end
balance, nesting, self-time accounting, per-tag aggregation, tree() shape, toChromeTrace
microsecond output, overflow safety, stable tag ids across reset(), and every v1.1
surface: ring mode (overwrite + reset + mixed entry types), counters (recording, Chrome
trace, exclusion from forEach/summary), instants (recording, exclusion, args), args on
begin/span/tree, async monotonic ids and b/e pair emission, metadata events
(opt-in + off-by-default), and forEachRaw across all entry types.
The demo (demo/index.html) runs a live workload -- frame with nested update, render,
draw×N, post children -- while counters track frame time and span count, instants
mark simulated GC pauses, and async spans span frame boundaries. Export a Chrome trace and
drop it into Perfetto.
License
MIT (c) Zahary Shinikchiev
