Skip to content

@btravstack/observability

Reference. A complete, structured description of @btravstack/observability: the Logger port's implementation, the default implementation, the Line/Sink contract, the two sinks, the starter and the LOG_LEVEL field, and the kernel-event adapter. For the task, see Log and correlate; for the generated signatures, see the API reference.

Logs, traces and metrics. The package is named for the whole of observability because the three share a correlation id, a resource, a configuration slice and a flush-on-shutdown lifecycle — splitting them across two packages would duplicate all four. Logging is the root entry point; tracing and metrics ride @btravstack/observability/otel, whose OpenTelemetry peers a consumer that never imports it never installs.

Install

sh
pnpm add @btravstack/observability @btravstack/core @btravstack/config @btravstack/di unthrown

Those four are peers. pino is an optional peer, needed only if you import the @btravstack/observability/pino subpath:

sh
pnpm add pino

The package itself has no runtime dependencies: the default sink is JSON.stringify and a write, for the same reason Config is a hand-rolled Standard Schema.

Logger and LoggerService

These are @btravstack/core's, not this package's — import them from the kernel. They are described here too because this is where the reader looking for a logger arrives, and /reference/core/observability is their one detailed home. Everything below is what this package does with them.

ts
class Logger extends Port("Logger")<LoggerService> {}

