@ontrails/observability 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,291 @@
1
+ # @ontrails/observability
2
+
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [`b3ddf91`](https://github.com/outfitter-dev/trails/commit/b3ddf918ada8211f44983512e2bbdfbc5b66d722): Prepare the first normal Trails release at `0.2.0` on `latest`, replacing the unpublished 1.0.0 source release. The target advances the original `0.1.0` source minor, which was never published under the current package names. Public packages remain in lockstep. Update consumer installation guidance and provide a temporary manifest bridge for old 1.0 beta sources. Published beta versions remain unchanged; minor 0.x releases may carry documented breaking changes.
8
+
9
+ The `1.0.0` section retained below records an unpublished preparation, not an npm release. Its accumulated changes are included in `0.2.0`; the section remains as source history alongside the published beta entries.
10
+
11
+ ## 1.0.0
12
+
13
+ ### Major Changes
14
+
15
+ - [`113aed6`](https://github.com/outfitter-dev/trails/commit/113aed62d20041e35b0cf9d6c1b1a18df4b88f57): Rename the dependency-light observability owner from `@ontrails/observe` to
16
+ `@ontrails/observability` as a pre-v1 hard cut. Update dependent packages,
17
+ documentation, package discovery, and the governed Regrade route; no
18
+ compatibility package or old import route is retained.
19
+ - [`0938e7b`](https://github.com/outfitter-dev/trails/commit/0938e7badc0c5470d194139d642b673658d099e0): Fold the removed `@ontrails/tracing` package into the truthful existing
20
+ owners: intrinsic trace contracts remain in core, developer-state tooling now
21
+ lives at `@ontrails/observability/dev`, and the dependency-light OTel adapter
22
+ lives at `@ontrails/observability/otel`. There is intentionally no root-package
23
+ compatibility redirect because the former root had more than one owner.
24
+ - [`50e2779`](https://github.com/outfitter-dev/trails/commit/50e27796d074851bccd57d7df009db749757b457): Extract the real `@ontrails/logtape` and `@ontrails/pino` adapters from the
25
+ temporary observability subpaths. The new packages own their namesake foreign
26
+ dependencies and preserve Trails record metadata, levels, redaction boundaries,
27
+ and lifecycle behavior; the old subpaths are removed in the pre-v1 hard cut.
28
+
29
+ Add governed Regrade transitions for both exact import replacements and expose
30
+ the observability adapter target through the shared adapter readiness check.
31
+
32
+ ### Patch Changes
33
+
34
+ - [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
35
+ to derive/derived for contract-owned fact production and render/rendered for
36
+ surface presentation. Public type, helper, rule, relation, and report names move
37
+ without compatibility aliases; ordinary repository/project nouns remain
38
+ explicit preserves or structured review inventory.
39
+ - [`b1fbe57`](https://github.com/outfitter-dev/trails/commit/b1fbe574e6f44d1fecb5e3a000270955c0a77b7b): Publish Bun-validated package tarballs through an npm trusted-publishing adapter
40
+ binding, add exact repository metadata for each public workspace package, and
41
+ correct the native Bun release descriptor to its pack-only runtime boundary.
42
+
43
+ ## 1.0.0-beta.50
44
+
45
+ ## 1.0.0-beta.49
46
+
47
+ ## 1.0.0-beta.48
48
+
49
+ ## 1.0.0-beta.47
50
+
51
+ ## 1.0.0-beta.46
52
+
53
+ ## 1.0.0-beta.45
54
+
55
+ ## 1.0.0-beta.44
56
+
57
+ ### Patch Changes
58
+
59
+ - [`b1fbe57`](https://github.com/outfitter-dev/trails/commit/b1fbe574e6f44d1fecb5e3a000270955c0a77b7b): Publish Bun-validated package tarballs through an npm trusted-publishing adapter
60
+ binding, add exact repository metadata for each public workspace package, and
61
+ correct the native Bun release descriptor to its pack-only runtime boundary.
62
+
63
+ ## 1.0.0-beta.43
64
+
65
+ ### Major Changes
66
+
67
+ - [`113aed6`](https://github.com/outfitter-dev/trails/commit/113aed62d20041e35b0cf9d6c1b1a18df4b88f57): Rename the dependency-light observability owner from `@ontrails/observe` to
68
+ `@ontrails/observability` as a pre-v1 hard cut. Update dependent packages,
69
+ documentation, package discovery, and the governed Regrade route; no
70
+ compatibility package or old import route is retained.
71
+ - [`0938e7b`](https://github.com/outfitter-dev/trails/commit/0938e7badc0c5470d194139d642b673658d099e0): Fold the removed `@ontrails/tracing` package into the truthful existing
72
+ owners: intrinsic trace contracts remain in core, developer-state tooling now
73
+ lives at `@ontrails/observability/dev`, and the dependency-light OTel adapter
74
+ lives at `@ontrails/observability/otel`. There is intentionally no root-package
75
+ compatibility redirect because the former root had more than one owner.
76
+ - [`50e2779`](https://github.com/outfitter-dev/trails/commit/50e27796d074851bccd57d7df009db749757b457): Extract the real `@ontrails/logtape` and `@ontrails/pino` adapters from the
77
+ temporary observability subpaths. The new packages own their namesake foreign
78
+ dependencies and preserve Trails record metadata, levels, redaction boundaries,
79
+ and lifecycle behavior; the old subpaths are removed in the pre-v1 hard cut.
80
+
81
+ Add governed Regrade transitions for both exact import replacements and expose
82
+ the observability adapter target through the shared adapter readiness check.
83
+
84
+ ### Patch Changes
85
+
86
+ - [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
87
+ to derive/derived for contract-owned fact production and render/rendered for
88
+ surface presentation. Public type, helper, rule, relation, and report names move
89
+ without compatibility aliases; ordinary repository/project nouns remain
90
+ explicit preserves or structured review inventory.
91
+
92
+ ## 1.0.0-beta.42
93
+
94
+ ## 1.0.0-beta.41
95
+
96
+ ## 1.0.0-beta.40
97
+
98
+ ### Major Changes
99
+
100
+ - [`b94cf85`](https://github.com/outfitter-dev/trails/commit/b94cf85011dfe18d34dfdaa74efe381b6295d5e1): Fold LogTape and Pino log forwarding into `@ontrails/observe/logtape` and `@ontrails/observe/pino`, and remove the standalone `@ontrails/logtape` and `@ontrails/pino` workspaces.
101
+
102
+ ## 1.0.0-beta.39
103
+
104
+ ## 1.0.0-beta.38
105
+
106
+ ## 1.0.0-beta.37
107
+
108
+ ## 1.0.0-beta.36
109
+
110
+ ## 1.0.0-beta.35
111
+
112
+ ### Patch Changes
113
+
114
+ - [`417bd84`](https://github.com/outfitter-dev/trails/commit/417bd8471d0f0f47ad5f33cd2ac1c606eccd72f8): Promote signal trace helpers from tracing compatibility code to core exports, and make tracing's memory sink wrapper use the observe-owned implementation.
115
+
116
+ ## 1.0.0-beta.34
117
+
118
+ ## 1.0.0-beta.33
119
+
120
+ ## 1.0.0-beta.32
121
+
122
+ ### Patch Changes
123
+
124
+ - Updated dependencies [3e5c0fc]
125
+ - Updated dependencies [f3c4fef]
126
+ - Updated dependencies [cb0a9d8]
127
+ - Updated dependencies [21c6dda]
128
+ - Updated dependencies [fe72b84]
129
+ - @ontrails/core@1.0.0-beta.32
130
+
131
+ ## 1.0.0-beta.31
132
+
133
+ ### Patch Changes
134
+
135
+ - Updated dependencies [4cd5d4e]
136
+ - Updated dependencies [38907cc]
137
+ - @ontrails/core@1.0.0-beta.31
138
+
139
+ ## 1.0.0-beta.30
140
+
141
+ ### Patch Changes
142
+
143
+ - @ontrails/core@1.0.0-beta.30
144
+
145
+ ## 1.0.0-beta.29
146
+
147
+ ### Patch Changes
148
+
149
+ - @ontrails/core@1.0.0-beta.29
150
+
151
+ ## 1.0.0-beta.28
152
+
153
+ ### Patch Changes
154
+
155
+ - @ontrails/core@1.0.0-beta.28
156
+
157
+ ## 1.0.0-beta.27
158
+
159
+ ### Patch Changes
160
+
161
+ - @ontrails/core@1.0.0-beta.27
162
+
163
+ ## 1.0.0-beta.26
164
+
165
+ ### Patch Changes
166
+
167
+ - Updated dependencies [1307568]
168
+ - Updated dependencies [371d19e]
169
+ - @ontrails/core@1.0.0-beta.26
170
+
171
+ ## 1.0.0-beta.25
172
+
173
+ ### Patch Changes
174
+
175
+ - Updated dependencies [c36aca9]
176
+ - Updated dependencies [3befcf1]
177
+ - Updated dependencies [a4f9cf6]
178
+ - Updated dependencies [9bcf34e]
179
+ - @ontrails/core@1.0.0-beta.25
180
+
181
+ ## 1.0.0-beta.24
182
+
183
+ ### Patch Changes
184
+
185
+ - @ontrails/core@1.0.0-beta.24
186
+
187
+ ## 1.0.0-beta.23
188
+
189
+ ### Patch Changes
190
+
191
+ - @ontrails/core@1.0.0-beta.23
192
+
193
+ ## 1.0.0-beta.22
194
+
195
+ ### Patch Changes
196
+
197
+ - @ontrails/core@1.0.0-beta.22
198
+
199
+ ## 1.0.0-beta.21
200
+
201
+ ### Patch Changes
202
+
203
+ - Updated dependencies [99523f2]
204
+ - @ontrails/core@1.0.0-beta.21
205
+
206
+ ## 1.0.0-beta.20
207
+
208
+ ### Patch Changes
209
+
210
+ - Updated dependencies [851a2a3]
211
+ - @ontrails/core@1.0.0-beta.20
212
+
213
+ ## 1.0.0-beta.19
214
+
215
+ ### Patch Changes
216
+
217
+ - 1eb5bdc: Rename first-class trail composition from the `cross` API family to the `compose` family across core contracts, testing helpers, topo projections, Warden rules, CLI scaffolds, and docs. `composes`, `ctx.compose`, `composeInput`, and `Compose*` type names are now the public authoring vocabulary; topo persistence migrates legacy composition rows and graph keys forward.
218
+ - Updated dependencies [e41c382]
219
+ - Updated dependencies [1eb5bdc]
220
+ - Updated dependencies [f8d80b9]
221
+ - Updated dependencies [846a597]
222
+ - Updated dependencies [223aaad]
223
+ - Updated dependencies [3125f4d]
224
+ - Updated dependencies [2494dc6]
225
+ - Updated dependencies [2d53717]
226
+ - Updated dependencies [16cb740]
227
+ - Updated dependencies [8894ecb]
228
+ - Updated dependencies [fdf7ec9]
229
+ - Updated dependencies [d76be13]
230
+ - Updated dependencies [84f56a5]
231
+ - Updated dependencies [431b04c]
232
+ - Updated dependencies [5d88104]
233
+ - Updated dependencies [f04a9ef]
234
+ - @ontrails/core@1.0.0-beta.19
235
+
236
+ ## 1.0.0-beta.18
237
+
238
+ ### Patch Changes
239
+
240
+ - bf44972: Document Pino sink usage and publish-readiness checks.
241
+ - e0ae995: Document the v1 `@ontrails/tracing/otel` OpenTelemetry adapter boundary, including callback export, stable `trails.*` attributes, flush behavior, and the absence of a standalone `@ontrails/otel` package.
242
+ - @ontrails/core@1.0.0-beta.18
243
+
244
+ ## 1.0.0-beta.17
245
+
246
+ ### Patch Changes
247
+
248
+ - Updated dependencies [3dc8254]
249
+ - @ontrails/core@1.0.0-beta.17
250
+
251
+ ## 1.0.0-beta.16
252
+
253
+ ### Minor Changes
254
+
255
+ - d40430d: Remove the retired `@ontrails/logging` workspace from the prerelease package set. Use `@ontrails/observe` for log and trace sink contracts and `@ontrails/logtape` for LogTape forwarding.
256
+ - 9cdb0f2: Add `renderTraceTree(records: readonly TraceRecord[]): string` — a pure post-execution renderer that builds a readable execution tree from `TraceRecord` entries. Renders root spans with `●`, children with `├──`/`└──`, status glyphs (`✓`/`✗`/`⊘`), durations, and parallel-branch detection (overlapping siblings render as a bracketed group with wall-vs-total summary). Tolerates forward-compatible record shapes (signal/activation kinds, `attrs.layer` from upcoming layer composition) without crashing — unknown kinds fall through to a generic span renderer. No live streaming; the tree is drawn once after the trail completes.
257
+ - 22c6c06: Accept ADR-0041 Unified Observability and ship the first activation and
258
+ observability primitives it depends on: activation trace records, topo-level
259
+ observe configuration, webhook activation materialization, signal/webhook
260
+ warden coaching, the `@ontrails/observe` package, sink composition, and
261
+ zero-dependency observe sinks.
262
+
263
+ ### Patch Changes
264
+
265
+ - 6300f70: Refresh source comments and test labels for retired connector terminology as adapter guardrails become strict.
266
+ - e898cc4: Add repo-level Knip dead-code detection and remove stale internal exports and unused package dependencies surfaced by the new check.
267
+ - a8997ed: Add migration guidance for the retired `@ontrails/logging` package and align observability README examples around `@ontrails/observe`, `@ontrails/tracing`, and `@ontrails/logtape`.
268
+ - fe03945: Document the v1 observability package boundary: `@ontrails/observe` is the production sink contract package, while `@ontrails/tracing` remains the compatibility and developer-state package with the supported `@ontrails/tracing/otel` adapter subpath.
269
+ - 49c2e7d: Refresh published package README taxonomy to use adapter language instead of retired connector vocabulary.
270
+ - Updated dependencies [73622ae]
271
+ - Updated dependencies [6300f70]
272
+ - Updated dependencies [d172013]
273
+ - Updated dependencies [c3fc5c3]
274
+ - Updated dependencies [20d7a5c]
275
+ - Updated dependencies [be5fb46]
276
+ - Updated dependencies [e898cc4]
277
+ - Updated dependencies [3395234]
278
+ - Updated dependencies [bcdc484]
279
+ - Updated dependencies [331e3a9]
280
+ - Updated dependencies [4399fdb]
281
+ - Updated dependencies [4b8d13b]
282
+ - Updated dependencies [112b9f2]
283
+ - Updated dependencies [893025e]
284
+ - Updated dependencies [eec5e9d]
285
+ - Updated dependencies [ebd4434]
286
+ - Updated dependencies [863d473]
287
+ - Updated dependencies [344f2f7]
288
+ - Updated dependencies [26f9ffd]
289
+ - Updated dependencies [10eae9a]
290
+ - Updated dependencies [22c6c06]
291
+ - @ontrails/core@1.0.0-beta.16
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @ontrails/observability
2
+
3
+ Primitive observability contracts for Trails.
4
+
5
+ This package is the public home for log and trace sink shapes used by Trails apps and adapters. It includes zero-dependency sinks for local and server baselines, plus adapter composition for production observability.
6
+
7
+ ## V1 package boundary
8
+
9
+ Use `@ontrails/observability` for app-facing observability contracts and sinks: `LogSink`, `TraceSink`, `combine(...)`, console/file sinks, bounded memory sinks, and trace rendering.
10
+
11
+ `@ontrails/core` owns intrinsic tracing execution: `TraceRecord`, `ctx.trace()`, trace context propagation, and the process-level trace sink registry.
12
+
13
+ `@ontrails/observability/dev` owns developer-state tooling: query/status trails, the SQLite dev store, sampling helpers, and state maintenance. Import intrinsic trace records, contexts, and the process-level sink registry from `@ontrails/core`.
14
+
15
+ For v1, OpenTelemetry trace export lives at `@ontrails/observability/otel`; there is no standalone `@ontrails/otel` package. That adapter translates Trails-native `TraceRecord` values to callback-delivered OTel-shaped spans without requiring the OpenTelemetry SDK as a runtime dependency.
16
+
17
+ ```typescript
18
+ import {
19
+ combine,
20
+ createConsoleSink,
21
+ createFileSink,
22
+ createMemorySink,
23
+ } from '@ontrails/observability';
24
+
25
+ const sink = combine(
26
+ createConsoleSink(),
27
+ createFileSink('./logs/app.log'),
28
+ createMemorySink({ maxRecords: 500 })
29
+ );
30
+ ```
31
+
32
+ `createFileSink()` is append-only and does not rotate files. Use external log rotation or a production adapter when retention policy matters.
33
+
34
+ ## Extracted logging adapters
35
+
36
+ Pino and LogTape are foreign-library boundaries, so they live in extracted adapter packages rather than observability subpaths. Use `@ontrails/pino` for the real Pino integration and `@ontrails/logtape` for the real LogTape integration. `@ontrails/observability` intentionally has no dependency on either library.
37
+
38
+ ## Migration from `@ontrails/logging`
39
+
40
+ `@ontrails/logging` was retired before v1. Move sink contracts, console/file sinks, formatters, and bounded memory sinks to `@ontrails/observability`. Use `@ontrails/logtape` for LogTape forwarding, `@ontrails/pino` for Pino forwarding, `@ontrails/observability/dev` for developer-state tooling, and `@ontrails/core` for intrinsic trace contracts.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@ontrails/observability",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/outfitter-dev/trails.git",
7
+ "directory": "packages/observability"
8
+ },
9
+ "files": [
10
+ "src/**/*.ts",
11
+ "!src/**/__tests__/**",
12
+ "!src/**/*.test.ts",
13
+ "!src/**/*.test-d.ts",
14
+ "README.md",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./dev": "./src/dev.ts",
21
+ "./otel": "./src/otel.ts",
22
+ "./package.json": "./package.json",
23
+ "./testing": "./src/testing.ts"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -b",
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "oxlint ./src",
30
+ "clean": "rm -rf dist *.tsbuildinfo"
31
+ },
32
+ "peerDependencies": {
33
+ "@ontrails/core": "^0.2.0",
34
+ "zod": "^4.3.5"
35
+ },
36
+ "trails": {
37
+ "adapterTargets": {
38
+ "observability": {
39
+ "conformance": {
40
+ "adapterType": "ObservabilityAdapterConformanceAdapter",
41
+ "casesFactory": "createObservabilityAdapterConformanceCases",
42
+ "runner": "runConformance"
43
+ },
44
+ "placements": [
45
+ "extracted"
46
+ ],
47
+ "testingImport": "@ontrails/observability/testing"
48
+ }
49
+ }
50
+ }
51
+ }
package/src/combine.ts ADDED
@@ -0,0 +1,280 @@
1
+ import { ValidationError } from '@ontrails/core';
2
+ import type {
3
+ LogRecord,
4
+ LogSink,
5
+ ObserveCapabilities,
6
+ TraceRecord,
7
+ TraceSink,
8
+ } from '@ontrails/core';
9
+
10
+ type ObserveRecord = LogRecord | TraceRecord;
11
+ type ObserveSink = LogSink | TraceSink;
12
+ type SinkWrite = (record: ObserveRecord) => void | Promise<void>;
13
+ type FlushableSink = ObserveSink & { readonly flush?: () => Promise<void> };
14
+
15
+ interface SinkFailure {
16
+ readonly error: unknown;
17
+ readonly index: number;
18
+ readonly sinkName: string | undefined;
19
+ }
20
+
21
+ export interface CombinedSink {
22
+ readonly name: string;
23
+ readonly observes: ObserveCapabilities;
24
+ write(record: LogRecord): void;
25
+ write(record: TraceRecord): void | Promise<void>;
26
+ flush(): Promise<void>;
27
+ }
28
+
29
+ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
30
+ typeof value === 'object' &&
31
+ value !== null &&
32
+ typeof (value as { readonly then?: unknown }).then === 'function';
33
+
34
+ const isLogSink = (sink: ObserveSink): sink is LogSink =>
35
+ 'name' in sink && typeof sink.name === 'string';
36
+
37
+ const isLogRecord = (record: ObserveRecord): record is LogRecord =>
38
+ 'level' in record && 'message' in record && 'timestamp' in record;
39
+
40
+ const readObserveCapabilities = (
41
+ sink: ObserveSink
42
+ ): ObserveCapabilities | undefined => {
43
+ const capabilities = (sink as { readonly observes?: unknown }).observes;
44
+ if (typeof capabilities !== 'object' || capabilities === null) {
45
+ return undefined;
46
+ }
47
+ const log = (capabilities as ObserveCapabilities).log === true;
48
+ const trace = (capabilities as ObserveCapabilities).trace === true;
49
+ if (!log && !trace) {
50
+ return undefined;
51
+ }
52
+ return Object.freeze({
53
+ ...(log ? { log: true as const } : {}),
54
+ ...(trace ? { trace: true as const } : {}),
55
+ });
56
+ };
57
+
58
+ const capabilitiesForSink = (sink: ObserveSink): ObserveCapabilities =>
59
+ readObserveCapabilities(sink) ??
60
+ Object.freeze(
61
+ isLogSink(sink) ? { log: true as const } : { trace: true as const }
62
+ );
63
+
64
+ const combineCapabilities = (
65
+ sinks: readonly ObserveSink[]
66
+ ): ObserveCapabilities => {
67
+ let log = false;
68
+ let trace = false;
69
+ for (const sink of sinks) {
70
+ const capabilities = capabilitiesForSink(sink);
71
+ log ||= capabilities.log === true;
72
+ trace ||= capabilities.trace === true;
73
+ }
74
+ return Object.freeze({
75
+ ...(log ? { log: true as const } : {}),
76
+ ...(trace ? { trace: true as const } : {}),
77
+ });
78
+ };
79
+
80
+ const sinkName = (sink: ObserveSink): string | undefined =>
81
+ 'name' in sink && typeof sink.name === 'string' ? sink.name : undefined;
82
+
83
+ const flushForSink = (sink: ObserveSink): (() => Promise<void>) | undefined => {
84
+ const { flush } = sink as FlushableSink;
85
+ return typeof flush === 'function' ? flush.bind(sink) : undefined;
86
+ };
87
+
88
+ const canReceiveRecord = (
89
+ sink: ObserveSink,
90
+ record: ObserveRecord
91
+ ): boolean => {
92
+ const capabilities = capabilitiesForSink(sink);
93
+ return isLogRecord(record)
94
+ ? capabilities.log === true
95
+ : capabilities.trace === true;
96
+ };
97
+
98
+ const describeError = (error: unknown): string =>
99
+ error instanceof Error ? error.message : String(error);
100
+
101
+ const createFailureRecord = (
102
+ failure: SinkFailure,
103
+ record: ObserveRecord
104
+ ): LogRecord => ({
105
+ category: 'observe.combine',
106
+ level: 'warn',
107
+ message: 'Observe sink write failed; continuing with remaining sinks',
108
+ metadata: {
109
+ error: describeError(failure.error),
110
+ sinkIndex: failure.index,
111
+ ...(failure.sinkName === undefined ? {} : { sinkName: failure.sinkName }),
112
+ ...(isLogRecord(record)
113
+ ? { recordCategory: record.category }
114
+ : { recordId: record.id, traceId: record.traceId }),
115
+ },
116
+ timestamp: new Date(),
117
+ });
118
+
119
+ const writeToSink = (
120
+ sink: ObserveSink,
121
+ record: ObserveRecord
122
+ ): void | Promise<void> => {
123
+ // Call via the sink so class-based sinks keep `this` bound. Detaching the
124
+ // method (e.g. `(sink.write as SinkWrite)(record)`) would lose `this` and
125
+ // any class-method `write` would throw — silently dropping records since
126
+ // combine() swallows child errors. Mirrors flushForSink's binding.
127
+ const write = sink.write as SinkWrite;
128
+ return write.call(sink, record);
129
+ };
130
+
131
+ const ignoreReportFailure = async (
132
+ result: PromiseLike<unknown>
133
+ ): Promise<void> => {
134
+ try {
135
+ await result;
136
+ } catch {
137
+ // Error reporting is best-effort.
138
+ }
139
+ };
140
+
141
+ const reportFailures = (
142
+ sinks: readonly ObserveSink[],
143
+ failures: readonly SinkFailure[],
144
+ record: ObserveRecord
145
+ ): void => {
146
+ if (failures.length === 0) {
147
+ return;
148
+ }
149
+
150
+ const logSinks = sinks
151
+ .map((sink, index) => ({ index, sink }))
152
+ .filter(
153
+ (entry): entry is { readonly index: number; readonly sink: LogSink } =>
154
+ capabilitiesForSink(entry.sink).log === true && isLogSink(entry.sink)
155
+ );
156
+
157
+ for (const failure of failures) {
158
+ const failureRecord = createFailureRecord(failure, record);
159
+ for (const entry of logSinks) {
160
+ if (entry.index === failure.index) {
161
+ continue;
162
+ }
163
+ try {
164
+ const result = entry.sink.write(failureRecord);
165
+ if (isPromiseLike(result)) {
166
+ void ignoreReportFailure(result);
167
+ }
168
+ } catch {
169
+ // Error reporting is best-effort; the original write path is already isolated.
170
+ }
171
+ }
172
+ }
173
+ };
174
+
175
+ const createFailure = (
176
+ sink: ObserveSink,
177
+ index: number,
178
+ error: unknown
179
+ ): SinkFailure => ({
180
+ error,
181
+ index,
182
+ sinkName: sinkName(sink),
183
+ });
184
+
185
+ const isolateAsyncWriteFailure = async (
186
+ result: PromiseLike<unknown>,
187
+ sink: ObserveSink,
188
+ index: number,
189
+ failures: SinkFailure[]
190
+ ): Promise<void> => {
191
+ try {
192
+ await result;
193
+ } catch (error) {
194
+ failures.push(createFailure(sink, index, error));
195
+ }
196
+ };
197
+
198
+ const reportAfterPendingWrites = async (
199
+ pending: readonly Promise<void>[],
200
+ sinks: readonly ObserveSink[],
201
+ failures: readonly SinkFailure[],
202
+ record: ObserveRecord
203
+ ): Promise<void> => {
204
+ await Promise.all(pending);
205
+ reportFailures(sinks, failures, record);
206
+ };
207
+
208
+ /**
209
+ * Compose multiple observability sinks into one fan-out sink.
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * const sink = combine(otelSink, fileSink)
214
+ * const app = topo('app', trails, { observe: sink })
215
+ * ```
216
+ *
217
+ * @remarks
218
+ * A child sink failure never prevents sibling sinks from receiving the same
219
+ * record. Failures are swallowed and reported to log-capable sibling sinks
220
+ * when one is present.
221
+ */
222
+ export function combine(...sinks: readonly LogSink[]): CombinedSink;
223
+ export function combine(...sinks: readonly TraceSink[]): CombinedSink;
224
+ export function combine(...sinks: readonly ObserveSink[]): CombinedSink;
225
+ export function combine(...sinks: readonly ObserveSink[]): CombinedSink {
226
+ if (sinks.length === 0) {
227
+ throw new ValidationError(
228
+ 'combine() requires at least one sink; an empty composition has no observe capabilities and would fail topo validation.'
229
+ );
230
+ }
231
+
232
+ const observes = combineCapabilities(sinks);
233
+
234
+ return {
235
+ async flush(): Promise<void> {
236
+ await Promise.all(
237
+ sinks.map(async (sink) => {
238
+ const flush = flushForSink(sink);
239
+ if (flush === undefined) {
240
+ return;
241
+ }
242
+ try {
243
+ await flush();
244
+ } catch {
245
+ // Flush follows write isolation: one broken sink should not block shutdown.
246
+ }
247
+ })
248
+ );
249
+ },
250
+ name: 'combined',
251
+ observes,
252
+ write(record: ObserveRecord): void | Promise<void> {
253
+ const failures: SinkFailure[] = [];
254
+ const pending: Promise<void>[] = [];
255
+
256
+ for (const [index, sink] of sinks.entries()) {
257
+ if (!canReceiveRecord(sink, record)) {
258
+ continue;
259
+ }
260
+ try {
261
+ const result = writeToSink(sink, record);
262
+ if (isPromiseLike(result)) {
263
+ pending.push(
264
+ isolateAsyncWriteFailure(result, sink, index, failures)
265
+ );
266
+ }
267
+ } catch (error) {
268
+ failures.push(createFailure(sink, index, error));
269
+ }
270
+ }
271
+
272
+ if (pending.length === 0) {
273
+ reportFailures(sinks, failures, record);
274
+ return undefined;
275
+ }
276
+
277
+ return reportAfterPendingWrites(pending, sinks, failures, record);
278
+ },
279
+ };
280
+ }