95 lines
3.4 KiB
JavaScript
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 };
|