@btravstack/observability 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit TRAVERS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ <!-- doctest: prelude
2
+ import { HttpModule } from "@btravstack/http-server";
3
+ import { Port } from "@btravstack/di";
4
+ import type { AsyncResult } from "unthrown";
5
+ import { orderRouter } from "../../module.js";
6
+ import { otel } from "@btravstack/observability/otel";
7
+ import { cache } from "@btravstack/cache";
8
+ import { redisCache } from "@btravstack/cache/redis";
9
+ import { CustomersSlice } from "../../slices/customers/module.js";
10
+ import { OrdersSlice } from "../../slices/orders/module.js";
11
+ class PlaceOrder extends Port("PlaceOrder")<{
12
+ readonly execute: (id: string, quantity: number) => AsyncResult<void, never>;
13
+ }> {}
14
+ class OrderRepository extends Port("OrderRepository")<{
15
+ readonly save: (order: {
16
+ readonly id: string;
17
+ readonly quantity: number;
18
+ }) => AsyncResult<void, never>;
19
+ }> {}
20
+ -->
21
+
22
+ # @btravstack/observability
23
+
24
+ > Observability for [`@btravstack/core`](../core), starting with logging: a
25
+ > **strict** `Logger` port โ€” no `any`, no printf, no mutable context, no static
26
+ > instance โ€” a default implementation that stamps every line with the ambient
27
+ > unit's trace id, a dependency-free JSON sink, pino behind a subpath, and the
28
+ > kernel's nine lifecycle events as log lines in the same stream.
29
+
30
+ ๐Ÿ“– **[Documentation](https://btravstack.github.io/btravstack/reference/observability)** ยท
31
+ [How-to](https://btravstack.github.io/btravstack/how-to/log-and-correlate) ยท
32
+ [API Reference](https://btravstack.github.io/btravstack/api/observability/)
33
+
34
+ ```sh
35
+ pnpm add @btravstack/observability @btravstack/core @btravstack/config @btravstack/di unthrown
36
+ ```
37
+
38
+ Those four are peers. `pino` is an **optional** peer, needed only if you import
39
+ `@btravstack/observability/pino`. Node `>=20`.
40
+
41
+ ## A worked example
42
+
43
+ ```ts
44
+ import { runMain, Logger } from "@btravstack/core";
45
+ import {
46
+ createLogger,
47
+ jsonSink,
48
+ kernelEvents,
49
+ observability,
50
+ } from "@btravstack/observability";
51
+ import { Module, Provider } from "@btravstack/di";
52
+
53
+ // The application depends on the port, like any other service.
54
+ const placeOrder = Provider(PlaceOrder)(
55
+ { orders: OrderRepository, logger: Logger },
56
+ {
57
+ sync: ({ orders, logger }) => ({
58
+ execute: (id, quantity) =>
59
+ orders
60
+ .save({ id, quantity })
61
+ .tap(() => logger.info("order placed", { id, quantity })),
62
+ }),
63
+ },
64
+ );
65
+
66
+ // The starter provides it. `LOG_LEVEL` is read inside the graph and validated
67
+ // once โ€” `verbose` is a startup failure naming the variable, not a silent
68
+ // fallback to `info`.
69
+ const OrderApi = HttpModule("OrderApi")({
70
+ router: orderRouter,
71
+ imports: [
72
+ OrdersSlice,
73
+ CustomersSlice,
74
+ cache({ adapter: redisCache() }),
75
+ observability(),
76
+ otel(),
77
+ ],
78
+ exports: [Logger],
79
+ });
80
+
81
+ // The kernel's own events, in the same stream and the same shape. The logger
82
+ // here is built by hand because `building` is emitted while the graph still
83
+ // is: the sink cannot come from the context it is watching.
84
+ await runMain(OrderApi, { onEvent: kernelEvents(createLogger(jsonSink())) });
85
+ ```
86
+
87
+ Every line written inside a unit carries that unit's `traceId`, `unitId` and
88
+ `tenantId` โ€” the logger reads `currentUnit()` **per call**, so one
89
+ application-scope logger is correct for every request without a single
90
+ argument threaded through the call stack.
91
+
92
+ ```json
93
+ {
94
+ "orderId": "0199a1e0-0000-7000-8000-000000000001",
95
+ "time": "2026-08-16T09:41:02.113Z",
96
+ "level": "info",
97
+ "message": "order placed",
98
+ "unitId": "u-7",
99
+ "traceId": "3f9cโ€ฆ"
100
+ }
101
+ ```
102
+
103
+ ## What makes the interface strict
104
+
105
+ | Decision | Why |
106
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
107
+ | A **port**, never a class or a static | A test provides its own; there is no global to reach past DI with |
108
+ | `with(attributes)` returns a logger | Nothing mutates: two scopes cannot interleave each other's context |
109
+ | `Attributes` is a flat record of scalars | The shape a log backend indexes; no `any`, no printf, no stringifying whatever it is handed |
110
+ | A failure goes in `cause` | An `Error`'s `message` and `stack` are non-enumerable โ€” `JSON.stringify` alone drops exactly the part worth keeping |
111
+ | It cannot throw | A broken sink is swallowed: an observability fault must not become an outage |
112
+ | Six levels, fixed | `LOG_LEVEL` is validated at startup, and `isEnabled` is a comparison |
113
+
114
+ ## Swapping the implementation
115
+
116
+ `observability({ sink })` replaces the destination; providing `Logger`
117
+ yourself replaces everything. For a deployment that wants pino's throughput:
118
+
119
+ <!-- doctest: skip โ€” needs `pino`, which no example workspace installs; held by packages/observability/src/pino.spec.ts instead -->
120
+
121
+ ```ts
122
+ import pino from "pino";
123
+ import { pinoSink } from "@btravstack/observability/pino";
124
+
125
+ observability({ sink: pinoSink(pino()) });
126
+ ```
127
+
128
+ The level filter stays this package's โ€” `LOG_LEVEL`, validated once โ€” so there
129
+ is one filter in the process rather than two that can disagree.
130
+
131
+ ## Traces and metrics
132
+
133
+ The other half of the package's name, behind an optional-peer subpath the way
134
+ `pino` is:
135
+
136
+ ```sh
137
+ pnpm add @opentelemetry/api @opentelemetry/sdk-node
138
+ ```
139
+
140
+ <!-- doctest: skip โ€” needs `@opentelemetry/*`, which no example workspace installs; held by packages/observability/src/otel.spec.ts instead -->
141
+
142
+ ```ts
143
+ import { observability } from "@btravstack/observability";
144
+ import { UnitSpanModule, otel } from "@btravstack/observability/otel";
145
+
146
+ // Beside observability() in the root's imports; the SDK is a resource of the
147
+ // graph โ€” started with the scope, flushed by release on every exit path.
148
+ // Configuration is the OTEL_* environment conventions, read by the SDK itself.
149
+ const AppImports = [observability(), otel()];
150
+
151
+ // A span per kernel unit, correlated with the same ids the logger stamps:
152
+ // runMain(App, { unit: UnitSpanModule })
153
+ ```
154
+
155
+ Inbound W3C `traceparent` feeds the unit's trace id in `@btravstack/http-server` and
156
+ `@btravstack/amqp-worker`. Auto-instrumentation must be preloaded
157
+ (`node --import @opentelemetry/auto-instrumentations-node/register`) โ€” it
158
+ cannot be a provider, and the package does not pretend otherwise. Full
159
+ semantics on
160
+ [the reference page](https://btravstack.github.io/btravstack/reference/observability).
161
+
162
+ ## License
163
+
164
+ [MIT](./LICENSE) ยฉ Benoit TRAVERS
package/dist/index.cjs ADDED
@@ -0,0 +1,213 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _btravstack_config = require("@btravstack/config");
3
+ let _btravstack_core = require("@btravstack/core");
4
+ let unthrown = require("unthrown");
5
+ let _btravstack_di = require("@btravstack/di");
6
+ //#region src/config.ts
7
+ /**
8
+ * `LOG_LEVEL`, as a `ConfigField` of the six levels and nothing else.
9
+ *
10
+ * A value outside the set is a `ConfigInvalid` naming the variable โ€” exit
11
+ * `78` under `runMain`, before a line is written โ€” rather than a silent
12
+ * fallback to `info`: a deployment that meant `debug` and typed `verbose`
13
+ * should be told, not quietly under-logged for a week. Built on
14
+ * `Config.string`, so it inherits the semantics every other variable has: an
15
+ * unset variable takes the default, a set-but-blank one is an error.
16
+ */
17
+ const logLevel = (options = {}) => {
18
+ const text = _btravstack_config.Config.string("LOG_LEVEL", { default: options.default ?? "info" });
19
+ return {
20
+ variable: text.variable,
21
+ parse: (raw) => text.parse(raw).flatMap((value) => _btravstack_core.LEVELS.includes(value) ? (0, unthrown.Ok)(value) : (0, unthrown.Err)(new _btravstack_config.ConfigFieldInvalid({ reason: `must be one of ${_btravstack_core.LEVELS.join(", ")}, got ${JSON.stringify(value)}` })))
22
+ };
23
+ };
24
+ /** The schema `observability()` binds `LoggerConfig` through โ€” one field today, and the place a second one lands. */
25
+ const loggerSchema = (level) => _btravstack_config.Config.object({ level: _btravstack_config.Config.pinned(level, logLevel()) });
26
+ //#endregion
27
+ //#region src/json-sink.ts
28
+ /**
29
+ * An `Error`'s `message` and `stack` are **non-enumerable**, so
30
+ * `JSON.stringify` renders a thrown one as `{}` โ€” the line that exists to carry
31
+ * a failure would carry nothing. Walks `cause` chains so a wrapped failure keeps
32
+ * its origin.
33
+ */
34
+ const renderCause = (cause, depth = 0) => {
35
+ if (!(cause instanceof Error)) return cause;
36
+ return {
37
+ name: cause.name,
38
+ message: cause.message,
39
+ stack: cause.stack,
40
+ ...cause.cause === void 0 || depth >= 4 ? {} : { cause: renderCause(cause.cause, depth + 1) }
41
+ };
42
+ };
43
+ /**
44
+ * One JSON object per line on `stream`.
45
+ *
46
+ * The caller's attributes are spread FIRST and the line's own fields after them,
47
+ * and that order is the precedence: an `attributes: { level: "info" }` cannot
48
+ * rewrite an `error` line's severity, nor its `traceId`.
49
+ *
50
+ * The unit's ids are spread at the top level rather than nested under `unit`,
51
+ * because `traceId` is the field an operator searches.
52
+ */
53
+ const jsonSink = (stream = process.stdout) => (line) => {
54
+ const { level, message, attributes, cause, time, unit } = line;
55
+ const rendered = {
56
+ ...attributes,
57
+ time: new Date(time).toISOString(),
58
+ level,
59
+ message,
60
+ ...unit === void 0 ? {} : unit,
61
+ ...cause === void 0 ? {} : { cause: renderCause(cause) }
62
+ };
63
+ stream.write(`${safeStringify(rendered)}\n`);
64
+ };
65
+ const safeStringify = (rendered) => {
66
+ try {
67
+ return JSON.stringify(rendered);
68
+ } catch {
69
+ return JSON.stringify({
70
+ time: rendered["time"],
71
+ level: rendered["level"],
72
+ message: rendered["message"],
73
+ cause: "[unserialisable]"
74
+ });
75
+ }
76
+ };
77
+ //#endregion
78
+ //#region src/logger.ts
79
+ const severity = (level) => _btravstack_core.LEVELS.indexOf(level);
80
+ /**
81
+ * A logger over `sink`, filtered at `level` and correlated with the ambient
82
+ * unit.
83
+ *
84
+ * The correlation is read **per call**, not captured: one logger is built per
85
+ * scope and every unit the kernel opens has its own record, so a logger that
86
+ * captured it at construction would stamp the wrong trace id on every line but
87
+ * the first. `with` layers attributes and nothing else, so a child costs one
88
+ * object and shares the sink.
89
+ *
90
+ * Every path is wrapped: a sink that throws is swallowed here, because a
91
+ * logger that takes the process down is worse than a line nobody sees.
92
+ */
93
+ const createLogger = (sink, level = "info") => {
94
+ const floor = severity(level);
95
+ const build = (base) => {
96
+ const write = (lineLevel, message, attributes, cause) => {
97
+ if (severity(lineLevel) < floor) return;
98
+ const unit = (0, _btravstack_core.currentUnit)();
99
+ try {
100
+ sink({
101
+ level: lineLevel,
102
+ message,
103
+ attributes: attributes === void 0 ? base : {
104
+ ...base,
105
+ ...attributes
106
+ },
107
+ cause,
108
+ time: Date.now(),
109
+ unit: unit === void 0 ? void 0 : {
110
+ unitId: unit.unitId,
111
+ traceId: unit.traceId,
112
+ ...unit.tenantId === void 0 ? {} : { tenantId: unit.tenantId }
113
+ }
114
+ });
115
+ } catch {}
116
+ };
117
+ return {
118
+ log: (lineLevel, message, attributes, cause) => write(lineLevel, message, attributes, cause),
119
+ trace: (message, attributes, cause) => write("trace", message, attributes, cause),
120
+ debug: (message, attributes, cause) => write("debug", message, attributes, cause),
121
+ info: (message, attributes, cause) => write("info", message, attributes, cause),
122
+ warn: (message, attributes, cause) => write("warn", message, attributes, cause),
123
+ error: (message, attributes, cause) => write("error", message, attributes, cause),
124
+ fatal: (message, attributes, cause) => write("fatal", message, attributes, cause),
125
+ with: (attributes) => build({
126
+ ...base,
127
+ ...attributes
128
+ }),
129
+ isEnabled: (lineLevel) => severity(lineLevel) >= floor
130
+ };
131
+ };
132
+ return build({});
133
+ };
134
+ //#endregion
135
+ //#region src/observability.ts
136
+ /**
137
+ * What the graph bound from the environment: the level every logger in it
138
+ * filters at. A port of its own, like a starter's `HttpConfig`, so anything
139
+ * that wants to know reads it rather than re-deriving it.
140
+ */
141
+ var LoggerConfig = class extends (0, _btravstack_di.Port)("LoggerConfig") {};
142
+ /**
143
+ * The observability starter: a module providing the application's `Logger` and
144
+ * the `LoggerConfig` it was built from. Import it next to the application and
145
+ * export `Logger`.
146
+ *
147
+ * Every line carries the ambient unit's `traceId`, because the logger reads
148
+ * `currentUnit()` per call โ€” so a request's lines are attributable with no
149
+ * argument threaded through the call stack.
150
+ */
151
+ const observability = (options = {}) => (0, _btravstack_di.Module)("Observability")({
152
+ needs: [_btravstack_config.Env],
153
+ provides: [_btravstack_config.Config.provider(LoggerConfig)(loggerSchema(options.level)), (0, _btravstack_di.Provider)(_btravstack_core.Logger)({ config: LoggerConfig }, { sync: ({ config }) => createLogger(options.sink ?? jsonSink(), config.level) })],
154
+ exports: [_btravstack_core.Logger, LoggerConfig]
155
+ });
156
+ /**
157
+ * The kernel's nine lifecycle events, as log lines on `logger` โ€” the adapter
158
+ * between `StartOptions.onEvent` and an application's own stream.
159
+ *
160
+ * The mapping is deliberate rather than mechanical: `startFailed` and `uncaught`
161
+ * are `error`, `teardownError` is `warn` (the application is already stopping
162
+ * and the exit code says so), everything else `info`. Each event's own fields
163
+ * become attributes, so a drain is queryable by field.
164
+ *
165
+ * The logger is a PARAMETER, not resolved: `building` is emitted while the graph
166
+ * is still being built, so the sink cannot come from the context it watches.
167
+ */
168
+ const kernelEvents = (logger) => (event) => {
169
+ switch (event.type) {
170
+ case "startFailed":
171
+ logger.error("the application failed to start", { event: event.type }, event.cause);
172
+ return;
173
+ case "uncaught":
174
+ logger.error("an uncaught exception stopped the application", { event: event.type }, event.cause);
175
+ return;
176
+ case "teardownError":
177
+ logger.warn("a finaliser failed while the application was stopping", {
178
+ event: event.type,
179
+ port: event.port
180
+ }, event.cause);
181
+ return;
182
+ case "serving":
183
+ logger.info("serving", {
184
+ event: event.type,
185
+ runtime: event.runtime,
186
+ probePort: event.probePort,
187
+ ...typeof event.info === "object" && event.info !== null && !Array.isArray(event.info) ? event.info : {}
188
+ });
189
+ return;
190
+ case "draining":
191
+ logger.info("draining", {
192
+ event: event.type,
193
+ inFlight: event.inFlight
194
+ });
195
+ return;
196
+ case "drained":
197
+ logger.info("drained", {
198
+ event: event.type,
199
+ inFlightAtStart: event.report.inFlightAtStart,
200
+ completed: event.report.completed,
201
+ abandoned: event.report.abandoned
202
+ });
203
+ return;
204
+ default: logger.info(event.type, { event: event.type });
205
+ }
206
+ };
207
+ //#endregion
208
+ exports.LoggerConfig = LoggerConfig;
209
+ exports.createLogger = createLogger;
210
+ exports.jsonSink = jsonSink;
211
+ exports.kernelEvents = kernelEvents;
212
+ exports.logLevel = logLevel;
213
+ exports.observability = observability;
@@ -0,0 +1,81 @@
1
+ import { n as Sink, r as createLogger, t as Line } from "./logger-B1-_76ej.cjs";
2
+ import { ConfigField, ConfigInvalid, Env } from "@btravstack/config";
3
+ import { EventSink, Level, Logger, LoggerService } from "@btravstack/core";
4
+ import { Module } from "@btravstack/di";
5
+ //#region src/config.d.ts
6
+ /**
7
+ * `LOG_LEVEL`, as a `ConfigField` of the six levels and nothing else.
8
+ *
9
+ * A value outside the set is a `ConfigInvalid` naming the variable โ€” exit
10
+ * `78` under `runMain`, before a line is written โ€” rather than a silent
11
+ * fallback to `info`: a deployment that meant `debug` and typed `verbose`
12
+ * should be told, not quietly under-logged for a week. Built on
13
+ * `Config.string`, so it inherits the semantics every other variable has: an
14
+ * unset variable takes the default, a set-but-blank one is an error.
15
+ */
16
+ declare const logLevel: (options?: {
17
+ readonly default?: Level;
18
+ }) => ConfigField<Level>;
19
+ /** What `observability()` binds from the environment. */
20
+ type LoggerSettings = {
21
+ readonly level: Level;
22
+ };
23
+ //#endregion
24
+ //#region src/json-sink.d.ts
25
+ /**
26
+ * One JSON object per line on `stream`.
27
+ *
28
+ * The caller's attributes are spread FIRST and the line's own fields after them,
29
+ * and that order is the precedence: an `attributes: { level: "info" }` cannot
30
+ * rewrite an `error` line's severity, nor its `traceId`.
31
+ *
32
+ * The unit's ids are spread at the top level rather than nested under `unit`,
33
+ * because `traceId` is the field an operator searches.
34
+ */
35
+ declare const jsonSink: (stream?: {
36
+ readonly write: (chunk: string) => unknown;
37
+ }) => Sink;
38
+ //#endregion
39
+ //#region src/observability.d.ts
40
+ declare const LoggerConfig_base: import("@btravstack/di").PortClass<"LoggerConfig">;
41
+ /**
42
+ * What the graph bound from the environment: the level every logger in it
43
+ * filters at. A port of its own, like a starter's `HttpConfig`, so anything
44
+ * that wants to know reads it rather than re-deriving it.
45
+ */
46
+ declare class LoggerConfig extends LoggerConfig_base<LoggerSettings> {}
47
+ type ObservabilityOptions = {
48
+ /**
49
+ * Where lines go. Default: one JSON object per line on `stdout`. The
50
+ * `@btravstack/observability/pino` subpath is the same seam for a deployment
51
+ * that wants pino's throughput.
52
+ */
53
+ readonly sink?: Sink;
54
+ /** Pins the level instead of reading `LOG_LEVEL` โ€” a test's `"fatal"`, a CLI's `"debug"`. */
55
+ readonly level?: Level;
56
+ };
57
+ /**
58
+ * The observability starter: a module providing the application's `Logger` and
59
+ * the `LoggerConfig` it was built from. Import it next to the application and
60
+ * export `Logger`.
61
+ *
62
+ * Every line carries the ambient unit's `traceId`, because the logger reads
63
+ * `currentUnit()` per call โ€” so a request's lines are attributable with no
64
+ * argument threaded through the call stack.
65
+ */
66
+ declare const observability: (options?: ObservabilityOptions) => Module<Logger | LoggerConfig, ConfigInvalid, Env>;
67
+ /**
68
+ * The kernel's nine lifecycle events, as log lines on `logger` โ€” the adapter
69
+ * between `StartOptions.onEvent` and an application's own stream.
70
+ *
71
+ * The mapping is deliberate rather than mechanical: `startFailed` and `uncaught`
72
+ * are `error`, `teardownError` is `warn` (the application is already stopping
73
+ * and the exit code says so), everything else `info`. Each event's own fields
74
+ * become attributes, so a drain is queryable by field.
75
+ *
76
+ * The logger is a PARAMETER, not resolved: `building` is emitted while the graph
77
+ * is still being built, so the sink cannot come from the context it watches.
78
+ */
79
+ declare const kernelEvents: (logger: LoggerService) => EventSink;
80
+ //#endregion
81
+ export { type Line, LoggerConfig, type LoggerSettings, type ObservabilityOptions, type Sink, createLogger, jsonSink, kernelEvents, logLevel, observability };
@@ -0,0 +1,81 @@
1
+ import { n as Sink, r as createLogger, t as Line } from "./logger-B1-_76ej.mjs";
2
+ import { ConfigField, ConfigInvalid, Env } from "@btravstack/config";
3
+ import { EventSink, Level, Logger, LoggerService } from "@btravstack/core";
4
+ import { Module } from "@btravstack/di";
5
+ //#region src/config.d.ts
6
+ /**
7
+ * `LOG_LEVEL`, as a `ConfigField` of the six levels and nothing else.
8
+ *
9
+ * A value outside the set is a `ConfigInvalid` naming the variable โ€” exit
10
+ * `78` under `runMain`, before a line is written โ€” rather than a silent
11
+ * fallback to `info`: a deployment that meant `debug` and typed `verbose`
12
+ * should be told, not quietly under-logged for a week. Built on
13
+ * `Config.string`, so it inherits the semantics every other variable has: an
14
+ * unset variable takes the default, a set-but-blank one is an error.
15
+ */
16
+ declare const logLevel: (options?: {
17
+ readonly default?: Level;
18
+ }) => ConfigField<Level>;
19
+ /** What `observability()` binds from the environment. */
20
+ type LoggerSettings = {
21
+ readonly level: Level;
22
+ };
23
+ //#endregion
24
+ //#region src/json-sink.d.ts
25
+ /**
26
+ * One JSON object per line on `stream`.
27
+ *
28
+ * The caller's attributes are spread FIRST and the line's own fields after them,
29
+ * and that order is the precedence: an `attributes: { level: "info" }` cannot
30
+ * rewrite an `error` line's severity, nor its `traceId`.
31
+ *
32
+ * The unit's ids are spread at the top level rather than nested under `unit`,
33
+ * because `traceId` is the field an operator searches.
34
+ */
35
+ declare const jsonSink: (stream?: {
36
+ readonly write: (chunk: string) => unknown;
37
+ }) => Sink;
38
+ //#endregion
39
+ //#region src/observability.d.ts
40
+ declare const LoggerConfig_base: import("@btravstack/di").PortClass<"LoggerConfig">;
41
+ /**
42
+ * What the graph bound from the environment: the level every logger in it
43
+ * filters at. A port of its own, like a starter's `HttpConfig`, so anything
44
+ * that wants to know reads it rather than re-deriving it.
45
+ */
46
+ declare class LoggerConfig extends LoggerConfig_base<LoggerSettings> {}
47
+ type ObservabilityOptions = {
48
+ /**
49
+ * Where lines go. Default: one JSON object per line on `stdout`. The
50
+ * `@btravstack/observability/pino` subpath is the same seam for a deployment
51
+ * that wants pino's throughput.
52
+ */
53
+ readonly sink?: Sink;
54
+ /** Pins the level instead of reading `LOG_LEVEL` โ€” a test's `"fatal"`, a CLI's `"debug"`. */
55
+ readonly level?: Level;
56
+ };
57
+ /**
58
+ * The observability starter: a module providing the application's `Logger` and
59
+ * the `LoggerConfig` it was built from. Import it next to the application and
60
+ * export `Logger`.
61
+ *
62
+ * Every line carries the ambient unit's `traceId`, because the logger reads
63
+ * `currentUnit()` per call โ€” so a request's lines are attributable with no
64
+ * argument threaded through the call stack.
65
+ */
66
+ declare const observability: (options?: ObservabilityOptions) => Module<Logger | LoggerConfig, ConfigInvalid, Env>;
67
+ /**
68
+ * The kernel's nine lifecycle events, as log lines on `logger` โ€” the adapter
69
+ * between `StartOptions.onEvent` and an application's own stream.
70
+ *
71
+ * The mapping is deliberate rather than mechanical: `startFailed` and `uncaught`
72
+ * are `error`, `teardownError` is `warn` (the application is already stopping
73
+ * and the exit code says so), everything else `info`. Each event's own fields
74
+ * become attributes, so a drain is queryable by field.
75
+ *
76
+ * The logger is a PARAMETER, not resolved: `building` is emitted while the graph
77
+ * is still being built, so the sink cannot come from the context it watches.
78
+ */
79
+ declare const kernelEvents: (logger: LoggerService) => EventSink;
80
+ //#endregion
81
+ export { type Line, LoggerConfig, type LoggerSettings, type ObservabilityOptions, type Sink, createLogger, jsonSink, kernelEvents, logLevel, observability };
package/dist/index.mjs ADDED
@@ -0,0 +1,207 @@
1
+ import { Config, ConfigFieldInvalid, Env } from "@btravstack/config";
2
+ import { LEVELS, Logger, currentUnit } from "@btravstack/core";
3
+ import { Err, Ok } from "unthrown";
4
+ import { Module, Port, Provider } from "@btravstack/di";
5
+ //#region src/config.ts
6
+ /**
7
+ * `LOG_LEVEL`, as a `ConfigField` of the six levels and nothing else.
8
+ *
9
+ * A value outside the set is a `ConfigInvalid` naming the variable โ€” exit
10
+ * `78` under `runMain`, before a line is written โ€” rather than a silent
11
+ * fallback to `info`: a deployment that meant `debug` and typed `verbose`
12
+ * should be told, not quietly under-logged for a week. Built on
13
+ * `Config.string`, so it inherits the semantics every other variable has: an
14
+ * unset variable takes the default, a set-but-blank one is an error.
15
+ */
16
+ const logLevel = (options = {}) => {
17
+ const text = Config.string("LOG_LEVEL", { default: options.default ?? "info" });
18
+ return {
19
+ variable: text.variable,
20
+ parse: (raw) => text.parse(raw).flatMap((value) => LEVELS.includes(value) ? Ok(value) : Err(new ConfigFieldInvalid({ reason: `must be one of ${LEVELS.join(", ")}, got ${JSON.stringify(value)}` })))
21
+ };
22
+ };
23
+ /** The schema `observability()` binds `LoggerConfig` through โ€” one field today, and the place a second one lands. */
24
+ const loggerSchema = (level) => Config.object({ level: Config.pinned(level, logLevel()) });
25
+ //#endregion
26
+ //#region src/json-sink.ts
27
+ /**
28
+ * An `Error`'s `message` and `stack` are **non-enumerable**, so
29
+ * `JSON.stringify` renders a thrown one as `{}` โ€” the line that exists to carry
30
+ * a failure would carry nothing. Walks `cause` chains so a wrapped failure keeps
31
+ * its origin.
32
+ */
33
+ const renderCause = (cause, depth = 0) => {
34
+ if (!(cause instanceof Error)) return cause;
35
+ return {
36
+ name: cause.name,
37
+ message: cause.message,
38
+ stack: cause.stack,
39
+ ...cause.cause === void 0 || depth >= 4 ? {} : { cause: renderCause(cause.cause, depth + 1) }
40
+ };
41
+ };
42
+ /**
43
+ * One JSON object per line on `stream`.
44
+ *
45
+ * The caller's attributes are spread FIRST and the line's own fields after them,
46
+ * and that order is the precedence: an `attributes: { level: "info" }` cannot
47
+ * rewrite an `error` line's severity, nor its `traceId`.
48
+ *
49
+ * The unit's ids are spread at the top level rather than nested under `unit`,
50
+ * because `traceId` is the field an operator searches.
51
+ */
52
+ const jsonSink = (stream = process.stdout) => (line) => {
53
+ const { level, message, attributes, cause, time, unit } = line;
54
+ const rendered = {
55
+ ...attributes,
56
+ time: new Date(time).toISOString(),
57
+ level,
58
+ message,
59
+ ...unit === void 0 ? {} : unit,
60
+ ...cause === void 0 ? {} : { cause: renderCause(cause) }
61
+ };
62
+ stream.write(`${safeStringify(rendered)}\n`);
63
+ };
64
+ const safeStringify = (rendered) => {
65
+ try {
66
+ return JSON.stringify(rendered);
67
+ } catch {
68
+ return JSON.stringify({
69
+ time: rendered["time"],
70
+ level: rendered["level"],
71
+ message: rendered["message"],
72
+ cause: "[unserialisable]"
73
+ });
74
+ }
75
+ };
76
+ //#endregion
77
+ //#region src/logger.ts
78
+ const severity = (level) => LEVELS.indexOf(level);
79
+ /**
80
+ * A logger over `sink`, filtered at `level` and correlated with the ambient
81
+ * unit.
82
+ *
83
+ * The correlation is read **per call**, not captured: one logger is built per
84
+ * scope and every unit the kernel opens has its own record, so a logger that
85
+ * captured it at construction would stamp the wrong trace id on every line but
86
+ * the first. `with` layers attributes and nothing else, so a child costs one
87
+ * object and shares the sink.
88
+ *
89
+ * Every path is wrapped: a sink that throws is swallowed here, because a
90
+ * logger that takes the process down is worse than a line nobody sees.
91
+ */
92
+ const createLogger = (sink, level = "info") => {
93
+ const floor = severity(level);
94
+ const build = (base) => {
95
+ const write = (lineLevel, message, attributes, cause) => {
96
+ if (severity(lineLevel) < floor) return;
97
+ const unit = currentUnit();
98
+ try {
99
+ sink({
100
+ level: lineLevel,
101
+ message,
102
+ attributes: attributes === void 0 ? base : {
103
+ ...base,
104
+ ...attributes
105
+ },
106
+ cause,
107
+ time: Date.now(),
108
+ unit: unit === void 0 ? void 0 : {
109
+ unitId: unit.unitId,
110
+ traceId: unit.traceId,
111
+ ...unit.tenantId === void 0 ? {} : { tenantId: unit.tenantId }
112
+ }
113
+ });
114
+ } catch {}
115
+ };
116
+ return {
117
+ log: (lineLevel, message, attributes, cause) => write(lineLevel, message, attributes, cause),
118
+ trace: (message, attributes, cause) => write("trace", message, attributes, cause),
119
+ debug: (message, attributes, cause) => write("debug", message, attributes, cause),
120
+ info: (message, attributes, cause) => write("info", message, attributes, cause),
121
+ warn: (message, attributes, cause) => write("warn", message, attributes, cause),
122
+ error: (message, attributes, cause) => write("error", message, attributes, cause),
123
+ fatal: (message, attributes, cause) => write("fatal", message, attributes, cause),
124
+ with: (attributes) => build({
125
+ ...base,
126
+ ...attributes
127
+ }),
128
+ isEnabled: (lineLevel) => severity(lineLevel) >= floor
129
+ };
130
+ };
131
+ return build({});
132
+ };
133
+ //#endregion
134
+ //#region src/observability.ts
135
+ /**
136
+ * What the graph bound from the environment: the level every logger in it
137
+ * filters at. A port of its own, like a starter's `HttpConfig`, so anything
138
+ * that wants to know reads it rather than re-deriving it.
139
+ */
140
+ var LoggerConfig = class extends Port("LoggerConfig") {};
141
+ /**
142
+ * The observability starter: a module providing the application's `Logger` and
143
+ * the `LoggerConfig` it was built from. Import it next to the application and
144
+ * export `Logger`.
145
+ *
146
+ * Every line carries the ambient unit's `traceId`, because the logger reads
147
+ * `currentUnit()` per call โ€” so a request's lines are attributable with no
148
+ * argument threaded through the call stack.
149
+ */
150
+ const observability = (options = {}) => Module("Observability")({
151
+ needs: [Env],
152
+ provides: [Config.provider(LoggerConfig)(loggerSchema(options.level)), Provider(Logger)({ config: LoggerConfig }, { sync: ({ config }) => createLogger(options.sink ?? jsonSink(), config.level) })],
153
+ exports: [Logger, LoggerConfig]
154
+ });
155
+ /**
156
+ * The kernel's nine lifecycle events, as log lines on `logger` โ€” the adapter
157
+ * between `StartOptions.onEvent` and an application's own stream.
158
+ *
159
+ * The mapping is deliberate rather than mechanical: `startFailed` and `uncaught`
160
+ * are `error`, `teardownError` is `warn` (the application is already stopping
161
+ * and the exit code says so), everything else `info`. Each event's own fields
162
+ * become attributes, so a drain is queryable by field.
163
+ *
164
+ * The logger is a PARAMETER, not resolved: `building` is emitted while the graph
165
+ * is still being built, so the sink cannot come from the context it watches.
166
+ */
167
+ const kernelEvents = (logger) => (event) => {
168
+ switch (event.type) {
169
+ case "startFailed":
170
+ logger.error("the application failed to start", { event: event.type }, event.cause);
171
+ return;
172
+ case "uncaught":
173
+ logger.error("an uncaught exception stopped the application", { event: event.type }, event.cause);
174
+ return;
175
+ case "teardownError":
176
+ logger.warn("a finaliser failed while the application was stopping", {
177
+ event: event.type,
178
+ port: event.port
179
+ }, event.cause);
180
+ return;
181
+ case "serving":
182
+ logger.info("serving", {
183
+ event: event.type,
184
+ runtime: event.runtime,
185
+ probePort: event.probePort,
186
+ ...typeof event.info === "object" && event.info !== null && !Array.isArray(event.info) ? event.info : {}
187
+ });
188
+ return;
189
+ case "draining":
190
+ logger.info("draining", {
191
+ event: event.type,
192
+ inFlight: event.inFlight
193
+ });
194
+ return;
195
+ case "drained":
196
+ logger.info("drained", {
197
+ event: event.type,
198
+ inFlightAtStart: event.report.inFlightAtStart,
199
+ completed: event.report.completed,
200
+ abandoned: event.report.abandoned
201
+ });
202
+ return;
203
+ default: logger.info(event.type, { event: event.type });
204
+ }
205
+ };
206
+ //#endregion
207
+ export { LoggerConfig, createLogger, jsonSink, kernelEvents, logLevel, observability };
@@ -0,0 +1,35 @@
1
+ import { Attributes, Level, LoggerService } from "@btravstack/core";
2
+ //#region src/logger.d.ts
3
+ /** One line, as the implementation hands it to a {@link Sink}: the message, its severity, and everything known about it. */
4
+ type Line = {
5
+ readonly level: Level;
6
+ readonly message: string;
7
+ readonly attributes: Attributes;
8
+ readonly cause: unknown;
9
+ /** Milliseconds since the epoch, stamped when the line was written. */
10
+ readonly time: number;
11
+ /** What `currentUnit()` carried, or `undefined` outside a unit. */
12
+ readonly unit: {
13
+ readonly unitId: string;
14
+ readonly traceId: string;
15
+ readonly tenantId?: string;
16
+ } | undefined;
17
+ };
18
+ /** Where a line goes. Given a {@link Line}, writes it โ€” and never throws, which `createLogger` guarantees on its behalf. */
19
+ type Sink = (line: Line) => void;
20
+ /**
21
+ * A logger over `sink`, filtered at `level` and correlated with the ambient
22
+ * unit.
23
+ *
24
+ * The correlation is read **per call**, not captured: one logger is built per
25
+ * scope and every unit the kernel opens has its own record, so a logger that
26
+ * captured it at construction would stamp the wrong trace id on every line but
27
+ * the first. `with` layers attributes and nothing else, so a child costs one
28
+ * object and shares the sink.
29
+ *
30
+ * Every path is wrapped: a sink that throws is swallowed here, because a
31
+ * logger that takes the process down is worse than a line nobody sees.
32
+ */
33
+ declare const createLogger: (sink: Sink, level?: Level) => LoggerService;
34
+ //#endregion
35
+ export { Sink as n, createLogger as r, Line as t };
@@ -0,0 +1,35 @@
1
+ import { Attributes, Level, LoggerService } from "@btravstack/core";
2
+ //#region src/logger.d.ts
3
+ /** One line, as the implementation hands it to a {@link Sink}: the message, its severity, and everything known about it. */
4
+ type Line = {
5
+ readonly level: Level;
6
+ readonly message: string;
7
+ readonly attributes: Attributes;
8
+ readonly cause: unknown;
9
+ /** Milliseconds since the epoch, stamped when the line was written. */
10
+ readonly time: number;
11
+ /** What `currentUnit()` carried, or `undefined` outside a unit. */
12
+ readonly unit: {
13
+ readonly unitId: string;
14
+ readonly traceId: string;
15
+ readonly tenantId?: string;
16
+ } | undefined;
17
+ };
18
+ /** Where a line goes. Given a {@link Line}, writes it โ€” and never throws, which `createLogger` guarantees on its behalf. */
19
+ type Sink = (line: Line) => void;
20
+ /**
21
+ * A logger over `sink`, filtered at `level` and correlated with the ambient
22
+ * unit.
23
+ *
24
+ * The correlation is read **per call**, not captured: one logger is built per
25
+ * scope and every unit the kernel opens has its own record, so a logger that
26
+ * captured it at construction would stamp the wrong trace id on every line but
27
+ * the first. `with` layers attributes and nothing else, so a child costs one
28
+ * object and shares the sink.
29
+ *
30
+ * Every path is wrapped: a sink that throws is swallowed here, because a
31
+ * logger that takes the process down is worse than a line nobody sees.
32
+ */
33
+ declare const createLogger: (sink: Sink, level?: Level) => LoggerService;
34
+ //#endregion
35
+ export { Sink as n, createLogger as r, Line as t };
package/dist/otel.cjs ADDED
@@ -0,0 +1,90 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _btravstack_core = require("@btravstack/core");
3
+ let unthrown = require("unthrown");
4
+ let _btravstack_di = require("@btravstack/di");
5
+ let _opentelemetry_api = require("@opentelemetry/api");
6
+ let _opentelemetry_sdk_node = require("@opentelemetry/sdk-node");
7
+ //#region src/otel.ts
8
+ /**
9
+ * The tracing half of observability โ€” the ADAPTER, not the contract. `Tracer`
10
+ * and `Meter` are the kernel's ports, and OTel's presence stops at this subpath,
11
+ * behind an optional peer exactly like `pino`.
12
+ *
13
+ * The ports are narrowings of OTel's own shapes, so what the SDK hands back
14
+ * satisfies them with no translation in between.
15
+ */
16
+ /**
17
+ * The SDK itself, module-private: providing it is what starts it, and `release`
18
+ * is what flushes it. It rides the graph as a RESOURCE so the kernel closes it
19
+ * on every exit path โ€” a span lost in `shutdown()` becomes a `teardownError` and
20
+ * exit `2`, never silence.
21
+ */
22
+ var OtelSdk = class extends (0, _btravstack_di.Port)("OtelSdk") {};
23
+ /**
24
+ * The OTel starter: a module providing `Tracer` and `Meter` over a `NodeSDK`
25
+ * opened with the scope and flushed on close. Compose it beside
26
+ * `observability()` in a root's `imports`.
27
+ *
28
+ * **No config slice, deliberately**: the SDK reads the `OTEL_*` environment
29
+ * conventions itself, and re-binding them through `Config` would be a second
30
+ * spelling of names operators already know. Programmatic overrides go through
31
+ * `options`, the SDK's own configuration type.
32
+ *
33
+ * **Auto-instrumentation cannot live here**: the register hook must be
34
+ * preloaded before the instrumented libraries are imported, which no DI
35
+ * provider can promise. Preloading is the deployment's line.
36
+ */
37
+ const otel = (options) => (0, _btravstack_di.Module)("Otel")({
38
+ provides: [
39
+ (0, _btravstack_di.Provider)(OtelSdk)({
40
+ acquire: () => {
41
+ const sdk = new _opentelemetry_sdk_node.NodeSDK(options);
42
+ sdk.start();
43
+ return (0, unthrown.Ok)(sdk);
44
+ },
45
+ release: (sdk) => sdk.shutdown()
46
+ }),
47
+ (0, _btravstack_di.Provider)(_btravstack_core.Tracer)({ sdk: OtelSdk }, { sync: () => {
48
+ const tracer = _opentelemetry_api.trace.getTracer("@btravstack/observability");
49
+ return { startSpan: (name) => tracer.startSpan(name) };
50
+ } }),
51
+ (0, _btravstack_di.Provider)(_btravstack_core.Meter)({ sdk: OtelSdk }, { sync: () => _opentelemetry_api.metrics.getMeter("@btravstack/observability") })
52
+ ],
53
+ exports: [_btravstack_core.Tracer, _btravstack_core.Meter]
54
+ });
55
+ /** The span a unit rides, ended when the unit's fork closes. */
56
+ var UnitSpan = class extends (0, _btravstack_di.Port)("UnitSpan") {};
57
+ /**
58
+ * A span per kernel unit, as a `StartOptions.unit` module: the kernel forks it
59
+ * per unit and tears it down inside the unit's ambient record, so the span opens
60
+ * when the unit does and `onStop` ends it on every path out.
61
+ *
62
+ * The correlation is the ambient record's own, carried as attributes so a span
63
+ * joins the same query the logger's lines answer. The remote PARENT is
64
+ * deliberately not reconstructed: `UnitMeta.traceId` carries the inbound trace
65
+ * id alone, so this correlates by attribute rather than pretending to a W3C
66
+ * parent-child edge it cannot prove.
67
+ *
68
+ * Compose it as `start`'s `unit`; the root must export `Tracer`.
69
+ */
70
+ const UnitSpanModule = (0, _btravstack_di.Module)("UnitSpan")({
71
+ needs: [_btravstack_core.Tracer],
72
+ provides: [(0, _btravstack_di.Provider)(UnitSpan)({ tracer: _btravstack_core.Tracer }, {
73
+ sync: ({ tracer }) => {
74
+ const unit = (0, _btravstack_core.currentUnit)();
75
+ const span = tracer.startSpan("unit");
76
+ if (unit !== void 0) span.setAttributes({
77
+ "btravstack.unit_id": unit.unitId,
78
+ "btravstack.trace_id": unit.traceId,
79
+ ...unit.tenantId === void 0 ? {} : { "btravstack.tenant_id": unit.tenantId }
80
+ });
81
+ return span;
82
+ },
83
+ onStop: (span) => span.end()
84
+ })],
85
+ exports: [UnitSpan]
86
+ });
87
+ //#endregion
88
+ exports.UnitSpan = UnitSpan;
89
+ exports.UnitSpanModule = UnitSpanModule;
90
+ exports.otel = otel;
@@ -0,0 +1,38 @@
1
+ import { Meter, Span, Tracer } from "@btravstack/core";
2
+ import { Module, Scope } from "@btravstack/di";
3
+ import { NodeSDKConfiguration } from "@opentelemetry/sdk-node";
4
+ //#region src/otel.d.ts
5
+ /**
6
+ * The OTel starter: a module providing `Tracer` and `Meter` over a `NodeSDK`
7
+ * opened with the scope and flushed on close. Compose it beside
8
+ * `observability()` in a root's `imports`.
9
+ *
10
+ * **No config slice, deliberately**: the SDK reads the `OTEL_*` environment
11
+ * conventions itself, and re-binding them through `Config` would be a second
12
+ * spelling of names operators already know. Programmatic overrides go through
13
+ * `options`, the SDK's own configuration type.
14
+ *
15
+ * **Auto-instrumentation cannot live here**: the register hook must be
16
+ * preloaded before the instrumented libraries are imported, which no DI
17
+ * provider can promise. Preloading is the deployment's line.
18
+ */
19
+ declare const otel: (options?: Partial<NodeSDKConfiguration>) => Module<Tracer | Meter, never, Scope>;
20
+ declare const UnitSpan_base: import("@btravstack/di").PortClass<"UnitSpan">;
21
+ /** The span a unit rides, ended when the unit's fork closes. */
22
+ declare class UnitSpan extends UnitSpan_base<Span> {}
23
+ /**
24
+ * A span per kernel unit, as a `StartOptions.unit` module: the kernel forks it
25
+ * per unit and tears it down inside the unit's ambient record, so the span opens
26
+ * when the unit does and `onStop` ends it on every path out.
27
+ *
28
+ * The correlation is the ambient record's own, carried as attributes so a span
29
+ * joins the same query the logger's lines answer. The remote PARENT is
30
+ * deliberately not reconstructed: `UnitMeta.traceId` carries the inbound trace
31
+ * id alone, so this correlates by attribute rather than pretending to a W3C
32
+ * parent-child edge it cannot prove.
33
+ *
34
+ * Compose it as `start`'s `unit`; the root must export `Tracer`.
35
+ */
36
+ declare const UnitSpanModule: Module<UnitSpan, never, Scope | Tracer>;
37
+ //#endregion
38
+ export { UnitSpan, UnitSpanModule, otel };
@@ -0,0 +1,38 @@
1
+ import { Meter, Span, Tracer } from "@btravstack/core";
2
+ import { Module, Scope } from "@btravstack/di";
3
+ import { NodeSDKConfiguration } from "@opentelemetry/sdk-node";
4
+ //#region src/otel.d.ts
5
+ /**
6
+ * The OTel starter: a module providing `Tracer` and `Meter` over a `NodeSDK`
7
+ * opened with the scope and flushed on close. Compose it beside
8
+ * `observability()` in a root's `imports`.
9
+ *
10
+ * **No config slice, deliberately**: the SDK reads the `OTEL_*` environment
11
+ * conventions itself, and re-binding them through `Config` would be a second
12
+ * spelling of names operators already know. Programmatic overrides go through
13
+ * `options`, the SDK's own configuration type.
14
+ *
15
+ * **Auto-instrumentation cannot live here**: the register hook must be
16
+ * preloaded before the instrumented libraries are imported, which no DI
17
+ * provider can promise. Preloading is the deployment's line.
18
+ */
19
+ declare const otel: (options?: Partial<NodeSDKConfiguration>) => Module<Tracer | Meter, never, Scope>;
20
+ declare const UnitSpan_base: import("@btravstack/di").PortClass<"UnitSpan">;
21
+ /** The span a unit rides, ended when the unit's fork closes. */
22
+ declare class UnitSpan extends UnitSpan_base<Span> {}
23
+ /**
24
+ * A span per kernel unit, as a `StartOptions.unit` module: the kernel forks it
25
+ * per unit and tears it down inside the unit's ambient record, so the span opens
26
+ * when the unit does and `onStop` ends it on every path out.
27
+ *
28
+ * The correlation is the ambient record's own, carried as attributes so a span
29
+ * joins the same query the logger's lines answer. The remote PARENT is
30
+ * deliberately not reconstructed: `UnitMeta.traceId` carries the inbound trace
31
+ * id alone, so this correlates by attribute rather than pretending to a W3C
32
+ * parent-child edge it cannot prove.
33
+ *
34
+ * Compose it as `start`'s `unit`; the root must export `Tracer`.
35
+ */
36
+ declare const UnitSpanModule: Module<UnitSpan, never, Scope | Tracer>;
37
+ //#endregion
38
+ export { UnitSpan, UnitSpanModule, otel };
package/dist/otel.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import { Meter, Tracer, currentUnit } from "@btravstack/core";
2
+ import { Ok } from "unthrown";
3
+ import { Module, Port, Provider } from "@btravstack/di";
4
+ import { metrics, trace } from "@opentelemetry/api";
5
+ import { NodeSDK } from "@opentelemetry/sdk-node";
6
+ //#region src/otel.ts
7
+ /**
8
+ * The tracing half of observability โ€” the ADAPTER, not the contract. `Tracer`
9
+ * and `Meter` are the kernel's ports, and OTel's presence stops at this subpath,
10
+ * behind an optional peer exactly like `pino`.
11
+ *
12
+ * The ports are narrowings of OTel's own shapes, so what the SDK hands back
13
+ * satisfies them with no translation in between.
14
+ */
15
+ /**
16
+ * The SDK itself, module-private: providing it is what starts it, and `release`
17
+ * is what flushes it. It rides the graph as a RESOURCE so the kernel closes it
18
+ * on every exit path โ€” a span lost in `shutdown()` becomes a `teardownError` and
19
+ * exit `2`, never silence.
20
+ */
21
+ var OtelSdk = class extends Port("OtelSdk") {};
22
+ /**
23
+ * The OTel starter: a module providing `Tracer` and `Meter` over a `NodeSDK`
24
+ * opened with the scope and flushed on close. Compose it beside
25
+ * `observability()` in a root's `imports`.
26
+ *
27
+ * **No config slice, deliberately**: the SDK reads the `OTEL_*` environment
28
+ * conventions itself, and re-binding them through `Config` would be a second
29
+ * spelling of names operators already know. Programmatic overrides go through
30
+ * `options`, the SDK's own configuration type.
31
+ *
32
+ * **Auto-instrumentation cannot live here**: the register hook must be
33
+ * preloaded before the instrumented libraries are imported, which no DI
34
+ * provider can promise. Preloading is the deployment's line.
35
+ */
36
+ const otel = (options) => Module("Otel")({
37
+ provides: [
38
+ Provider(OtelSdk)({
39
+ acquire: () => {
40
+ const sdk = new NodeSDK(options);
41
+ sdk.start();
42
+ return Ok(sdk);
43
+ },
44
+ release: (sdk) => sdk.shutdown()
45
+ }),
46
+ Provider(Tracer)({ sdk: OtelSdk }, { sync: () => {
47
+ const tracer = trace.getTracer("@btravstack/observability");
48
+ return { startSpan: (name) => tracer.startSpan(name) };
49
+ } }),
50
+ Provider(Meter)({ sdk: OtelSdk }, { sync: () => metrics.getMeter("@btravstack/observability") })
51
+ ],
52
+ exports: [Tracer, Meter]
53
+ });
54
+ /** The span a unit rides, ended when the unit's fork closes. */
55
+ var UnitSpan = class extends Port("UnitSpan") {};
56
+ /**
57
+ * A span per kernel unit, as a `StartOptions.unit` module: the kernel forks it
58
+ * per unit and tears it down inside the unit's ambient record, so the span opens
59
+ * when the unit does and `onStop` ends it on every path out.
60
+ *
61
+ * The correlation is the ambient record's own, carried as attributes so a span
62
+ * joins the same query the logger's lines answer. The remote PARENT is
63
+ * deliberately not reconstructed: `UnitMeta.traceId` carries the inbound trace
64
+ * id alone, so this correlates by attribute rather than pretending to a W3C
65
+ * parent-child edge it cannot prove.
66
+ *
67
+ * Compose it as `start`'s `unit`; the root must export `Tracer`.
68
+ */
69
+ const UnitSpanModule = Module("UnitSpan")({
70
+ needs: [Tracer],
71
+ provides: [Provider(UnitSpan)({ tracer: Tracer }, {
72
+ sync: ({ tracer }) => {
73
+ const unit = currentUnit();
74
+ const span = tracer.startSpan("unit");
75
+ if (unit !== void 0) span.setAttributes({
76
+ "btravstack.unit_id": unit.unitId,
77
+ "btravstack.trace_id": unit.traceId,
78
+ ...unit.tenantId === void 0 ? {} : { "btravstack.tenant_id": unit.tenantId }
79
+ });
80
+ return span;
81
+ },
82
+ onStop: (span) => span.end()
83
+ })],
84
+ exports: [UnitSpan]
85
+ });
86
+ //#endregion
87
+ export { UnitSpan, UnitSpanModule, otel };
package/dist/pino.cjs ADDED
@@ -0,0 +1,34 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/pino.ts
3
+ /**
4
+ * A {@link Sink} over a pino logger โ€” the subpath a deployment reaches for
5
+ * when the default JSON sink's `JSON.stringify` per line is the thing showing
6
+ * up in a profile. `pino` is an **optional** peer: install it, import
7
+ * `@btravstack/observability/pino`, and pass the sink; a consumer that never
8
+ * imports this file never needs it.
9
+ *
10
+ * ```ts
11
+ * import pino from "pino";
12
+ * import { observability } from "@btravstack/observability";
13
+ * import { pinoSink } from "@btravstack/observability/pino";
14
+ *
15
+ * observability({ sink: pinoSink(pino()) });
16
+ * ```
17
+ *
18
+ * The level filter stays **ours**: `createLogger` has already decided the
19
+ * line is worth writing by the time a sink sees it, so pino is configured at
20
+ * `trace` here โ€” one filter in the process, and it is the one `LOG_LEVEL`
21
+ * validated at startup. `fatal` and `trace` map to pino's own; the ambient
22
+ * unit's ids ride as fields, not as a message prefix, so they stay indexable.
23
+ */
24
+ const pinoSink = (logger) => (line) => {
25
+ const { level, message, attributes, cause, unit } = line;
26
+ const fields = {
27
+ ...attributes,
28
+ ...unit === void 0 ? {} : unit,
29
+ ...cause === void 0 ? {} : { err: cause }
30
+ };
31
+ logger[level](fields, message);
32
+ };
33
+ //#endregion
34
+ exports.pinoSink = pinoSink;
@@ -0,0 +1,27 @@
1
+ import { n as Sink } from "./logger-B1-_76ej.cjs";
2
+ import { Logger } from "pino";
3
+ //#region src/pino.d.ts
4
+ /**
5
+ * A {@link Sink} over a pino logger โ€” the subpath a deployment reaches for
6
+ * when the default JSON sink's `JSON.stringify` per line is the thing showing
7
+ * up in a profile. `pino` is an **optional** peer: install it, import
8
+ * `@btravstack/observability/pino`, and pass the sink; a consumer that never
9
+ * imports this file never needs it.
10
+ *
11
+ * ```ts
12
+ * import pino from "pino";
13
+ * import { observability } from "@btravstack/observability";
14
+ * import { pinoSink } from "@btravstack/observability/pino";
15
+ *
16
+ * observability({ sink: pinoSink(pino()) });
17
+ * ```
18
+ *
19
+ * The level filter stays **ours**: `createLogger` has already decided the
20
+ * line is worth writing by the time a sink sees it, so pino is configured at
21
+ * `trace` here โ€” one filter in the process, and it is the one `LOG_LEVEL`
22
+ * validated at startup. `fatal` and `trace` map to pino's own; the ambient
23
+ * unit's ids ride as fields, not as a message prefix, so they stay indexable.
24
+ */
25
+ declare const pinoSink: (logger: Logger) => Sink;
26
+ //#endregion
27
+ export { pinoSink };
@@ -0,0 +1,27 @@
1
+ import { n as Sink } from "./logger-B1-_76ej.mjs";
2
+ import { Logger } from "pino";
3
+ //#region src/pino.d.ts
4
+ /**
5
+ * A {@link Sink} over a pino logger โ€” the subpath a deployment reaches for
6
+ * when the default JSON sink's `JSON.stringify` per line is the thing showing
7
+ * up in a profile. `pino` is an **optional** peer: install it, import
8
+ * `@btravstack/observability/pino`, and pass the sink; a consumer that never
9
+ * imports this file never needs it.
10
+ *
11
+ * ```ts
12
+ * import pino from "pino";
13
+ * import { observability } from "@btravstack/observability";
14
+ * import { pinoSink } from "@btravstack/observability/pino";
15
+ *
16
+ * observability({ sink: pinoSink(pino()) });
17
+ * ```
18
+ *
19
+ * The level filter stays **ours**: `createLogger` has already decided the
20
+ * line is worth writing by the time a sink sees it, so pino is configured at
21
+ * `trace` here โ€” one filter in the process, and it is the one `LOG_LEVEL`
22
+ * validated at startup. `fatal` and `trace` map to pino's own; the ambient
23
+ * unit's ids ride as fields, not as a message prefix, so they stay indexable.
24
+ */
25
+ declare const pinoSink: (logger: Logger) => Sink;
26
+ //#endregion
27
+ export { pinoSink };
package/dist/pino.mjs ADDED
@@ -0,0 +1,33 @@
1
+ //#region src/pino.ts
2
+ /**
3
+ * A {@link Sink} over a pino logger โ€” the subpath a deployment reaches for
4
+ * when the default JSON sink's `JSON.stringify` per line is the thing showing
5
+ * up in a profile. `pino` is an **optional** peer: install it, import
6
+ * `@btravstack/observability/pino`, and pass the sink; a consumer that never
7
+ * imports this file never needs it.
8
+ *
9
+ * ```ts
10
+ * import pino from "pino";
11
+ * import { observability } from "@btravstack/observability";
12
+ * import { pinoSink } from "@btravstack/observability/pino";
13
+ *
14
+ * observability({ sink: pinoSink(pino()) });
15
+ * ```
16
+ *
17
+ * The level filter stays **ours**: `createLogger` has already decided the
18
+ * line is worth writing by the time a sink sees it, so pino is configured at
19
+ * `trace` here โ€” one filter in the process, and it is the one `LOG_LEVEL`
20
+ * validated at startup. `fatal` and `trace` map to pino's own; the ambient
21
+ * unit's ids ride as fields, not as a message prefix, so they stay indexable.
22
+ */
23
+ const pinoSink = (logger) => (line) => {
24
+ const { level, message, attributes, cause, unit } = line;
25
+ const fields = {
26
+ ...attributes,
27
+ ...unit === void 0 ? {} : unit,
28
+ ...cause === void 0 ? {} : { err: cause }
29
+ };
30
+ logger[level](fields, message);
31
+ };
32
+ //#endregion
33
+ export { pinoSink };
package/package.json ADDED
@@ -0,0 +1,114 @@
1
+ {
2
+ "name": "@btravstack/observability",
3
+ "version": "0.3.0",
4
+ "description": "Observability for @btravstack/core: a strict Logger port correlated with the kernel's units, a dependency-free JSON sink, and the lifecycle events as log lines",
5
+ "keywords": [
6
+ "dependency-injection",
7
+ "logger",
8
+ "logging",
9
+ "observability",
10
+ "opentelemetry",
11
+ "structured-logging",
12
+ "typescript",
13
+ "unthrown"
14
+ ],
15
+ "homepage": "https://github.com/btravstack/btravstack#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/btravstack/btravstack/issues"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/btravstack/btravstack.git",
24
+ "directory": "packages/observability"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.cjs",
32
+ "module": "./dist/index.mjs",
33
+ "types": "./dist/index.d.cts",
34
+ "exports": {
35
+ ".": {
36
+ "import": {
37
+ "types": "./dist/index.d.mts",
38
+ "default": "./dist/index.mjs"
39
+ },
40
+ "require": {
41
+ "types": "./dist/index.d.cts",
42
+ "default": "./dist/index.cjs"
43
+ }
44
+ },
45
+ "./pino": {
46
+ "import": {
47
+ "types": "./dist/pino.d.mts",
48
+ "default": "./dist/pino.mjs"
49
+ },
50
+ "require": {
51
+ "types": "./dist/pino.d.cts",
52
+ "default": "./dist/pino.cjs"
53
+ }
54
+ },
55
+ "./otel": {
56
+ "import": {
57
+ "types": "./dist/otel.d.mts",
58
+ "default": "./dist/otel.mjs"
59
+ },
60
+ "require": {
61
+ "types": "./dist/otel.d.cts",
62
+ "default": "./dist/otel.cjs"
63
+ }
64
+ },
65
+ "./package.json": "./package.json"
66
+ },
67
+ "devDependencies": {
68
+ "@btravstack/tsconfig": "0.2.0",
69
+ "@opentelemetry/api": "1.9.1",
70
+ "@opentelemetry/sdk-node": "0.221.0",
71
+ "@opentelemetry/sdk-trace": "2.10.0",
72
+ "@types/node": "26.2.0",
73
+ "@unthrown/vitest": "5.7.0",
74
+ "@vitest/coverage-v8": "4.1.11",
75
+ "pino": "10.3.1",
76
+ "tsdown": "0.22.14",
77
+ "typescript": "7.0.2",
78
+ "unthrown": "5.7.0",
79
+ "vitest": "4.1.11",
80
+ "@btravstack/config": "0.3.0",
81
+ "@btravstack/di": "0.3.0",
82
+ "@btravstack/testing": "0.3.0",
83
+ "@btravstack/core": "0.3.0"
84
+ },
85
+ "peerDependencies": {
86
+ "@opentelemetry/api": "^1.9.0",
87
+ "@opentelemetry/sdk-node": "^0.221.0",
88
+ "pino": "^10",
89
+ "unthrown": "^5.0.0",
90
+ "@btravstack/config": "^0.3.0",
91
+ "@btravstack/core": "^0.3.0",
92
+ "@btravstack/di": "^0.3.0"
93
+ },
94
+ "peerDependenciesMeta": {
95
+ "@opentelemetry/api": {
96
+ "optional": true
97
+ },
98
+ "@opentelemetry/sdk-node": {
99
+ "optional": true
100
+ },
101
+ "pino": {
102
+ "optional": true
103
+ }
104
+ },
105
+ "engines": {
106
+ "node": ">=20"
107
+ },
108
+ "scripts": {
109
+ "build": "tsdown src/index.ts src/pino.ts src/otel.ts --format cjs,esm --dts --clean",
110
+ "dev": "tsdown src/index.ts src/pino.ts --format cjs,esm --dts --watch",
111
+ "test": "vitest run --coverage",
112
+ "typecheck": "tsc --noEmit"
113
+ }
114
+ }