The Observer Architecture
footprintjs observes execution along 4 orthogonal channels. Each has one purpose. Together they reconstruct the full causal trace of a run.
This is the architectural overview. For task-focused guides on attaching recorders see Observing with recorders and Recorder storage primitives.
The shape
Section titled “The shape”┌──────────────────────────────────────────────────────────────────────┐│ FlowChartExecutor ││ ││ traversal proceeds → fires events on 4 channels → ││ ││ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ Structure │ │ Scope │ │ Flow │ │ Emit │ ││ │ (build-time)│ │ (data ops) │ │(control flow)│ │(custom evts)│ ││ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ ││ │ │ │ │ ││ └────────────────┴────────────────┴────────────────┘ ││ dispatchers fan out ││ │ │└──────────────────────────────────┼───────────────────────────────────┘ ▼ ┌──────────────────────────────────┐ │ consumer recorders │ │ (sync handlers, error-isolated) │ └──────────────────────────────────┘Every event carries the same correlation key — runtimeStageId — in format [subflowPath/]stageId#executionIndex.
Channel 1 — Structure (build-time chart shape)
Section titled “Channel 1 — Structure (build-time chart shape)”Fires synchronously during flowChart(...).addFunction(...).build(). Not at runtime.
| Event | Fires on |
|---|---|
onStageAdded |
every spec node added |
onEdgeAdded |
every outgoing edge wired |
onLoopEdgeAdded |
every loopTo() back-edge |
onDeciderComplete |
every decider/selector .end() |
onSubflowMounted |
every subflow mount (with subflowSpec) |
import { flowChart, type StructureRecorder } from 'footprintjs';
const structureRec: StructureRecorder = { id: 'topology', onStageAdded(e) { /* every node */ }, onEdgeAdded(e) { /* every edge */ },};
flowChart('seed', () => {}, 'seed', { structureRecorders: [structureRec] }) .addFunction('a', () => {}, 'a') .build();Why this channel exists: chart shape is reference data. UIs render it. Audit systems hash it. Static analysis tools verify it. None of this needs to wait for execution.
The onSubflowMounted event carries the full subflow spec — parent recorders can materialize the entire chart tree in one pass via walkSubflowSpec from footprintjs/trace. No inner-builder attachment needed.
Channel 2 — Scope (data ops)
Section titled “Channel 2 — Scope (data ops)”Fires during stage execution. onRead and onWrite fire live, before the commit.
| Event | Fires on |
|---|---|
onStageStart |
stage begins |
onRead |
every shared-state read |
onWrite |
every shared-state write |
onStageEnd |
stage completes |
onCommit |
transaction flush |
onError |
stage threw |
Why this channel exists: gives you the data-level “stack trace”. When the rejection says 'high-risk', you want to know: which key was read, what value did it have, what threshold was checked, did it match. Logs answer “this happened.” Scope events answer “because X was Y.”
Channel 3 — Flow (control flow)
Section titled “Channel 3 — Flow (control flow)”Fires after stage execution (or after the control-flow decision for forks/deciders).
| Event | Fires on |
|---|---|
onStageExecuted |
every stage — event.stageType discriminates 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount' |
onNext |
linear continuation |
onDecision |
decider chose a branch |
onFork |
fork spawned children |
onSelected |
selector picked a subset |
onSubflowEntry / onSubflowExit |
subflow boundary |
onSubflowRegistered |
dynamic subflow attached at runtime |
onLoop |
loop iteration |
onBreak |
$break() called |
onError |
stage threw |
onPause / onResume |
pause/resume signal |
onRunStart / onRunEnd |
top-level run boundary (clean) |
onRunFailed |
top-level run terminated by error (terminal counterpart to onRunEnd) |
Why this channel exists: data alone doesn’t tell you the path. You need to know which branch the engine took, which child it forked, how many loop iterations ran. The shape of the path is part of the answer.
Channel 4 — Emit (consumer-defined events)
Section titled “Channel 4 — Emit (consumer-defined events)”Fires synchronously when consumer code calls scope.$emit(name, payload).
scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });// → fires onEmit({// name: 'myapp.llm.tokens',// payload: { input: 100, output: 50 },// stageName, runtimeStageId, subflowPath, timestamp,// })Why this channel exists: lets consumers send domain-specific events (token counts, retry attempts, custom metrics) that flow through the same fan-out as built-in events — but with a hierarchical name (myapp.billing.spend) so downstream tools can route by namespace.
The traversal animation
Section titled “The traversal animation”How the channels fire as one stage runs:
┌─────────────────────────────────┐ t = 0 ●────────►│ FlowRecorder.onRunStart │ └─────────────────────────────────┘ │ ┌─────────────────────────────────┐ t = 1 ●────────►│ Recorder.onStageStart('seed') │ ─┐ └─────────────────────────────────┘ │ │ │ ┌─────────────────────────────────┐ │ t = 2 ●────────►│ Recorder.onRead('input') │ │ live └─────────────────────────────────┘ │ scope ops │ │ for 'seed' ┌─────────────────────────────────┐ │ t = 3 ●────────►│ Recorder.onWrite('result', x) │ │ └─────────────────────────────────┘ ─┘ │ ┌─────────────────────────────────┐ t = 4 ●────────►│ Recorder.onStageEnd('seed') │ └─────────────────────────────────┘ │ ┌─────────────────────────────────┐ t = 5 ●────────►│ Recorder.onCommit │ writes committed └─────────────────────────────────┘ │ ┌─────────────────────────────────┐ t = 6 ●────────►│ FlowRecorder.onStageExecuted │ stageType: 'linear' │ ('seed', stageType: 'linear') │ └─────────────────────────────────┘ │ ┌─────────────────────────────────┐ t = 7 ●────────►│ FlowRecorder.onNext │ │ ('seed' → 'classify') │ └─────────────────────────────────┘ │ . . . traversal continues with the next stage │ ▼ ┌─────────────────────────────────┐ │ FlowRecorder.onRunEnd │ └─────────────────────────────────┘Every event flows through its dispatcher to every attached recorder. Errors thrown by a recorder are caught and isolated — one misbehaving consumer can’t bring down the engine or block the next event.
The universal correlation key
Section titled “The universal correlation key” runtimeStageId ┌───────────────────────────────────────────────┐ │ [subflowPath/]stageId#executionIndex │ │ │ │ examples: │ │ seed#0 │ │ call-llm#5 │ │ sf-tools/execute-tool-calls#8 │ │ sf-outer/sf-inner/validate#3 │ └─────────────────────┬─────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ scope events flow events commit log entries (all 4 channels) (all 4 channels) (write history) └─────────── same key ──────────────────┘No correlation IDs to weave by hand. No timestamps to align across services. The engine assigns it once, threads it through every event.
import { parseRuntimeStageId, splitStageId } from 'foottrace';
parseRuntimeStageId('sf-tools/call-llm#5');// → { stageId: 'call-llm', executionIndex: 5, subflowPath: 'sf-tools' }
splitStageId('sf-tools/call-llm'); // bare prefixed id (no #N)// → { localStageId: 'call-llm', subflowPath: 'sf-tools' }Live vs Offline — the consumer’s choice
Section titled “Live vs Offline — the consumer’s choice”The same recorder API works in two consumption modes. You pick based on what you’re trying to do.
LIVE — real-time BE → FE
Section titled “LIVE — real-time BE → FE”┌──────────────────────┐ ┌──────────────────────┐│ Backend executor │ ──► WebSocket ──►│ Frontend dashboard ││ (running pipeline) │ events │ (lights up live) │└──────────────────────┘ └──────────────────────┘- Slow handler blocks the engine — handlers MUST be sync and fast
- Events fire in execution order
- Real-time dashboards, interactive debugging, live support tools
const liveRec: FlowRecorder = { id: 'live-stream', onStageExecuted(e) { ws.send({ kind: 'stage', data: e }); }, onDecision(e) { ws.send({ kind: 'decision', data: e }); }, onError(e) { ws.send({ kind: 'error', data: e }); },};executor.attachFlowRecorder(liveRec);await executor.run({ input });OFFLINE — capture, ship, replay
Section titled “OFFLINE — capture, ship, replay”┌──────────────────────┐ ┌──────────────────────┐│ Process A (capture) │ ── JSON ────────►│ Process B (replay) ││ ───────── │ trace.json │ ───────── ││ await run() │ ──────────────► │ load trace ││ snapshot = ... │ S3 / SQS / │ render in UI ││ narrative = ... │ data warehouse │ run analytics ││ spec = chart.buildT │ │ generate audit │└──────────────────────┘ └──────────────────────┘- Returns fast — request handler unblocked
- Loses reference equality (deep copies via JSON)
- Long-term storage, post-mortem debugging, Lambda → Lambda fan-out
// Process A — captureconst executor = new FlowChartExecutor(chart);executor.enableNarrative();await executor.run({ input });
const wire = JSON.stringify({ spec: chart.buildTimeStructure, snapshot: executor.getSnapshot(), narrative: executor.getNarrativeEntries(),});await s3.putObject({ Bucket: 'traces', Key: `${runId}.json`, Body: wire });return response;
// Process B — replayconst trace = JSON.parse(await fs.readFile('trace.json'));// → render in <TracedFlow>, feed to analytics, anomaly detection, …See examples/runtime-features/snapshot-replay/ for a runnable end-to-end demo of the offline mode using existing primitives.
ID contract
Section titled “ID contract”Every attach*Recorder method (attachScopeRecorder, attachFlowRecorder, attachCombinedRecorder, attachEmitRecorder, and the builder’s attachStructureRecorder) is idempotent by ID: same ID replaces, different IDs coexist. Prevents accidental double-counting; multiple instances with different configs can coexist naturally.
CombinedRecorder is a union shape — implement methods from any subset of channels in one object. The runtime detects which methods exist (own properties and class-prototype methods; methods inherited from Object.prototype are ignored) and routes events accordingly. One attachCombinedRecorder(r) call.
Why 4 channels and not 1
Section titled “Why 4 channels and not 1”The first instinct is “just one observer with a bunch of methods.” We tried that. It collapses two real distinctions consumers depend on:
-
Build-time vs runtime. Structure events fire during construction. They have no
runtimeStageId, noiteration, notraversalContext. A unified interface would force every Structure event to ship a fake “runtime” field, or every Flow event to ship a fake “structure” field. A topology dashboard doesn’t want runtime data leaking in; a latency monitor doesn’t want build-time data confusing the picture. -
Data ops vs control flow. Scope events fire WHILE a stage runs (every read, every write). Flow events fire AFTER it completes. They have fundamentally different timing — Scope is hot-path, Flow is post-hoc. Merging them would force a single dispatcher to handle both, which means hot-path code paying for cold-path bookkeeping.
The four-channel separation matches log4j’s Logger / Appender / Layout separation: each channel has one invariant set, and consumers compose what they need.