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 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 }, plus scalar own data fields from a fixed whitelist: _tag, sqlState, constraint, table, database, role, bypass and port. Getters and other tagged payload fields are omitted: they may contain tokens or personal data. Put reviewed, non-sensitive diagnostics in the line's attributes explicitly. Aggregate errors keep up to sixteen errors members per level, with omittedErrors naming any remainder. A malformed non-array errors property is omitted. Only an own data-valued array is read; an errors accessor and indexed member accessors are never invoked. Missing or accessor-valued members render as null, preserving sibling positions. Non-error objects, functions and BigInts nested in members or cause chains render as "[non-error]" without invoking getters or serializers; JSON-compatible scalar reasons remain visible. Cycles render as "[circular]"; both member trees and cause chains are 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.

The fields, every one at the top level of one object:

FieldValue
the attributesthe line's own and its logger's with layers, by their own names
timeISO 8601, UTC, to the millisecond
leveltrace, debug, info, warn, error or fatal, as text
messagethe message
unitIdinside a unit only
traceIdinside a unit only
tenantIdinside a unit that has one
causewhen one was passed — { name, message, stack, cause } for an Error
unserialisableonly on a fallback line: the names of the fields that could not be rendered

The shape is this package's own — neither ECS, nor the OpenTelemetry log data model, nor pino's (whose numeric level and msg are what pinoSink writes). The names are the ones above and change only with a changeset.

A payload JSON.stringify refuses outright — a circular value reaching in through cause, or a BigInt passed as an attribute — does not cost the line: every field that renders is kept, the unit's ids included, and unserialisable names the ones that did not (["total"]).

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,
  unit: { anonymous: RequestModule, user: UserModule, service: ServiceModule },
  imports: [
    OrdersSlice,
    CustomersSlice,
    OrderPersistenceModule,
    cache({ adapter: redisCache() }),
    observability(),
    otel(),
  ],
  provides: [sessionCodec()],
});

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 start—yes
servinginfoservingruntime—
draininginfodraininginFlight—
drainedinfodrainedinFlightAtStart, completed, abandoned—
stoppinginfostopping——
exitedinfoexited——
teardownErrorwarna finaliser failed while the application was stoppingportyes
uncaughterroran uncaught exception stopped the application—yes
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. A unit the kernel aborted ends with an error status and the message aborted; one whose work failed — an Err, a Defect or a throw, read through the kernel's unitOutcome() — ends with an error status, and so does one whose unit module failed to build — a sibling provider that threw tears the span down before the work has settled at all. That is the kernel's outcome, not the transport's: an HTTP unit that answered a 500 delivered its response and is not marked, while the runtime's own http.request span is.

An operation observed inside the unit is that span's child. A cache read, a stored object, a sent mail or a query opens its span under the unit's, and stamps the same three ids (btravstack.unit_id, btravstack.trace_id, btravstack.tenant_id) read from currentUnit() per operation. The observer otel() contributes still injects nothing: it finds the unit's span by the ambient record it was opened for. That holds whatever order the unit module lists its imports in: an operation that starts before UnitSpanModule has built its span — a sibling provider di constructed first — opens its span later, back-dated to its start and ended at its own end, under the unit's. One that even SETTLES during that provider's construction waits a microtask: di builds UnitSpanModule, whose one dependency comes from the parent scope, in the fork's first level, in the same synchronous pass. Without UnitSpanModule bound, an operation inside a unit carries the ids and parents on whatever OTel context is active. The runtime's own operation — http.request, amqp.delivery, temporal.attempt — is observed AROUND the unit rather than inside it, so it carries neither the parent nor the ids.

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.

What otel() records, and where it departs from semconv ​

Every operation a starter reports to Observers becomes one span and two instruments, named from the operation so nothing had to become uniform to be shared:

SignalNameNotes
span<component>.<name>cache.get, http.request, amqp.delivery; error status when it settled error
spanunitone per unit, from UnitSpanModule; the parent of every operation inside it
counterbtravstack.<component>.operationsthe operation's dimensions plus outcome
histogrambtravstack.<component>.durationseconds, on the semantic conventions' bucket boundaries (0.005 … 10)

Durations follow the OpenTelemetry semantic conventions — seconds, with their buckets. The rest deliberately does not, and the deviations are these:

  • Span names are <component>.<name>, not semconv's. An HTTP server span is http.request rather than GET, because one observer names every component's span the same way; the method is the method attribute, and the route is absent on purpose (its cardinality).
  • No span sets a SpanKind, so every one is INTERNAL — unit included. The kernel's Tracer port takes a name and nothing else, and an operation does not say which side of a call it is on; a backend that draws a service map from SERVER/CLIENT edges draws none from these.
  • Attributes and metric names are the starters' own — method, status, btravstack.http.duration — not http.request.method or http.server.request.duration. Borrowing a semconv name would promise a series shape these do not have.

A deployment that needs the semconv shapes gets them from auto-instrumentation, below, which emits them for the libraries it patches.

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.

No starter contributes one today. @btravstack/prisma did on Prisma 7, for @prisma/instrumentation's engine spans; Prisma 8 has no engine and ships no tracer, so its query middleware reports to Observers and opens the only database span there is.

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" })) });

The child logger is pinned at trace without changing the supplied logger. 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 rendered as err through the same safe diagnostic whitelist and bounded cause walk as jsonSink. The error's type, name, message, stack and safe diagnostics survive; arbitrary payloads do not. This sink uses a child logger at a trace floor to install that serializer without changing the supplied logger. 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.