Skip to content

feat(observability): a strict Logger port, correlated with the kernel's units - #45

Merged
btravers merged 2 commits into
mainfrom
feat/observability
Aug 16, 2026
Merged

feat(observability): a strict Logger port, correlated with the kernel's units#45
btravers merged 2 commits into
mainfrom
feat/observability

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Why a package, and why observability rather than a logger

The README's runtime map already listed "an observability package — logger and OpenTelemetry, binding to KernelEvent" as planned, and thesis 2 already named the logger as a legitimate ambient reader. This is the logging half of it.

One package, not @btravstack/logger + a later observability one: logs, traces and metrics share a correlation id, a resource, a config slice (LOG_LEVEL, later OTEL_*) and a flush-on-shutdown lifecycle. Split them and either you duplicate all four or the logger package ends up depending on the OTel one anyway. Subpaths keep a logging-only user from installing anything extra.

The interface, strict where Nest's is loose

Decision Why
A port, never a class or a static A test provides its own; there is no useLogger to reach past DI with
with(attributes) returns a logger Nothing mutates: two scopes cannot interleave each other's context
Attributes = flat scalars The shape a log backend indexes; no any, no printf, no stringifying whatever it is handed
A dedicated cause An Error's message/stack are non-enumerable — JSON.stringify alone drops the part worth keeping
One argument order, (message, attributes?, cause?), on all six See below
It cannot throw A broken sink is swallowed: an observability fault must not become an outage
Six fixed levels LOG_LEVEL is validated at startup; isEnabled is a comparison

Correlation is free: createLogger reads currentUnit() per call, so every line inside a unit carries its traceId/unitId/tenantId with nothing threaded through the call stack.

A correction made mid-PR, worth reading

The first draft had error(message, cause, attributes) and no cause on the other four. Migrating the examples showed the cost twice over: the outbox relay's "publishing failed, will retry" is a retryable failure that had to be logged at error purely to keep its reason, and kernelEvents' teardownError arm silently dropped the finaliser's error. A failure is not a property of severity — so all six methods now take (message, attributes?, cause?). The cost is logger.error("boom", undefined, cause) for a failure with nothing else to say, which is rare.

What ships

  • Logger / LoggerService / Level / LEVELS / Attributes / Line / Sink / createLogger
  • observability({ sink?, level? })Logger + LoggerConfig from LOG_LEVEL (invalid → ConfigInvalid → exit 78)
  • jsonSink (default, dependency-free, stdout, correlation as top-level fields), pinoSink behind @btravstack/observability/pino (pino an optional peer)
  • kernelEvents(logger) for StartOptions.onEvent — the nine lifecycle events as lines, each event's fields kept as attributes
  • 26 specs, 100% lines/functions

