@escouade/graph-trace
v0.2.0
Published
Structured tracing model for graph agent runs.
Maintainers
Readme
@escouade/graph-trace
Structured tracing for graph agent runs — capture an LLM/tool run as a flat event stream, project it into a self-contained, versioned trace document, and export it anywhere.
- Runtime-agnostic core — zero framework dependency. Events flow through a
TraceRecorderport; any engine (or an adapter like@escouade/graph-trace-langchain) can feed it. - Pipeline provider → collector → exporter — a
TraceCollectorowns the exporters; every finished run is projected toTraceDocumentand exported automatically. - Step projection isomorphic to graph traversal — each step = one pass through a graph node, carrying its events and the real edge taken on exit. Loops are preserved, not collapsed.
- Portable document format (
graph-trace/v1) — exportable, re-importable, and consolidatable run after run.
Install
npm install @escouade/graph-traceZero runtime dependencies (other than tslib). Ships ESM + type declarations.
Concepts
| Piece | Role |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TraceRecorder | Port a provider feeds: record(event, span), succeed(), fail(err). |
| RunTrace | In-memory ledger of a single run; self-stamps the run.start/run.end envelope. |
| TraceCollector | Reusable pipeline: opens runs, projects each to TraceDocument on close, hands it to exporters. |
| TraceExporter | Terminal sink for a TraceDocument (the assembled run). @escouade/graph-trace-file-exporter ships a Node.js file exporter; bring your own for HTTP/console. |
| TraceStreamSink | Live sink fed each record as it happens (record({ event, span })), for relaying a run event-by-event. Additive to TraceExporter — see Streaming records live. |
| WorkflowDefinition | Declarative topology (nodes + edges) your host writes once, fused with its own execution fields. toWorkflowDescriptor strips it to the trace-facing shape embedded in every run. |
| TraceWorkflowDescriptor | Trace-facing topology (id / kind / label per node, from/to/condition/loop per edge) embedded in run.start and drawn by the viewer. |
| TraceDocument | The versioned, self-contained format a conversation's trace is exported as. |
Quick start
Wire a collector once, then open a trace per run and feed it events.
import { TraceCollector } from '@escouade/graph-trace';
import { FileExporter } from '@escouade/graph-trace-file-exporter';
// Singleton pipeline — projects every run to TraceDocument and exports it.
const collector = new TraceCollector({
exporters: [
new FileExporter({
resolvePath: (doc) => `./traces/${doc.meta.traceId}.trace.json`,
}),
],
// traceId: 'my-session' // optional: override the auto-generated trace id
});
// Per run: `run.start` is posed from this metadata.
const trace = collector.createTrace({
provider: 'anthropic',
model: 'claude-sonnet-4-6',
workflow, // a TraceWorkflowDescriptor (see below)
systemPrompt: 'You are…',
context: '',
history: [{ role: 'user', content: 'Hello' }],
tags: { agent: 'assistant' }, // optional free-form metadata
});
// Feed events through the recorder port (or let an adapter do it — see graph-trace-langchain).
trace.record(
{ type: 'llm.start', messages: [{ role: 'user', content: 'Hello' }], tools: [] },
{ node: 'agent', spanId: 'span-1' },
);
trace.record(
{ type: 'llm.end', text: 'Hi!', toolCalls: [], usage: { input_tokens: 8, output_tokens: 3 } },
{ node: 'agent', spanId: 'span-1' },
);
trace.succeed(); // closes the run → projected + buffered
// Drain the buffer at graceful shutdown:
await collector.flush();Tracing a LangGraph run
You rarely feed events by hand. With LangGraph (or any LangChain runnable), install
@escouade/graph-trace-langchain and attach the adapter
to the run — it captures LLM turns and tools, attributes each event to its langgraph_node, and closes
the run from the root chain end/error.
import { TraceCollector, toWorkflowDescriptor } from '@escouade/graph-trace';
import { FileExporter } from '@escouade/graph-trace-file-exporter';
import { langChainTraceHandler } from '@escouade/graph-trace-langchain';
const collector = new TraceCollector({
exporters: [new FileExporter({ resolvePath: (doc) => `./traces/${doc.meta.traceId}.json` })],
});
// At app shutdown — drain all buffered runs:
process.on('SIGTERM', async () => {
await collector.shutdown();
process.exit(0);
});
const trace = collector.createTrace({
provider: 'anthropic', // known at LLM client init, not in input
model: 'claude-sonnet-4-6', // idem
workflow: toWorkflowDescriptor(def),
systemPrompt: input.systemPrompt,
context: input.context ?? '',
history: input.messages, // your graph state's message list
});
try {
await graph.invoke(input, { callbacks: [langChainTraceHandler(trace)] });
} catch (err) {
trace.fail(err); // safety net if the error was thrown before any chain ran
throw err;
}Tagging runs
Pass a tags object to createTrace to attach free-form metadata to a run. Tags flow through to
TraceTurn.tags in the projected document — the viewer and your tooling can read them.
const trace = collector.createTrace({
// …
tags: {
serviceName: 'my-service', // identifies the originating service
env: 'production',
agent: 'planner',
},
});
// Read them back from a projected turn:
for (const turn of doc.turns) {
console.log(turn.tags?.serviceName); // 'my-service'
}Tags are string → string. For structured values, serialize to JSON before passing.
Individual events can carry their own tags via record() — useful for correlation ids or
event-level context:
trace.record(
{ type: 'tool.start', name: 'search', input: query, tags: { correlationId: req.id } },
{ node: 'tools' },
);Per-event tags are accessible in step.events[n].tags in the projected document.
Describing the graph
A run carries a TraceWorkflowDescriptor (nodes + edges) so a viewer can draw the executed topology.
Build one from a declarative WorkflowDefinition to keep a single source of truth — the same
object is both runnable by your engine and traceable by the library:
import { toWorkflowDescriptor, type WorkflowDefinition } from '@escouade/graph-trace';
const def: WorkflowDefinition = {
id: 'agent-loop',
label: 'Agent loop',
entry: 'agent', // first node executed (draws the implicit START → agent edge)
nodes: [
{ id: 'agent', kind: 'agent', label: 'Agent' }, // `kind` is a free string — viewer uses it for icons
{ id: 'tools', kind: 'tools', label: 'Tools' },
],
edges: [
{ from: 'agent', to: 'tools', condition: 'tool calls' }, // `condition` is a display label, not a predicate
{ from: 'agent', to: 'END', condition: 'no tool calls' },
{ from: 'tools', to: 'agent', condition: 'continue', loop: true }, // `loop: true` draws a back-arc
],
};
const workflow = toWorkflowDescriptor(def); // strips execution fields, keeps trace shapeWorkflowDefinition is generic (<TNode extends TraceNode, TEdge extends TraceEdge>): fuse it with
your engine's execution augmentation (node factory, routing predicate) so the same object builds
your graph and produces the trace descriptor — they can never diverge.
See docs/workflow-definition.md for the field reference and docs/fused-workflow-definition.md for the fusion pattern.
Collector options and run ids
const collector = new TraceCollector({
projector: projectTraceDocument,
exporters: [...],
traceId: 'my-session', // optional: override the auto-generated trace id (e.g. to resume a persisted session)
});
// collector.traceId — the active trace id (auto-generated or the one you passed)
const trace = collector.createTrace(meta, {
runId: externalCorrelationId, // optional: override the auto-generated run id
});TraceCollector implements the RunTracePipeline interface — use that type when injecting the
collector into your orchestration layer to avoid coupling to the concrete class.
import type { RunTracePipeline } from '@escouade/graph-trace';
class Orchestrator {
constructor(private readonly pipeline: RunTracePipeline) {}
}Streaming records live
The exporters above fire once per run, at run.end, with the whole assembled TraceDocument.
To relay a run as it happens — event by event — attach a TraceStreamSink to the run. The
recorder pushes every record to it at the moment it is captured, in emission order (run.start →
llm.*/tool.* → run.end included). This is additive: with no sink, tracing behaves exactly as
before; the terminal exporters still fire on run.end.
import type { TraceStreamSink } from '@escouade/graph-trace';
// A downstream relay: forward each record over your transport (SSE, WebSocket, queue…).
const relay: TraceStreamSink = {
record({ event, span }) {
// Synchronous and non-blocking — do not await here. Serialize (e.g. JSONL) and enqueue.
channel.post(JSON.stringify({ event, span }));
},
};
// Scoped to the run — the relay naturally follows the run/turn it streams.
const trace = collector.createTrace(meta, { streamSink: relay });record(entry) receives a TraceStreamEntry = { event, span }: the fully stamped TraceEvent
(ids/seq/ts already set) and the TraceSpan it was recorded with. A sink must not block or throw —
any async delivery is the consumer's responsibility.
Reading a trace
projectTraceDocument(events, label?) turns a flat TraceEvent[] into a TraceDocument (turns →
steps, aggregated tokens/duration/errors). parseTraceDocument(raw) validates a re-imported file
and throws if traceFormat is not 'graph-trace/v1' (exposed as TRACE_FORMAT);
mergeTraceDocuments(base, addition) consolidates runs.
From a JSONL stream
Records relayed through a TraceStreamSink and serialized one-per-line (JSONL) fold back into a
TraceDocument — equivalent to what the terminal exporter would have produced for the same runs.
parseTraceJsonl(text) splits the lines, JSON-parses each { event, span } record and delegates to
foldTraceRecords(entries), which runs the projection over the flattened events. Multiple runs in the
input yield multiple turns. Both are best-effort: a malformed or truncated line (an unterminated
final run, a cut last line, a stray non-JSON line) is skipped, keeping the complete runs.
import { parseTraceJsonl } from '@escouade/graph-trace';
const doc = parseTraceJsonl(await fs.readFile('conv-1.jsonl', 'utf8'));
// doc.turns — one per run, ready to render like any TraceDocumentimport { parseTraceDocument, TRACE_FORMAT } from '@escouade/graph-trace';
const doc = parseTraceDocument(await fs.readFile('conv-1.trace.json', 'utf8'));
// doc.traceFormat === TRACE_FORMAT ('graph-trace/v1')
for (const turn of doc.turns) {
console.log(
turn.userMessage.content,
turn.steps.map((s) => s.node),
);
}projectTurns(events) is the lower-level primitive: projects a flat event stream into
TraceTurn[] without the conversation-level envelope. Useful when you don't need the full
TraceDocument (e.g. streaming partial results to a viewer).
Events
A run is a flat, ordered stream of discriminated events: run.start, llm.start, llm.end,
tool.start, tool.end, tool.error, run.end. Each is stamped with runId/seq/ts and an
optional span (node/spanId/parentSpanId). Secrets (API keys, provider config) never appear in a
trace by construction.
Resources
- Workflow definition — field reference
- Fused workflow definition — single source of truth pattern
- Document format reference —
graph-trace/v1 - Writing a provider adapter
- File exporter (Node.js) →
@escouade/graph-trace-file-exporter - LangChain / LangGraph adapter →
@escouade/graph-trace-langchain
License
MIT
