feat(observability): a strict Logger port, correlated with the kernel's units - #45
Merged
Conversation
…'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.
There was a problem hiding this comment.
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, optionalpinoSink,observability()starter,kernelEvents()adapter) with full test coverage. - Migrate examples to import
observability()and to capture log output via a test sink (asserting onLinefields). - 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.
btravers
commented
Aug 16, 2026
btravers
commented
Aug 16, 2026
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, laterOTEL_*) 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
useLoggerto reach past DI withwith(attributes)returns a loggerAttributes= flat scalarsany, no printf, no stringifying whatever it is handedcauseError'smessage/stackare non-enumerable —JSON.stringifyalone drops the part worth keeping(message, attributes?, cause?), on all sixLOG_LEVELis validated at startup;isEnabledis a comparisonCorrelation is free:
createLoggerreadscurrentUnit()per call, so every line inside a unit carries itstraceId/unitId/tenantIdwith 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 aterrorpurely to keep its reason, andkernelEvents'teardownErrorarm 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 islogger.error("boom", undefined, cause)for a failure with nothing else to say, which is rare.What ships
Logger/LoggerService/Level/LEVELS/Attributes/Line/Sink/createLoggerobservability({ sink?, level? })→Logger+LoggerConfigfromLOG_LEVEL(invalid →ConfigInvalid→ exit78)jsonSink(default, dependency-free, stdout, correlation as top-level fields),pinoSinkbehind@btravstack/observability/pino(pino an optional peer)kernelEvents(logger)forStartOptions.onEvent— the nine lifecycle events as lines, each event's fields kept as attributesThe examples dogfood it (the lesson from #21)
order-applicationno longer declares its ownLogger; each composition root importsobservability();order-api'smain.tsshowsonEvent: kernelEvents(createLogger(jsonSink()))— with the reason that logger is built by hand (buildingfires 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/Meterports, the OTelNodeSDKas a resourceful provider whosereleaseflushes (a lost span becomes ateardownErrorand exit2), a span per unit as aStartOptions.unitprovider, an OTel appender as aSink,traceparentintoUnitMeta.traceId— is recorded inpackages/observability/CLAUDE.md, along with the constraint that OTel auto-instrumentation must be preloaded and so can never be DI-provided.Test plan
/reference/observability,/how-to/log-and-correlate,/api/observability/, nine CLAUDE files swept; docs build clean