The examples dogfood it (the lesson from #21)

order-application no longer declares its own Logger; each composition root imports observability(); order-api's main.ts shows onEvent: kernelEvents(createLogger(jsonSink())) — with the reason that logger is built by hand (building fires while the graph still is). Specs assert on fields (line.attributes.orderId) instead of substring-matching a rendered sentence.

Not here yet

Traces and metrics. Their shape — Tracer/Meter ports, the OTel NodeSDK as a resourceful provider whose release flushes (a lost span becomes a teardownError and exit 2), a span per unit as a StartOptions.unit provider, an OTel appender as a Sink, traceparent into UnitMeta.traceId — is recorded in packages/observability/CLAUDE.md, along with the constraint that OTel auto-instrumentation must be preloaded and so can never be DI-provided.

Test plan

  • format / lint / typecheck / knip / build green
  • tests green locally minus the two Docker suites (CI)
  • docs: /reference/observability, /how-to/log-and-correlate, /api/observability/, nine CLAUDE files swept; docs build clean
  • CI

…'s units

@btravstack/observability is the eighth package: observability for the
kernel, starting with logging. Named for the whole because logs, traces and
metrics share a correlation id, a resource, a config slice and a
flush-on-shutdown lifecycle — splitting them would duplicate all four.

Logger is a di port over a deliberately strict interface, and every difference
from NestJS's logger is a defect it does not have: a port rather than a class
you new (no static, no useLogger reaching past DI), with(attributes) returning
a new logger rather than setContext mutating the one every caller shares, a
flat record of scalars rather than any varargs, a dedicated cause channel, six
fixed levels, one argument order across all six methods, and a guarantee that
a log call cannot throw.

createLogger reads currentUnit() per call, so every line written inside a unit
carries its traceId, unitId and tenantId with nothing threaded through the
call stack. observability({ sink?, level? }) provides Logger and LoggerConfig,
bound from LOG_LEVEL and validated once — a level outside the six is a
ConfigInvalid, exit 78, not a silent fallback. jsonSink is the default (one
JSON object per line on stdout, no runtime dependency); pinoSink lives behind
the /pino subpath with pino as an optional peer. kernelEvents(logger) puts the
kernel's nine lifecycle events in the same stream, each event's fields kept as
attributes.

The examples consume it: order-application no longer declares its own Logger
port, each composition root imports observability(), and order-api's main.ts
shows the onEvent wiring — with the reason that logger is built by hand.

Traces and metrics are not here yet; their shape is recorded in
packages/observability/CLAUDE.md.
Copilot AI lite review requested due to automatic review settings August 16, 2026 14:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds @btravstack/observability as the logging starter for the stack: a strict Logger port whose implementation correlates each line with the kernel’s ambient unit, plus sinks and an adapter that writes KernelEvents through the same logger stream. The PR also migrates the example apps and documentation to dogfood the new port and to assert on structured Line fields instead of parsing formatted strings.

Changes:

  • Introduce @btravstack/observability (Logger port, createLogger, jsonSink, optional pinoSink, observability() starter, kernelEvents() adapter) with full test coverage.
  • Migrate examples to import observability() and to capture log output via a test sink (asserting on Line fields).
  • Wire docs + TypeDoc generation and build graph to include the new package.

Reviewed changes

Copilot reviewed 84 out of 86 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
turbo.json Include observability in docs build deps
pnpm-workspace.yaml Add pino to shared catalog
packages/observability/vitest.config.ts New vitest config for package
packages/observability/tsconfig.json New TS config for package
packages/observability/src/vitest.d.ts Register @unthrown/vitest matchers
packages/observability/src/test-fixtures.ts Test fixtures for logger/sinks/starter
packages/observability/src/pino.ts pinoSink subpath sink implementation
packages/observability/src/pino.spec.ts Tests for pinoSink behavior
packages/observability/src/observability.ts observability() starter + kernelEvents()
packages/observability/src/observability.spec.ts Starter + kernel-events adapter tests
packages/observability/src/logger.ts Logger port/types + createLogger
packages/observability/src/logger.spec.ts Logger surface/behavior tests
packages/observability/src/json-sink.ts Dependency-free JSON sink implementation
packages/observability/src/json-sink.spec.ts JSON sink tests
packages/observability/src/index.ts Public exports for package
packages/observability/src/config.ts LOG_LEVEL config field + schema
packages/observability/README.md Package README + usage example
packages/observability/package.json New package manifest/exports/scripts
packages/observability/LICENSE New package license file
packages/observability/CLAUDE.md Package-level spec for observability
packages/core/CLAUDE.md Note observability usage vs kernel sink
examples/README.md Update examples index + testing guidance
examples/order-temporal-worker/src/test-fixtures.ts Capture logs via sink; tap fewer services
examples/order-temporal-worker/src/temporal-runtime.spec.ts Assert on structured Line fields
examples/order-temporal-worker/src/module.ts Import observability() in root
examples/order-temporal-worker/src/fulfillment.ts Use Logger from observability + structured attrs
examples/order-temporal-worker/README.md Document LOG_LEVEL + sink-based assertions
examples/order-temporal-worker/package.json Add observability dependency
examples/order-infrastructure/README.md Mention observability closing Logger need
examples/order-application/src/use-cases.ts Use observability Logger + structured logging
examples/order-application/src/test-fixtures.ts Provide Env + observability sink in tests
examples/order-application/src/ports.ts Remove local Logger port
examples/order-application/src/place-order.spec.ts Assert on Line values/fields
examples/order-application/src/needs-gate.test-d.ts Add Logger need + wire logger without starter
examples/order-application/src/module.ts ApplicationModule now leaves Logger unmet
examples/order-application/src/logger.ts Remove bespoke logger adapter
examples/order-application/src/index.ts Stop exporting removed Logger port
examples/order-application/README.md Update layering + logging explanation
examples/order-application/package.json Depend on observability (+ config in devDeps)
examples/order-api/src/test-fixtures.ts Replace tapped logger with recording sink root
examples/order-api/src/request-scope.ts Use observability Logger + structured attrs
examples/order-api/src/needs-gate.test-d.ts Import observability + update gate examples
examples/order-api/src/module.ts Import observability() in root
examples/order-api/src/main.ts Wire kernel events into app logger stream
examples/order-api/src/api.spec.ts Assert tracing via Line.unit fields
examples/order-api/README.md Update docs around observability + kernelEvents
examples/order-api/package.json Add observability dependency
examples/order-amqp-worker/src/test-fixtures.ts Recording sink root + tap only services
examples/order-amqp-worker/src/outbox-relay.ts Use observability Logger + cause channel
examples/order-amqp-worker/src/needs-gate.test-d.ts Import observability + update gate examples
examples/order-amqp-worker/src/module.ts Import observability() in root
examples/order-amqp-worker/src/handlers.ts Structured logging w/ attributes
examples/order-amqp-worker/src/amqp-runtime.spec.ts Assert notifications via Line fields
examples/order-amqp-worker/README.md Document LOG_LEVEL + sink-based assertions
examples/order-amqp-worker/package.json Add observability dependency
docs/typedoc.observability.json Add TypeDoc config for new package
docs/tutorial/getting-started.md Link new “Log and correlate” guide
docs/scripts/build-api.ts Include observability in TypeDoc build
docs/reference/testing.md Update guidance: taps for services, sinks for logs
docs/reference/http.md Show observability() alongside http()
docs/reference/glossary.md Add “sink” definition + structured logging
docs/reference/amqp.md Show observability() alongside amqp()
docs/index.md List observability as 8th package
docs/how-to/test-an-application.md Add sink-based log capture recipe
docs/how-to/serve-orpc-over-http.md Update composition + main.ts examples
docs/how-to/run-a-temporal-worker.md Update composition examples w/ observability
docs/how-to/read-the-ambient-unit.md Replace logger recipe with shipped package
docs/how-to/open-a-per-request-scope.md Update Logger import + main.ts wiring
docs/how-to/consume-amqp-messages.md Update Logger import + root composition
docs/explanation/why-start.md Clarify starters incl. observability (no runtime)
docs/explanation/starters.md Add observability as non-runtime starter
docs/explanation/nothing-throws.md Mention kernelEvents adapter behavior
docs/explanation/design-decisions.md Document Logger/observability design choices
docs/explanation/ambient-vs-context.md Reference createLogger as canonical ambient reader
docs/examples/order-temporal-worker.md Update example docs w/ observability
docs/examples/order-application.md Update layering docs (Logger is framework port)
docs/examples/order-api.md Update example docs + main.ts wiring
docs/examples/order-amqp-worker.md Update example docs w/ observability
docs/api/index.md Add observability API entry + subpath
docs/.vitepress/config.ts Add nav/sidebar links for observability
.changeset/observability.md Changeset for new observability package
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/observability/src/json-sink.ts Outdated
Comment thread packages/observability/src/logger.spec.ts Outdated
Comment thread packages/observability/src/observability.ts
Comment thread examples/order-amqp-worker/src/module.ts
Comment thread examples/order-amqp-worker/src/test-fixtures.ts Outdated
…inline a port union

- json-sink.ts said the sink's fields come first; they come last, and that IS
  the precedence a caller cannot forge — say that instead
- the level spec's comment predated the uniform signature: a cause appears
  where the call supplied one, at every level
- root CLAUDE.md still said teardownError drops its cause; it carries it, and
  that was the point of the change
- examples/order-amqp-worker: spell the port union inline, as
  order-temporal-worker's fixture already does
@btravers
btravers merged commit ce0671c into main Aug 16, 2026
13 checks passed
@btravers
btravers deleted the feat/observability branch August 16, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants