trace/index.js

95 lines
3.4 KiB
JavaScript

'use strict';
/**
* @community/trace — dead-simple structured run logging + replay for AI agents.
*
* Agent debugging is "still in the stone age" because plain logs are flat,
* unstructured, and non-deterministic. `trace` gives you append-only structured
* events (JSONL) with runs, nested spans, and timings — greppable, replayable,
* and dependency-free. Point a sink at it live, or flush to a file and replay.
*/
const crypto = require('crypto');
const fs = require('fs');
const rid = () => crypto.randomBytes(6).toString('hex');
class Span {
constructor(tracer, name, data, parent) {
this.tracer = tracer;
this.name = name;
this.parent = parent || null;
this.id = rid();
this.startedAt = tracer.clock();
tracer._emit({ type: 'span:start', span: this.id, parent: this.parent, name, data });
}
event(type, name, data) { this.tracer._emit({ type, span: this.id, name, data }); return this; }
child(name, data) { return new Span(this.tracer, name, data, this.id); }
end(data) {
const ms = this.tracer.clock() - this.startedAt;
this.tracer._emit({ type: 'span:end', span: this.id, name: this.name, ms, data });
return ms;
}
}
class Tracer {
/** opts: { run?, sink?(event), clock?() } — clock is injectable for tests. */
constructor(opts = {}) {
this.run = opts.run || rid();
this.events = [];
this.sink = opts.sink || null;
this.clock = opts.clock || Date.now;
}
_emit(e) {
const rec = { ts: this.clock(), run: this.run, ...e };
this.events.push(rec);
if (typeof this.sink === 'function') { try { this.sink(rec); } catch { /* never let logging throw */ } }
return rec;
}
event(type, name, data) { return this._emit({ type, name, data }); }
step(name, data) { return this.event('step', name, data); }
error(name, data) { return this.event('error', name, data); }
start(name, data) { return new Span(this, name, data, null); }
/** Wrap an async fn in a span: auto-times, auto-captures a thrown error, re-throws. */
async span(name, fn, data) {
const s = this.start(name, data);
try {
const out = await fn(s);
s.end({ ok: true });
return out;
} catch (err) {
s.event('error', 'threw', { message: err && err.message });
s.end({ ok: false });
throw err;
}
}
toJSONL() { return this.events.map((e) => JSON.stringify(e)).join('\n') + (this.events.length ? '\n' : ''); }
flush(file) { fs.writeFileSync(file, this.toJSONL()); return file; }
}
/** Load events from a JSONL file path OR a raw JSONL string. */
function replay(source) {
let text = source;
try { if (typeof source === 'string' && fs.existsSync(source)) text = fs.readFileSync(source, 'utf8'); } catch { /* treat as text */ }
return String(text).split('\n').filter(Boolean).map((line) => JSON.parse(line));
}
/** One-glance health of a run: counts, total duration, slowest spans, errors. */
function summarize(events) {
const ends = events.filter((e) => e.type === 'span:end');
const errors = events.filter((e) => e.type === 'error');
const slowest = ends.slice().sort((a, b) => (b.ms || 0) - (a.ms || 0)).slice(0, 5)
.map((e) => ({ name: e.name, ms: e.ms }));
const total = events.length ? events[events.length - 1].ts - events[0].ts : 0;
return {
runs: new Set(events.map((e) => e.run)).size,
events: events.length,
spans: ends.length,
errors: errors.length,
total_ms: total,
slowest,
};
}
module.exports = { Tracer, Span, replay, summarize };