Skip to content

Telemetry

The canonical lifecycle stream. Every domain-significant thing the runtime does flows through one tap as a typed record.

Subscribe

ts
app.runtime.onTelemetry((rec) => {
  // rec.kind: 22 different kinds (see below)
  switch (rec.kind) {
    case "action.dispatched":   metrics.inc("actions", { name: rec.action }); break;
    case "external.call.failed": alerts.fire({ call: rec.call, err: rec.error }); break;
  }
});

Kinds

Domain (13):

KindFires when
action.dispatchedAn action enters the runtime — handler starts
action.completedHandler returned successfully + events committed
action.failedHandler threw (one record per retry attempt)
event.publishedAn event was applied to actors + projections + reactions
actor.transitionedAn actor moved between states (after commit)
projection.foldedA projection consumed an event
reaction.firedA when() reaction completed successfully
reaction.failedA when() reaction threw
query.executedruntime.query(...) was called
timer.scheduledAn actor state's after block scheduled a timer
timer.firedfireDueTimers() triggered an action
dlq.recordedRetries exhausted; entry recorded to the DLQ

Orchestrator (9):

KindFires when
external.call.startedctx.externalCall(...) invoked (per attempt)
external.call.completedExternal transport returned (success)
external.call.failedExternal transport threw (per attempt)
inbound.webhook.receivedHTTP wire handled a defineInboundWebhook request
outbox.flushedA batch of outbox events was flushed
inbox.dedup.hitAn incoming message was deduped
queue.job.enqueued / .started / .completedQueue worker lifecycle
cron.firedA scheduled action ran

Record shape

Every record carries:

ts
{
  kind: "…",
  appName: "learnflow",
  ts: "2026-05-12T10:24:31.128Z",
  // Domain records additionally carry:
  envelope: {
    messageId, correlationId, causationId,
    tenant?, userId?, timestamp, version,
  },
  // Plus kind-specific fields (action, durationMs, error, etc.)
}

Correlation

Every record in a single causal chain shares one correlationId. The chain is:

Action A dispatched         envelope.correlationId = C
└─ Event E1 published       envelope.correlationId = C  envelope.causationId = A.messageId
   └─ Reaction R fired      envelope.correlationId = C
      └─ Action A2 dispatched   envelope.correlationId = C
         └─ Event E2 published  envelope.correlationId = C  envelope.causationId = A2.messageId

Studio's correlation trace tree reconstructs this from causationId / messageId pairs.

Inspect endpoints

When the wire is started with httpInterface({ inspect: true }):

GET /_nwire/manifest            # apps + modules
GET /_nwire/telemetry/recent    # ring buffer (last 500 records)
GET /_nwire/telemetry/stream    # SSE — full canonical stream
GET /_nwire/events/recent       # back-compat: only kind==event.published
GET /_nwire/events/stream       # back-compat SSE
GET /_nwire/actors/:name/:id    # actor state + active timers
GET /_nwire/projections/:name   # projection state
GET /_nwire/dlq                 # dead-letter entries
POST /_nwire/dispatch           # invoke any action
GET /_nwire/openapi.json        # OpenAPI 3.1 spec

OpenTelemetry export

Opt-in bridge — translates the stream to OTLP spans + events. Pairs with Datadog, Honeycomb, Tempo, Vector + GreptimeDB:

ts
import { attachOtelExporter } from "@nwire/telemetry-otel";
import { trace } from "@opentelemetry/api";

attachOtelExporter(app.runtime, { tracer: trace.getTracer("amit") });

See the OpenTelemetry export guide.

See also

MIT licensed.