Skip to content

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
DAG JSON-LD registered with the dispatcher
{
  "@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 generated from the same DAG
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

bash
npm run docs:dev

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

vue
/**
 * 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).
  • placementPath ancestry. 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), WorkerObserver bridges events through an ObserverRelay back to the parent dispatcher's protected hooks. The placementPath starts 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.

Watched over by the Order of Dagon.