type LoggerService = {
  readonly log: (
    level: Level,
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly trace: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly debug: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly info: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly warn: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly error: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly fatal: (
    message: string,
    attributes?: Attributes,
    cause?: unknown,
  ) => void;
  readonly with: (attributes: Attributes) => LoggerService;
  readonly isEnabled: (level: Level) => boolean;
};

Logger is a di port like any other: a provider binds it, a dependency array names it, a test provides its own. It is the framework's port rather than each application's, because the framework itself logs — kernelEvents below — and an application-declared port could not serve both.

Every method takes the same three arguments in the same order, and every level can carry a failure. The first draft did not: error(message, cause, attributes) read better at the one call site that always has a cause, and it cost twice — a caller had to remember which arm it was in, and warn had nowhere to put a cause, so a retryable failure (a broker refusing a publish, which the next sweep takes) was logged at error purely to keep its reason. A failure is not a property of severity. The cost of uniformity is logger.error("boom", undefined, cause) for a failure with nothing else to say, which is rare: a line worth writing almost always has an id to write with it.

Every method returns void and none of them is an AsyncResult. A log call is fire-and-forget by definition — a caller who awaited it would be waiting on I/O to decide nothing — and this is the package's exemption from the rule that every async surface returns a Result.

Why the interface is strict

Each row is a defect this shape does not have, and together they are the package's whole argument:

DecisionWhat it rules out
A port, never a class you newA static instance, a useLogger reaching past DI, a global a test cannot replace
with(attributes) returns a new loggersetContext mutating the instance every caller shares, so two scopes interleave each other's context
Attributes is a flat record of scalarsany varargs, printf, and a logger that stringifies whatever it is handed — which is how a log call throws
A failure goes in causeJSON.stringify(error) rendering {}: an Error's message and stack are non-enumerable
It cannot throwAn observability fault becoming an outage; createLogger swallows a broken sink
Six levels, fixedLOG_LEVEL validated against a set at startup, and isEnabled a comparison rather than a lookup
Correlation is the implementation's jobA trace id threaded through every signature to reach the one place that writes it out

Level, LEVELS and Attributes

Also the kernel's, and imported from there.

ts
type Level = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
const LEVELS: readonly Level[];

type Attributes = Readonly<
  Record<string, string | number | boolean | undefined>
>;

LEVELS is the six in order, least severe first — what isEnabled compares through, what logLevel validates against, and what a future OpenTelemetry bridge maps to severity numbers without a table of synonyms. There is no silly, no verbose and no caller-defined addition.

Attributes is flat and scalar deliberately. A nested object is where a field's name stops being stable across lines (user.id on one, user: { id } on another), and an unknown value is where a logger starts stringifying whatever it is handed. Anything else is the caller's to render; a failure has a channel of its own.

createLogger(sink, level?)

ts
createLogger(sink: Sink, level: Level = "info"): LoggerService;

The implementation. Two details are load-bearing:

  • currentUnit() is read per call, not captured at construction. One logger is built per scope and every unit the kernel opens has its own record, so a captured one would stamp the first unit's trace id on every line thereafter. This is what makes a single application-scope logger correct for every request.
  • Every write is wrapped. A sink that throws is swallowed here — there is nowhere left to report a broken reporter to, and a logger that takes the process down is worse than a line nobody sees.

with(attributes) layers attributes on top of this logger's and shares the sink, so a child costs one object. A call's attribute wins over the layered one; nothing mutates.

A line below level is dropped before the sink is called and before currentUnit() is read.

Line and Sink

ts
type Line = {
  readonly level: Level;
  readonly message: string;
  readonly attributes: Attributes;
  readonly cause: unknown;
  readonly time: number; // milliseconds since the epoch, stamped at the write
  readonly unit:
    | {
        readonly unitId: string;
        readonly traceId: string;
        readonly tenantId?: string;
      }
    | undefined;
};

type Sink = (line: Line) => void;

unit is what currentUnit() carried, or undefined outside a unit — a startup line, a package's own specs. tenantId is present only when the runtime supplied one; no shipped starter does. signal is on the ambient record but not on the line: it is for code that must act on it, not for a log backend.

A Sink is allowed to throw. createLogger is what makes that safe, which is why a sink is a plain function with no error channel of its own.

jsonSink(stream?)

ts
jsonSink(stream?: { readonly write: (chunk: string) => unknown }): Sink;

The default: one JSON object per line, process.stdout unless a stream is given. The shape every log backend already reads, and the same one the kernel's stderrSink writes its events in.

json
{
  "orderId": "0199a1e0-0000-7000-8000-000000000001",
  "quantity": 2,
  "time": "2026-08-16T09:41:02.113Z",
  "level": "info",
  "message": "placing an order",
  "unitId": "3f9c…",
  "traceId": "b41e…"
}

Three rules:

  • The unit's ids are spread at the top level, not nested under unit. A log backend indexes fields, and traceId is the field an operator searches.
  • A caller's attribute can never overwrite one of them, nor level, message or time. An attributes: { level: "info" } that could rewrite the severity is how a log stream stops being trustworthy.
  • cause is normalised, not stringified: an Error becomes { name, message, stack, cause } and the cause chain is walked up to four levels. JSON.stringify skips non-enumerable properties, so a bare Error would render the line that exists to carry a failure as {} — the same rule, and the same reason, as the kernel's stderrSink.

A payload JSON.stringify refuses outright — a circular value reaching in through cause is the plausible one — falls back to the time, level, message and cause: "[unserialisable]" rather than costing the line.

observability(options?)

ts
observability(options?: ObservabilityOptions):
  Module<Logger | LoggerConfig | Observers, ConfigInvalid, Env>;

type ObservabilityOptions = {
  readonly sink?: Sink; // unset: jsonSink() — one JSON object per line, to stdout
  readonly level?: Level; // unset: read from LOG_LEVEL, which defaults to "info"
};

The starter: a module providing the application's Logger and the LoggerConfig it was built from, both exported, and contributing an Observers member that writes an observed operation's failure as a <component>.<name> failed line at error — a success writes nothing. Import it next to the application and export Logger if anything outside the root reads it — a bound unit module, a test:

ts
export const OrderApi = HttpModule("OrderApi")({
  router: orderRouter,
  imports: [
    OrdersSlice,
    CustomersSlice,
    cache({ adapter: redisCache() }),
    observability(),
    otel(),
  ],
  provides: [sessionCodec()],
  exports: [Logger, Tracer, Meter],
});

It needs Env, which start provides to every graph it boots; outside the kernel, provide it yourself with Provider(Env)({ inject: {}, value: {} }). Its error channel is ConfigInvalid, which is how a bad LOG_LEVEL reaches exit code 78.

level pins the way every starter's options pin — Config.pinned, precedence explicit > environment > default, per field. sink replaces the destination and nothing else; the level filter stays this package's.

An application that wants its own implementation entirely does not import this module and provides Logger itself. Nothing else in the graph can tell.

LoggerConfig and LOG_LEVEL

ts
class LoggerConfig extends Port("LoggerConfig")<LoggerSettings> {}
type LoggerSettings = { readonly level: Level };

One variable today, bound through Config.provider like any other slice:

VariableFieldDefaultInvalid
LOG_LEVELone of the six levels, no othersinfomust be one of trace, debug, info, warn, error, fatal, got "verbose"

A value outside the six is a ConfigInvalid naming the variable and the set — reported as a startFailed event and exit 78 under runMain, before a line is written — rather than a silent fallback: a deployment that meant debug and typed verbose should be told, not quietly under-logged for a week.

It is built on Config.string, so it inherits the semantics every other variable has: an unset variable takes the default, a set-but-blank one is an error. See @btravstack/config.

logLevel(options?)

ts
logLevel(options?: { readonly default?: Level }): ConfigField<Level>;

That field on its own, exported so an application composing its own schema reuses the validation rather than re-deriving it:

ts
const appConfig = Config.provider("AppConfig")(
  Config.object({
    level: logLevel({ default: "debug" }),
    region: Config.string("REGION"),
  }),
);

kernelEvents(logger)

ts
kernelEvents(logger: LoggerService): EventSink;

The kernel's ten lifecycle events as log lines on logger, for StartOptions.onEvent. The kernel's own default writes JSON to stderr, which is right for a process with no logger and wrong for one with: two streams, two shapes, two sets of fields to search.

The mapping is deliberate rather than mechanical. Each event's own fields become attributes, so a drain is queryable by field rather than parsed out of a sentence:

EventLevelMessageAttributes besides eventCarries cause
buildinginfobuilding
startFailederrorthe application failed to startyes
servinginfoservingruntime
draininginfodraininginFlight
drainedinfodrainedinFlightAtStart, completed, abandoned
stoppinginfostopping
exitedinfoexited
teardownErrorwarna finaliser failed while the application was stoppingportyes
uncaughterroran uncaught exception stopped the applicationyes
stoppedWaitingwarnthe kernel stopped waiting for a phase with no deadline of its ownphase, and afterMs when a deadline rather than a second signal ended the wait

Every line carries event — the event's own type — as an attribute, so one query finds the transitions whatever the message says. startFailed and uncaught are errors because they carry a cause and are what an operator is paged for; teardownError is a warning because the application is already stopping and the exit code already says 2.

The logger is a parameter, not a resolved port

building is emitted while the graph is still being built, and startFailed when it never finished — so a sink taken out of the context it is watching would have nothing to write the two events that matter most with. That is why an application wiring this constructs a logger by hand in main.ts, a second one deliberately, and the only one the framework asks anybody to construct.

ts
await runMain(OrderApi, {
  onEvent: kernelEvents(createLogger(jsonSink())),
});

That logger reads no LOG_LEVEL — the binding lives in the graph it is watching — so it logs at the default info.

The otel subpath — traces and metrics

@opentelemetry/api and @opentelemetry/sdk-node are optional peers behind @btravstack/observability/otel, exactly as pino is behind its subpath — a consumer that never imports it never installs them:

sh
pnpm add @opentelemetry/api @opentelemetry/sdk-node
ts
// `Tracer` and `Meter` are the kernel's ports — see /reference/core/observability.
// This subpath is one implementation of them.
otel(options?: Partial<NodeSDKConfiguration>): Module<Tracer | Meter, never, Scope>;

class UnitSpan extends Port("UnitSpan")<Span> {}
const UnitSpanModule: Module<UnitSpan, never, Scope>; // needs: [Tracer]

otel() provides both ports over a NodeSDK held as a resourceful provider: the SDK starts when the scope opens and release is sdk.shutdown(), which flushes — so the kernel's close-on-every-path is what gets spans out of a dying process, and a lost flush is a teardownError and exit 2, never silence. Compose it beside observability() in a root's imports.

otel() takes no options of its own. Its parameter is the SDK's own Partial<NodeSDKConfiguration>, passed through untouched, and unset means the SDK's defaults — for an unconfigured process: an OTLP/HTTP span exporter at http://localhost:4318, a periodic OTLP metric reader alongside it unless OTEL_METRICS_EXPORTER=none, and a resource combining the OTEL_* resource variables with the SDK's environment, process and host detectors. The two fields this repository's own specs set are spanProcessors and metricReader — the latter accepted but superseded by metricReaders, which takes a list — and that is how a test collects without a collector.

There is no config slice, deliberately: the SDK reads the OTEL_* environment conventions itself (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_SDK_DISABLED, …) — that vocabulary is the platform's own standard. Programmatic overrides go through options, which is the SDK's own configuration type. And one otel() per process: the OTel api's globals register once, which is the SDK's own contract.

UnitSpanModule is a module a starter's own unit option binds — unit: { anonymous: UnitSpanModule }, unit: { message: UnitSpanModule }, unit: { activity: UnitSpanModule } — and the runtime forks around every unit it opens: a span opens when the fork is built and onStop ends it on every path out, with the ambient record's unitId, traceId and tenantId as attributes — a span joins the same query the logger's lines answer. The remote W3C parent is deliberately not reconstructed: UnitMeta.traceId carries the inbound trace id alone, so correlation is by attribute, never a parent-child edge the record cannot prove. Inbound, @btravstack/http-server and @btravstack/amqp-worker honour a W3C traceparent (trace-id field only; it outranks x-request-id and messageId respectively); @btravstack/temporal-worker keeps the workflow id as its correlation, which is what that transport's retries and replays preserve.

Auto-instrumentation cannot live here: @opentelemetry/auto-instrumentations-node/register must be preloaded (node --import) before the instrumented libraries load, which no DI provider can promise. otel() owns what the graph owns; the preload is the deployment's line.

Instrumentations a starter contributes

That limit is about the preload, not about instrumentations generally. One that patches nothing — whose enable() sets a helper the instrumented library reads per call, as @prisma/instrumentation does — has no ordering requirement a provider cannot meet, and otel() registers it.

A package contributes to @btravstack/core's Instrumentations set port; otel() loads every contribution and hands it to the NodeSDK. A contribution is just the loader — a loaded instrumentation already carries OTel's own instrumentationName, so the port has no name of its own to keep in step:

ts
Provider.member(Instrumentations)({
  inject: {},
  value: async () => new (await loadOptionalPeer())(),
});

Composing the starter declares what can be instrumented; composing otel() turns it on. A graph without an SDK collects nothing, loads nothing and installs nothing — which is why load is async and answers undefined rather than failing: the package supplying it is an optional peer the consumer may not have installed, and the contributor logs that skip itself.

@btravstack/prisma is the worked example: composing it alone gives you a counted, logged client, and adding otel() is what brings engine spans.

pinoSink(logger)

ts
import { pinoSink } from "@btravstack/observability/pino";

pinoSink(logger: import("pino").Logger): Sink;

A Sink over a pino logger, behind a subpath so pino can be an optional peer: a consumer that never imports it never installs it.

ts
import pino from "pino";
import { Logger } from "@btravstack/core";
import { observability } from "@btravstack/observability";
import { pinoSink } from "@btravstack/observability/pino";

observability({ sink: pinoSink(pino({ level: "trace" })) });

Configure pino at trace. The level filter stays this package's: createLogger has already decided the line is worth writing by the time a sink sees it, so one filter is in the process, and it is the one LOG_LEVEL validated at startup. Two filters that can disagree is the failure this avoids.

The attributes and the unit's ids ride as pino fields, not as a message prefix, so they stay indexable; the cause is handed over as err, which pino's own serialiser renders with the stack. Each of the six levels maps onto pino's own method of the same name — 10 through 60 — so no level of ours collapses into another.

Summary of exports

ExportKind
Loggerport
LoggerConfigport — { level }, bound from LOG_LEVEL
LoggerSettingstype
Line / Sinktype — what an implementation hands a destination
createLoggervalue — the implementation
jsonSinkvalue — the default sink
observabilityvalue — the starter
ObservabilityOptionstype
logLevelvalue — the LOG_LEVEL field alone
kernelEventsvalue — the kernel's EventSink over a logger
pinoSinkvalue — @btravstack/observability/pino only
otelvalue — @btravstack/observability/otel only
UnitSpanport class — @btravstack/observability/otel only
UnitSpanModulevalue — @btravstack/observability/otel only

What it does not do

  • It does not declare the ports it implements. Logger, Tracer and Meter — and LoggerService, Level, LEVELS, Attributes with them — are the kernel's. This package provides them; importing one from here is a compile error, deliberately, so two import paths for one contract can never drift.
  • No transport, no rotation, no batching. A sink is a function; a deployment that wants any of those brings pino, or writes eleven lines of its own.
  • No Result on a log call. Delivery is the implementation's problem, and a lost line is not a modeled error.

See also

Released under the MIT License.