Example 18: Observability
What It Is
Observability is how an application turns Dagonizer execution into trace rows, metrics, progress panes, logs, or telemetry spans. The Dispatcher browser demo uses the hook surface to render a live support-flow trace beside the running DAG.
The integration point is intentionally narrow: subclass Dagonizer and override the protected lifecycle hooks. The graph stays the graph; observation lives at the runtime boundary.
How It Works
The dispatcher calls protected on* hooks around every execution boundary. A subclass translates those callbacks into domain events, UI state, or telemetry spans. Nested and contained execution includes placementPath, so applications can identify the full ancestry of an observed node even when embedded DAGs reuse display names.
This keeps observability out of node logic. Nodes return declared outputs; the runtime reports the flow around them.
Diagrams, Examples, and Outputs
DAG registration and diagram
The Dispatcher browser demo owns this principle directly: DispatcherBrowserObserver subclasses the engine observer surface and drives the trace feed plus Cytoscape DAG pane from lifecycle hooks.
support-dispatcher observable DAG
7 placements{
"@context": {
"@version": 1.1,
"name": {
"@id": "https://noocodec.dev/ontology/dag/name"
},
"version": {
"@id": "https://noocodec.dev/ontology/dag/version"
},
"entrypoints": {
"@id": "https://noocodec.dev/ontology/dag/entrypoints",
"@container": "@index"
},
"nodes": {
"@id": "https://noocodec.dev/ontology/dag/nodes",
"@container": "@set"
},
"outputs": {
"@id": "https://noocodec.dev/ontology/dag/outputs"
},
"node": {
"@id": "https://noocodec.dev/ontology/dag/node"
},
"dag": {
"@id": "https://noocodec.dev/ontology/dag/dag"
},
"body": {
"@id": "https://noocodec.dev/ontology/dag/body"
},
"source": {
"@id": "https://noocodec.dev/ontology/dag/source"
},
"sources": {
"@id": "https://noocodec.dev/ontology/dag/sources",
"@container": "@index"
},
"itemKey": {
"@id": "https://noocodec.dev/ontology/dag/itemKey"
},
"execution": {
"@id": "https://noocodec.dev/ontology/dag/execution"
},
"concurrency": {
"@id": "https://noocodec.dev/ontology/dag/concurrency"
},
"throttle": {
"@id": "https://noocodec.dev/ontology/dag/throttle"
},
"reservoir": {
"@id": "https://noocodec.dev/ontology/dag/reservoir"
},
"gather": {
"@id": "https://noocodec.dev/ontology/dag/gather"
},
"dagReference": {
"@id": "https://noocodec.dev/ontology/dag/dagReference",
"@type": "@id"
},
"DagReference": {
"@id": "https://noocodec.dev/ontology/dag/DagReference"
},
"from": {
"@id": "https://noocodec.dev/ontology/dag/from"
},
"path": {
"@id": "https://noocodec.dev/ontology/dag/path"
},
"candidates": {
"@id": "https://noocodec.dev/ontology/dag/candidates",
"@container": "@set"
},
"candidateDag": {
"@id": "https://noocodec.dev/ontology/dag/candidateDag",
"@type": "@id"
},
"selectedDag": {
"@id": "https://noocodec.dev/ontology/dag/selectedDag",
"@type": "@id"
},
"resultField": {
"@id": "https://noocodec.dev/ontology/dag/resultField"
},
"policy": {
"@id": "https://noocodec.dev/ontology/dag/policy"
},
"reducer": {
"@id": "https://noocodec.dev/ontology/dag/reducer"
},
"outcome": {
"@id": "https://noocodec.dev/ontology/dag/outcome"
},
"phase": {
"@id": "https://noocodec.dev/ontology/dag/phase"
},
"stateMapping": {
"@id": "https://noocodec.dev/ontology/dag/stateMapping"
},
"container": {
"@id": "https://noocodec.dev/ontology/dag/container"
},
"DAG": {
"@id": "https://noocodec.dev/ontology/dag/DAG"
},
"Placement": {
"@id": "https://noocodec.dev/ontology/dag/Placement"
},
"SingleNode": {
"@id": "https://noocodec.dev/ontology/dag/SingleNode"
},
"ScatterNode": {
"@id": "https://noocodec.dev/ontology/dag/ScatterNode"
},
"EmbeddedDAGNode": {
"@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
},
"GatherNode": {
"@id": "https://noocodec.dev/ontology/dag/GatherNode"
},
"TerminalNode": {
"@id": "https://noocodec.dev/ontology/dag/TerminalNode"
},
"PhaseNode": {
"@id": "https://noocodec.dev/ontology/dag/PhaseNode"
}
},
"@id": "urn:noocodec:dag:support-dispatcher",
"@type": "DAG",
"name": "dag:support-dispatcher",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:support-dispatcher/node/classify-message"
},
"nodes": [
{
"@id": "urn:noocodec:dag:support-dispatcher/node/setup",
"@type": "PhaseNode",
"name": "dag:support-dispatcher/node/setup",
"node": "urn:noocodec:node:dispatcher-setup",
"phase": "pre"
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/classify-message",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/classify-message",
"node": "urn:noocodec:node:classify-message",
"outputs": {
"routine": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"escalate": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"off-topic": "urn:noocodec:dag:support-dispatcher/node/decline"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/ai-compose",
"node": "urn:noocodec:node:ai-compose",
"outputs": {
"drafted": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/park-for-operator",
"node": "urn:noocodec:node:park-for-operator",
"outputs": {
"parked": "urn:noocodec:dag:support-dispatcher/node/end",
"ready": "urn:noocodec:dag:support-dispatcher/node/send-response"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/send-response",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/send-response",
"node": "urn:noocodec:node:send-response",
"outputs": {
"sent": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/decline",
"@type": "SingleNode",
"name": "dag:support-dispatcher/node/decline",
"node": "urn:noocodec:node:decline",
"outputs": {
"declined": "urn:noocodec:dag:support-dispatcher/node/end"
}
},
{
"@id": "urn:noocodec:dag:support-dispatcher/node/end",
"@type": "TerminalNode",
"name": "dag:support-dispatcher/node/end",
"outcome": "completed"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% dag:support-dispatcher (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_support-dispatcher/node/classify-message
urn_noocodec_dag_support-dispatcher/node/setup(["dag:support-dispatcher/node/setup (pre)"])
urn_noocodec_dag_support-dispatcher/node/classify-message["dag:support-dispatcher/node/classify-message"]
urn_noocodec_dag_support-dispatcher/node/classify-message -->|routine| urn_noocodec_dag_support-dispatcher/node/ai-compose
urn_noocodec_dag_support-dispatcher/node/classify-message -->|escalate| urn_noocodec_dag_support-dispatcher/node/park-for-operator
urn_noocodec_dag_support-dispatcher/node/classify-message -->|off-topic| urn_noocodec_dag_support-dispatcher/node/decline
urn_noocodec_dag_support-dispatcher/node/ai-compose["dag:support-dispatcher/node/ai-compose"]
urn_noocodec_dag_support-dispatcher/node/ai-compose -->|drafted| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/park-for-operator["dag:support-dispatcher/node/park-for-operator"]
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|parked| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|ready| urn_noocodec_dag_support-dispatcher/node/send-response
urn_noocodec_dag_support-dispatcher/node/send-response["dag:support-dispatcher/node/send-response"]
urn_noocodec_dag_support-dispatcher/node/send-response -->|sent| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/decline["dag:support-dispatcher/node/decline"]
urn_noocodec_dag_support-dispatcher/node/decline -->|declined| urn_noocodec_dag_support-dispatcher/node/end
urn_noocodec_dag_support-dispatcher/node/end((("dag:support-dispatcher/node/end")))Subclass hooks are the sole observability surface. Extend Dagonizer and override the protected hook methods — no extra objects, no plugin contract.
Run
npm run docs:devWhat It Lets You Do
Observability hooks let applications project the engine lifecycle without changing DAG topology. Use them when the app needs to see node starts, node ends, errors, phase hooks, nested placement paths, and final outcomes as they happen.
This is the right place to integrate UI progress, OpenTelemetry spans, audit logs, or per-node timing. It is not the right place to make routing decisions; routing still belongs in node outputs and DAG edges.
Code Samples
The Dispatcher runner snippet shows the browser observer subclass and how hook callbacks become trace entries in the runnable page.
/**
* DispatcherBrowserObserver: wires lifecycle hooks to the Vue reactive state
* (trace feed, DAG graph, conversation, awaiting-input tab auto-switch).
*/
class DispatcherBrowserObserver extends ObservedDag<DispatcherState> {
readonly #services: DispatcherServices;
constructor(log: DomConsoleLogger, services: DispatcherServices) {
super(log);
this.#services = services;
}
protected override onFlowStart(dagName: string, state: DispatcherState, signal: AbortSignal): void {
super.onFlowStart(dagName, state, signal);
void this.#services.llm.warm().catch(() => { /* best-effort: never block or fail a flow start */ });
}
protected override onNodeStart(
nodeName: string,
state: DispatcherState,
placementPath: readonly string[],
signal: AbortSignal,
): void {
super.onNodeStart(nodeName, state, placementPath, signal);
const fullId = [...placementPath, nodeName].join('/');
trace.value = [...trace.value, { 'node': fullId, 'ts': Date.now(), 'variant': 'start' }];
dagGraph.value?.setActive(fullId);
}
protected override onNodeEnd(
nodeName: string,
output: string | null,
state: DispatcherState,
placementPath: readonly string[],
signal: AbortSignal,
): void {
super.onNodeEnd(nodeName, output, state, placementPath, signal);
const fullId = [...placementPath, nodeName].join('/');
trace.value = [...trace.value, { 'node': fullId, output, 'ts': Date.now(), 'variant': 'end' }];
dagGraph.value?.setCompleted(fullId);
if (output !== null) dagGraph.value?.markEdgeTraversed(fullId, output);
DISPATCHER_NODE_TRACE[nodeName]?.(state, output);
}
protected override onError(
nodeName: string,
error: Error,
state: DispatcherState,
placementPath: readonly string[],
signal: AbortSignal,
): void {
super.onError(nodeName, error, state, placementPath, signal);
const fullId = [...placementPath, nodeName].join('/');
trace.value = [...trace.value, {
'node': fullId,
'ts': Date.now(),
'variant': 'error',
'message': error.message !== '' ? error.message : String(error),
}];
dagGraph.value?.setErrored(fullId);
}
protected override onFlowEnd(
dagName: string,
state: DispatcherState,
result: ExecutionResultType<DispatcherState>,
signal: AbortSignal,
): void {
super.onFlowEnd(dagName, state, result, signal);
const lifecycleVariant = state.lifecycle.variant;
// Update conversation from state.
if (state.conversation.length > 0) {
conversation.value = [...state.conversation];
}
escalationReason.value = state.escalationReason;
terminalVariant.value = (
lifecycleVariant === 'completed' ||
lifecycleVariant === 'failed' ||
lifecycleVariant === 'cancelled' ||
lifecycleVariant === 'timed_out'
) ? lifecycleVariant : 'pending';
if (lifecycleVariant === 'awaiting-input' && result.parked !== null) {
// Store the full result outside Vue reactivity (preserves class identity).
parkedExecution = { 'result': result, 'dagName': dagName, 'cursor': result.parked.cursor };
// Expose only the cursor to Vue so the template can react.
parked.value = { 'dagName': dagName, 'cursor': result.parked.cursor };
// Auto-switch left pane to Operator tab.
leftActiveKey.value = 'operator';
logger.note(`parked at cursor: ${result.parked.cursor} · key: ${result.parked.correlationKey}`);
} else {
parkedExecution = null;
parked.value = null;
}
logger.result(`lifecycle=${lifecycleVariant} · conversation=${String(state.conversation.length)} turns`);
}
}Details for Nerds
- Subclass hook surface. Override the protected lifecycle methods on
Dagonizer. All seven hooks receive strongly-typed state and placement context:onFlowStart(dagName, state),onFlowEnd(dagName, state, result),onNodeStart(nodeName, state, placementPath),onNodeEnd(nodeName, output, state, placementPath),onError(nodeName, error, state, placementPath),onPhaseEnter(dagName, phase, placementName, state, placementPath),onPhaseExit(dagName, phase, placementName, state, placementPath). placementPathancestry. Empty for top-level nodes; carries the ordered list of parent embedded-DAG placement identifiers for nested nodes. Use the placement path plus node display name for a human-readable trace label, and keep the placement IRI for durable identity.- Worker/container transparency. For nodes running in isolates (worker threads, child processes),
WorkerObserverbridges events through anObserverRelayback to the parent dispatcher's protected hooks. TheplacementPathstarts with the outer placement identifier so inner nodes are identifiable even when they share display names across placements. - Runnable visualization. The Dispatcher DAG pane and trace feed are populated from the same hooks shown above.
Related Concepts
- Observability guide - full hook reference and metrics patterns
- Example 20: Streaming - async-iterable execution API: per-node progress events
- Reference: Dagonizer