Skip to content

Observability

Dagonizer exposes five protected lifecycle hooks. Subclass the dispatcher and override any or all of them to attach metrics, structured logging, or distributed tracing.

The five hooks

ts
import { Dagonizer } from '@noocodex/dagonizer';
import type { ExecutionResultInterface } from '@noocodex/dagonizer';

class ObservableDispatcher<TState> extends Dagonizer<TState> {
  protected override onFlowStart(dagName: string, state: TState): void {
    console.log(`[flow:start] ${dagName}`);
  }

  protected override onFlowEnd(
    dagName: string,
    state: TState,
    result: ExecutionResultInterface<TState>,
  ): void {
    const lc = (state as any).lifecycle;
    console.log(`[flow:end] ${dagName} kind=${lc?.kind} cursor=${result.cursor}`);
  }

  protected override onNodeStart(nodeName: string, state: TState): void {
    console.log(`[node:start] ${nodeName}`);
  }

  protected override onNodeEnd(
    nodeName: string,
    output: string | undefined,
    state: TState,
  ): void {
    console.log(`[node:end] ${nodeName} output=${output}`);
  }

  protected override onError(nodeName: string, error: Error, state: TState): void {
    console.error(`[error] ${nodeName}: ${error.message}`);
  }
}

All five default to no-ops. Override only what you need — the base class provides no behavior.

Class extension is the only extension mechanism. There is no callback API. Multi-observer composition (logger + tracer + metrics) is a subclass concern — write it into your subclass.

Hook contracts

HookWhen calledArguments
onFlowStartAfter state.markRunning(), before the first nodedagName, state
onFlowEndAfter the last node (including aborted/failed paths)dagName, state, result
onNodeStartBefore node.execute() for each node entrynodeName, state
onNodeEndAfter each node resolves, before yieldnodeName, output | undefined, state
onErrorWhen a signal fires or a node throwsnodeName, error, state

onFlowEnd is always called — even when the flow fails or is cancelled. onError may fire before onFlowEnd in the same execution.

For parallel and fan-out nodes, onNodeStart / onNodeEnd fire once for the group entry (the containing parallel or fan-out node), not once per constituent node.

Structured logging example

ts
import { Dagonizer, NodeStateBase } from '@noocodex/dagonizer';
import type { ExecutionResultInterface } from '@noocodex/dagonizer';

interface Span {
  name: string;
  start: number;
  end?: number;
  output?: string;
}

class TracingDispatcher<TState extends NodeStateBase> extends Dagonizer<TState> {
  readonly spans: Span[] = [];

  protected override onNodeStart(nodeName: string): void {
    this.spans.push({ name: nodeName, start: Date.now() });
  }

  protected override onNodeEnd(nodeName: string, output: string | undefined): void {
    const span = this.spans.find((s) => s.name === nodeName && s.end === undefined);
    if (span) {
      span.end = Date.now();
      span.output = output;
    }
  }
}

const dispatcher = new TracingDispatcher<MyState>();
// ...register and execute...
console.table(dispatcher.spans);

OpenTelemetry sketch

ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { Dagonizer } from '@noocodex/dagonizer';
import type { ExecutionResultInterface } from '@noocodex/dagonizer';

const tracer = trace.getTracer('dagonizer');

class OtelDispatcher<TState> extends Dagonizer<TState> {
  #spans = new Map<string, ReturnType<typeof tracer.startSpan>>();

  protected override onFlowStart(dagName: string): void {
    const span = tracer.startSpan(`flow.${dagName}`);
    this.#spans.set(dagName, span);
  }

  protected override onFlowEnd(dagName: string): void {
    this.#spans.get(dagName)?.end();
    this.#spans.delete(dagName);
  }

  protected override onNodeStart(nodeName: string): void {
    const span = tracer.startSpan(`node.${nodeName}`);
    this.#spans.set(nodeName, span);
  }

  protected override onNodeEnd(nodeName: string, output?: string): void {
    const span = this.#spans.get(nodeName);
    if (span) {
      span.setAttribute('output', output ?? '');
      span.end();
      this.#spans.delete(nodeName);
    }
  }

  protected override onError(nodeName: string, error: Error): void {
    const span = this.#spans.get(nodeName);
    if (span) {
      span.recordException(error);
      span.setStatus({ code: SpanStatusCode.ERROR });
    }
  }
}

Watched over by the Order of Dagon.