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):
| Kind | Fires when |
|---|---|
action.dispatched | An action enters the runtime — handler starts |
action.completed | Handler returned successfully + events committed |
action.failed | Handler threw (one record per retry attempt) |
event.published | An event was applied to actors + projections + reactions |
actor.transitioned | An actor moved between states (after commit) |
projection.folded | A projection consumed an event |
reaction.fired | A when() reaction completed successfully |
reaction.failed | A when() reaction threw |
query.executed | runtime.query(...) was called |
timer.scheduled | An actor state's after block scheduled a timer |
timer.fired | fireDueTimers() triggered an action |
dlq.recorded | Retries exhausted; entry recorded to the DLQ |
Orchestrator (9):
| Kind | Fires when |
|---|---|
external.call.started | ctx.externalCall(...) invoked (per attempt) |
external.call.completed | External transport returned (success) |
external.call.failed | External transport threw (per attempt) |
inbound.webhook.received | HTTP wire handled a defineInboundWebhook request |
outbox.flushed | A batch of outbox events was flushed |
inbox.dedup.hit | An incoming message was deduped |
queue.job.enqueued / .started / .completed | Queue worker lifecycle |
cron.fired | A 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.messageIdStudio'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 specOpenTelemetry 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
- Telemetry kinds — full reference
- Studio — consumes this stream