Skip to content

Observability

Most stacks make you assemble observability: pick a logger, thread a request id, wire spans, hope the pieces line up. Nwire's runtime emits one typed stream of everything domain-significant it does, and every record in a causal chain shares a correlation id. You don't build the trace — you read it.

One stream, every significant act

Each dispatch, event, reaction, query, timer, and external call becomes a typed telemetry record on a single tap:

ts
app.runtime.onTelemetry((rec) => {
  switch (rec.kind) {
    case "action.dispatched": metrics.inc("actions", { name: rec.action }); break;
    case "action.failed":     alerts.fire({ action: rec.action, err: rec.error }); break;
  }
});

That's the whole subscription surface. The telemetry kinds reference lists every record type and its fields; you switch on rec.kind and react.

Correlation is automatic

Every record carries an envelope with correlationId, causationId, and messageId. A handler that emits an event, whose listener dispatches a follow-up, produces a chain that all shares one correlationId:

registerUser dispatched          correlationId = C
└─ UserRegistered published      correlationId = C
   └─ welcome listener fired      correlationId = C
      └─ sendWelcomeEmail         correlationId = C

You didn't pass an id through five function calls. The envelope threads it for you — ctx.send and ctx.emit carry it forward — so the trace reconstructs the actual causal shape, not a flat pile of log lines.

Logs ride the same stream

Application logs aren't a separate channel. Every ctx.logger call — and the framework's own request, lifecycle, and error lines — writes to the configured driver and emits a log telemetry record, so a log shows up in Studio next to the dispatch that produced it.

The driver is pino, shaped by NODE_ENV: pretty and colorized in development, JSON lines in production. The templates wire it for you:

ts
import { resolveLogger } from "@nwire/logger-pino";

const app = createApp({ logger: resolveLogger({ service: "orders" }) });

Every HTTP request is logged once — method, path, status, duration, correlation id — at a level that tracks the status class. Set DEBUG=dispatch,hooks (or DEBUG=true) to surface the matching telemetry kinds to the console as well, for when you want the dispatch flow without opening Studio.

Seeing it live: Studio

Turn on the inspect plugin and the stream is served over HTTP for the bundled trace viewer:

ts
await endpoint("orders", { port: 3000 })
  .use(httpKoa({ inspect: true }))
  .mount(app)
  .run();

Studio reads that stream and shows a live, correlation-grouped view — a domain trace (this request caused that event caused that email), not a wall of spans. Its Logs page tails the log records — level, message, fields — filtered and searchable. The same /_nwire/* endpoints also serve recent telemetry and the OpenAPI spec, so the viewer needs no separate datastore.

Exporting to your own stack

The stream is yours to forward. The OpenTelemetry bridge translates it to OTLP spans and events for Datadog, Honeycomb, Tempo, or GreptimeDB:

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

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

The correlation ids become real span parent/child links, so the same causal tree you see in Studio shows up in your tracing backend.

See also

MIT licensed.