Skip to content

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.


┌──────────────────────────────────────────────────────────────────────┐
│ 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.


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.”


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.


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.


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.

┌──────────────────────┐ ┌──────────────────────┐
│ 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 });
┌──────────────────────┐ ┌──────────────────────┐
│ 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 — capture
const 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 — replay
const 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.


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.


The first instinct is “just one observer with a bunch of methods.” We tried that. It collapses two real distinctions consumers depend on:

  1. Build-time vs runtime. Structure events fire during construction. They have no runtimeStageId, no iteration, no traversalContext. 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.

  2. 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.