@ontrails/observability 1.0.0-beta.42

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Post-execution trace tree renderer.
3
+ *
4
+ * Pure projection of a flat `TraceRecord[]` into a multi-line string suitable
5
+ * for stderr. The renderer is intentionally side-effect free: callers (such as
6
+ * the CLI's `--trace` flag) own the actual write to stderr. Live streaming is
7
+ * deferred per ADR-0028; this renderer assumes the records are complete.
8
+ *
9
+ * The output follows the trace tree shape documented in ADR-0041:
10
+ *
11
+ * ```
12
+ * ● booking.confirm
13
+ * ├── availability.reserve
14
+ * │ └─ ✓ 45ms
15
+ * ├── billing.charge
16
+ * │ └─ ✗ ConflictError (90ms)
17
+ * └─ ✓ 380ms
18
+ * ```
19
+ *
20
+ * Status glyphs:
21
+ * - `✓` ok
22
+ * - `✗` err (with error category)
23
+ * - `⊘` cancelled
24
+ *
25
+ * Parallel siblings (overlapping `[startedAt, endedAt]` intervals) are
26
+ * bracketed with `┌` / `├` / `└` and followed by a parallel summary line.
27
+ *
28
+ * @see {@link https://github.com/outfitter-dev/trails/blob/main/docs/adr/0041-unified-observability.md | ADR-0041: Unified Observability}
29
+ */
30
+
31
+ import type { TraceRecord } from '@ontrails/core';
32
+
33
+ /** Glyph used to mark the root of a rendered tree. */
34
+ const ROOT_GLYPH = '●';
35
+
36
+ /** Status glyph for an `ok` outcome. */
37
+ const OK_GLYPH = '✓';
38
+
39
+ /** Status glyph for an `err` outcome. */
40
+ const ERR_GLYPH = '✗';
41
+
42
+ /** Status glyph for a `cancelled` outcome. */
43
+ const CANCELLED_GLYPH = '⊘';
44
+
45
+ /** Branch prefix for a non-last child in a regular (non-parallel) group. */
46
+ const BRANCH_MID = '├── ';
47
+
48
+ /** Branch prefix for the last child in a regular (non-parallel) group. */
49
+ const BRANCH_LAST = '└── ';
50
+
51
+ /** Continuation prefix for descendants under a non-last child. */
52
+ const CONTINUATION_MID = '│ ';
53
+
54
+ /** Continuation prefix for descendants under the last child. */
55
+ const CONTINUATION_LAST = ' ';
56
+
57
+ /** Prefix for a parallel-group leader. */
58
+ const PARALLEL_FIRST = '┌ ';
59
+
60
+ /** Prefix for a middle entry in a parallel group. */
61
+ const PARALLEL_MID = '├ ';
62
+
63
+ /** Prefix for the final entry in a parallel group. */
64
+ const PARALLEL_LAST = '└ ';
65
+
66
+ interface ChildBlockArgs {
67
+ readonly children: readonly TraceRecord[];
68
+ readonly childrenByParent: ReadonlyMap<string, readonly TraceRecord[]>;
69
+ readonly indent: string;
70
+ readonly lines: string[];
71
+ }
72
+
73
+ interface GroupArgs extends ChildBlockArgs {
74
+ readonly group: readonly TraceRecord[];
75
+ readonly isLastGroup: boolean;
76
+ readonly renderDescendants: (
77
+ child: TraceRecord,
78
+ continuation: string
79
+ ) => void;
80
+ }
81
+
82
+ interface ParallelRun {
83
+ readonly kind: 'parallel' | 'serial';
84
+ readonly records: readonly TraceRecord[];
85
+ }
86
+
87
+ const durationOf = (record: TraceRecord): number => {
88
+ if (record.endedAt === undefined) {
89
+ return 0;
90
+ }
91
+ return Math.max(0, record.endedAt - record.startedAt);
92
+ };
93
+
94
+ const computeWallTime = (records: readonly TraceRecord[]): number => {
95
+ if (records.length === 0) {
96
+ return 0;
97
+ }
98
+ let earliest = Number.POSITIVE_INFINITY;
99
+ let latest = 0;
100
+ for (const record of records) {
101
+ if (record.startedAt < earliest) {
102
+ earliest = record.startedAt;
103
+ }
104
+ const end = record.endedAt ?? record.startedAt;
105
+ if (end > latest) {
106
+ latest = end;
107
+ }
108
+ }
109
+ return Math.max(0, latest - earliest);
110
+ };
111
+
112
+ const computeTotalTime = (records: readonly TraceRecord[]): number => {
113
+ let total = 0;
114
+ for (const record of records) {
115
+ total += durationOf(record);
116
+ }
117
+ return total;
118
+ };
119
+
120
+ const decorationForKind = (record: TraceRecord): string => {
121
+ switch (record.kind) {
122
+ case 'signal': {
123
+ return record.attrs['emit'] === true ? '↑ ' : '';
124
+ }
125
+ case 'activation': {
126
+ return '→ ';
127
+ }
128
+ case 'span':
129
+ case 'trail': {
130
+ return '';
131
+ }
132
+ default: {
133
+ // Forward-compatible: render unknown kinds as plain spans.
134
+ return '';
135
+ }
136
+ }
137
+ };
138
+
139
+ const formatRecordHeader = (record: TraceRecord): string => {
140
+ const decoration = decorationForKind(record);
141
+ return `${decoration}${record.name}`;
142
+ };
143
+
144
+ const formatStatus = (record: TraceRecord): string => {
145
+ const ms = durationOf(record);
146
+ switch (record.status) {
147
+ case 'ok': {
148
+ return `${OK_GLYPH} ${ms}ms`;
149
+ }
150
+ case 'err': {
151
+ const category = record.errorCategory ?? 'Error';
152
+ return `${ERR_GLYPH} ${category} (${ms}ms)`;
153
+ }
154
+ case 'cancelled': {
155
+ return `${CANCELLED_GLYPH} ${ms}ms`;
156
+ }
157
+ default: {
158
+ // Forward-compatible: unknown future statuses render as a neutral note.
159
+ return `${ms}ms`;
160
+ }
161
+ }
162
+ };
163
+
164
+ const sortByStartedAt = (
165
+ records: readonly TraceRecord[]
166
+ ): readonly TraceRecord[] => {
167
+ const copy = [...records];
168
+ copy.sort((left, right) => {
169
+ if (left.startedAt !== right.startedAt) {
170
+ return left.startedAt - right.startedAt;
171
+ }
172
+ if (left.id < right.id) {
173
+ return -1;
174
+ }
175
+ if (left.id > right.id) {
176
+ return 1;
177
+ }
178
+ return 0;
179
+ });
180
+ return copy;
181
+ };
182
+
183
+ const parallelInnerBranch = (index: number, count: number): string => {
184
+ if (index === 0) {
185
+ return PARALLEL_FIRST;
186
+ }
187
+ if (index === count - 1) {
188
+ return PARALLEL_LAST;
189
+ }
190
+ return PARALLEL_MID;
191
+ };
192
+
193
+ const groupParallelRuns = (
194
+ children: readonly TraceRecord[]
195
+ ): readonly ParallelRun[] => {
196
+ const [head, ...rest] = children;
197
+ if (head === undefined) {
198
+ return [];
199
+ }
200
+ const runs: ParallelRun[] = [];
201
+ let bucket: TraceRecord[] = [head];
202
+ let bucketEnd = head.endedAt ?? Number.POSITIVE_INFINITY;
203
+ let bucketIsParallel = false;
204
+ for (const current of rest) {
205
+ const overlaps = current.startedAt < bucketEnd;
206
+ if (overlaps && bucket.length === 1 && !bucketIsParallel) {
207
+ bucket.push(current);
208
+ bucketEnd = Math.max(
209
+ bucketEnd,
210
+ current.endedAt ?? Number.POSITIVE_INFINITY
211
+ );
212
+ bucketIsParallel = true;
213
+ continue;
214
+ }
215
+ if (overlaps && bucketIsParallel) {
216
+ bucket.push(current);
217
+ bucketEnd = Math.max(
218
+ bucketEnd,
219
+ current.endedAt ?? Number.POSITIVE_INFINITY
220
+ );
221
+ continue;
222
+ }
223
+ runs.push({
224
+ kind: bucketIsParallel ? 'parallel' : 'serial',
225
+ records: bucket,
226
+ });
227
+ bucket = [current];
228
+ bucketEnd = current.endedAt ?? Number.POSITIVE_INFINITY;
229
+ bucketIsParallel = false;
230
+ }
231
+ runs.push({
232
+ kind: bucketIsParallel ? 'parallel' : 'serial',
233
+ records: bucket,
234
+ });
235
+ return runs;
236
+ };
237
+
238
+ const appendParallelGroup = (args: GroupArgs): void => {
239
+ const { group, indent, isLastGroup, lines, renderDescendants } = args;
240
+ let index = 0;
241
+ for (const child of group) {
242
+ const branch = parallelInnerBranch(index, group.length);
243
+ const isLastEntryOfLastGroup = index === group.length - 1 && isLastGroup;
244
+ const outerBranch = isLastEntryOfLastGroup ? BRANCH_LAST : BRANCH_MID;
245
+ const continuation = isLastEntryOfLastGroup
246
+ ? CONTINUATION_LAST
247
+ : CONTINUATION_MID;
248
+ const header = formatRecordHeader(child);
249
+ const status = formatStatus(child);
250
+ lines.push(`${indent}${outerBranch}${branch}${header} ${status}`);
251
+ renderDescendants(child, continuation);
252
+ index += 1;
253
+ }
254
+ const wall = computeWallTime(group);
255
+ const total = computeTotalTime(group);
256
+ const continuation = isLastGroup ? CONTINUATION_LAST : CONTINUATION_MID;
257
+ lines.push(
258
+ `${indent}${continuation}(parallel: ${wall}ms wall, ${total}ms total)`
259
+ );
260
+ };
261
+
262
+ /**
263
+ * Render a contiguous block of sibling children, splitting them into runs
264
+ * of sequential and parallel groups. Self-recursive: parallel groups render
265
+ * as flat brackets, and serial entries recurse for grandchildren.
266
+ */
267
+ const appendChildBlock = (args: ChildBlockArgs): void => {
268
+ const { childrenByParent, children, indent, lines } = args;
269
+ if (children.length === 0) {
270
+ return;
271
+ }
272
+ const groups = groupParallelRuns(children);
273
+ let groupIndex = 0;
274
+ for (const group of groups) {
275
+ const isLastGroup = groupIndex === groups.length - 1;
276
+ if (group.kind === 'parallel') {
277
+ appendParallelGroup({
278
+ ...args,
279
+ group: group.records,
280
+ isLastGroup,
281
+ renderDescendants: (child, continuation) => {
282
+ const grandchildren = sortByStartedAt(
283
+ childrenByParent.get(child.id) ?? []
284
+ );
285
+ appendChildBlock({
286
+ children: grandchildren,
287
+ childrenByParent,
288
+ indent: `${indent}${continuation}`,
289
+ lines,
290
+ });
291
+ },
292
+ });
293
+ groupIndex += 1;
294
+ continue;
295
+ }
296
+ let entryIndex = 0;
297
+ for (const child of group.records) {
298
+ const isLastChild =
299
+ isLastGroup && entryIndex === group.records.length - 1;
300
+ const branch = isLastChild ? BRANCH_LAST : BRANCH_MID;
301
+ const continuation = isLastChild ? CONTINUATION_LAST : CONTINUATION_MID;
302
+ lines.push(`${indent}${branch}${formatRecordHeader(child)}`);
303
+ const grandchildren = sortByStartedAt(
304
+ childrenByParent.get(child.id) ?? []
305
+ );
306
+ appendChildBlock({
307
+ children: grandchildren,
308
+ childrenByParent,
309
+ indent: `${indent}${continuation}`,
310
+ lines,
311
+ });
312
+ lines.push(`${indent}${continuation}└─ ${formatStatus(child)}`);
313
+ entryIndex += 1;
314
+ }
315
+ groupIndex += 1;
316
+ }
317
+ };
318
+
319
+ const renderRoot = (
320
+ root: TraceRecord,
321
+ childrenByParent: ReadonlyMap<string, readonly TraceRecord[]>
322
+ ): string => {
323
+ const header = `${ROOT_GLYPH} ${formatRecordHeader(root)}`;
324
+ const lines: string[] = [header];
325
+ const children = sortByStartedAt(childrenByParent.get(root.id) ?? []);
326
+ appendChildBlock({
327
+ children,
328
+ childrenByParent,
329
+ indent: ' ',
330
+ lines,
331
+ });
332
+ lines.push(` └─ ${formatStatus(root)}`);
333
+ return lines.join('\n');
334
+ };
335
+
336
+ /**
337
+ * Render a flat array of trace records as a tree string.
338
+ *
339
+ * Returns an empty string for empty input. When multiple records have no
340
+ * `parentId`, each is rendered as a separate top-level tree joined by a
341
+ * blank line.
342
+ *
343
+ * @param records - Flat list of trace records, in any order.
344
+ * @returns Multi-line string. No trailing newline.
345
+ */
346
+ export const renderTraceTree = (records: readonly TraceRecord[]): string => {
347
+ if (records.length === 0) {
348
+ return '';
349
+ }
350
+
351
+ const byId = new Map<string, TraceRecord>();
352
+ for (const record of records) {
353
+ byId.set(record.id, record);
354
+ }
355
+
356
+ const childrenByParent = new Map<string, TraceRecord[]>();
357
+ const roots: TraceRecord[] = [];
358
+ for (const record of records) {
359
+ if (record.parentId !== undefined && byId.has(record.parentId)) {
360
+ const siblings = childrenByParent.get(record.parentId) ?? [];
361
+ siblings.push(record);
362
+ childrenByParent.set(record.parentId, siblings);
363
+ continue;
364
+ }
365
+ roots.push(record);
366
+ }
367
+
368
+ const sortedRoots = sortByStartedAt(roots);
369
+ const renderedTrees = sortedRoots.map((root) =>
370
+ renderRoot(root, childrenByParent)
371
+ );
372
+ return renderedTrees.join('\n\n');
373
+ };
package/src/sinks.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ import type { LogFormatter, LogRecord, LogSink } from '@ontrails/core';
5
+ import { createJsonFormatter } from './formatters.js';
6
+
7
+ export interface ConsoleSinkOptions {
8
+ /** Formatter to use. Defaults to newline-delimited JSON. */
9
+ readonly formatter?: LogFormatter | undefined;
10
+ /** Send every record to stderr. Defaults to false. */
11
+ readonly stderr?: boolean | undefined;
12
+ }
13
+
14
+ export interface FileSinkOptions {
15
+ /** Formatter to use. Defaults to newline-delimited JSON. */
16
+ readonly formatter?: LogFormatter | undefined;
17
+ }
18
+
19
+ export interface FileSinkConfig extends FileSinkOptions {
20
+ /** Path to the append-only log file. */
21
+ readonly path: string;
22
+ }
23
+
24
+ export interface FileLogSink extends LogSink {
25
+ /** Flush pending bytes to disk. */
26
+ flush(): Promise<void>;
27
+ /** Flush pending bytes and close the underlying file handle. */
28
+ close(): Promise<void>;
29
+ }
30
+
31
+ type ConsoleMethod = 'debug' | 'info' | 'warn' | 'error';
32
+
33
+ const CONSOLE_METHOD: Record<string, ConsoleMethod | undefined> = {
34
+ debug: 'debug',
35
+ error: 'error',
36
+ fatal: 'error',
37
+ info: 'info',
38
+ silent: undefined,
39
+ trace: 'debug',
40
+ warn: 'warn',
41
+ };
42
+
43
+ /**
44
+ * Create a log sink that writes records to console methods by level.
45
+ *
46
+ * Trace/debug records use `console.debug`, info uses `console.info`, warn uses
47
+ * `console.warn`, and error/fatal use `console.error`. Set `stderr: true` to
48
+ * send every record to `console.error`.
49
+ */
50
+ export const createConsoleSink = (
51
+ options: ConsoleSinkOptions = {}
52
+ ): LogSink => {
53
+ const formatter = options.formatter ?? createJsonFormatter();
54
+ const allStderr = options.stderr === true;
55
+
56
+ return {
57
+ name: 'console',
58
+ write(record: LogRecord): void {
59
+ // Always consult the level mapping first so `silent` records are dropped
60
+ // regardless of stderr mapping. Falling back to `error` when stderr
61
+ // mapping is enabled preserves the documented behavior for every other
62
+ // level while keeping `silent` semantics intact.
63
+ const levelMethod = CONSOLE_METHOD[record.level];
64
+ if (levelMethod === undefined) {
65
+ return;
66
+ }
67
+ const method = allStderr ? 'error' : levelMethod;
68
+ console[method](formatter.format(record));
69
+ },
70
+ };
71
+ };
72
+
73
+ const normalizeFileConfig = (
74
+ pathOrOptions: string | FileSinkConfig,
75
+ options: FileSinkOptions | undefined
76
+ ): FileSinkConfig => {
77
+ if (typeof pathOrOptions === 'string') {
78
+ return {
79
+ ...options,
80
+ path: pathOrOptions,
81
+ };
82
+ }
83
+ return pathOrOptions;
84
+ };
85
+
86
+ const ensureFileParentDirectory = (path: string): void => {
87
+ const parent = dirname(path);
88
+ if (parent === '.' || parent === '') {
89
+ return;
90
+ }
91
+ mkdirSync(parent, { recursive: true });
92
+ };
93
+
94
+ const toError = (value: unknown): Error =>
95
+ value instanceof Error ? value : new Error(String(value));
96
+
97
+ /**
98
+ * Create an append-only log sink backed by `Bun.file().writer()`.
99
+ *
100
+ * @remarks
101
+ * This zero-dependency sink does not rotate log files. Pair it with external
102
+ * log rotation or use a production adapter when retention policy matters.
103
+ */
104
+ export function createFileSink(
105
+ path: string,
106
+ options?: FileSinkOptions
107
+ ): FileLogSink;
108
+ export function createFileSink(options: FileSinkConfig): FileLogSink;
109
+ export function createFileSink(
110
+ pathOrOptions: string | FileSinkConfig,
111
+ options?: FileSinkOptions
112
+ ): FileLogSink {
113
+ const config = normalizeFileConfig(pathOrOptions, options);
114
+ const formatter = config.formatter ?? createJsonFormatter();
115
+ ensureFileParentDirectory(config.path);
116
+ const fileDescriptor = openSync(config.path, 'a');
117
+ const writer = Bun.file(fileDescriptor).writer();
118
+ let closed = false;
119
+
120
+ const assertOpen = (): void => {
121
+ if (closed) {
122
+ throw new Error('Cannot write to a closed file sink');
123
+ }
124
+ };
125
+
126
+ return {
127
+ async close(): Promise<void> {
128
+ if (closed) {
129
+ return;
130
+ }
131
+ closed = true;
132
+ // `writer.end()` may throw (e.g. write-back failures on a backing fd).
133
+ // We still need to release the file descriptor, but `closeSync` itself
134
+ // can throw too (EBADF, EIO). The original `writer.end()` failure is
135
+ // the more useful diagnostic, so capture it first and surface it as the
136
+ // primary error; any cleanup failure becomes a `cause` annotation.
137
+ try {
138
+ await writer.end();
139
+ } catch (endError) {
140
+ // writer.end() failed. Still attempt to release the descriptor; if
141
+ // that also fails, attach it as `cause` so the original error wins
142
+ // but the cleanup failure is not silently swallowed.
143
+ const primary = toError(endError);
144
+ try {
145
+ closeSync(fileDescriptor);
146
+ } catch (closeError) {
147
+ if (primary.cause === undefined) {
148
+ try {
149
+ (primary as { cause?: unknown }).cause = toError(closeError);
150
+ } catch {
151
+ // Some Error subclasses freeze `cause`; best-effort attachment.
152
+ }
153
+ }
154
+ }
155
+ throw primary;
156
+ }
157
+ // writer.end() succeeded; surface any closeSync failure directly.
158
+ closeSync(fileDescriptor);
159
+ },
160
+ async flush(): Promise<void> {
161
+ if (closed) {
162
+ return;
163
+ }
164
+ await writer.flush();
165
+ },
166
+ name: 'file',
167
+ write(record: LogRecord): void {
168
+ assertOpen();
169
+ // Bun's `FileSink.write()` returns the number of bytes written and
170
+ // throws synchronously on failure (e.g. EBADF). Discarding the byte
171
+ // count is intentional — it is not a backpressure signal and not an
172
+ // error code. Synchronous failures propagate to the caller naturally.
173
+ writer.write(`${formatter.format(record)}\n`);
174
+ },
175
+ };
176
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { LogSink } from './index.js';
4
+
5
+ /**
6
+ * The small common shape every extracted observability adapter proves through
7
+ * the owner package. Library-specific behavior remains in that adapter's own
8
+ * integration tests.
9
+ */
10
+ export interface ObservabilityAdapterConformanceAdapter {
11
+ readonly createSink: () => LogSink;
12
+ readonly name: string;
13
+ }
14
+
15
+ export interface ObservabilityAdapterConformanceCase {
16
+ readonly check: (adapter: ObservabilityAdapterConformanceAdapter) => void;
17
+ readonly name: string;
18
+ }
19
+
20
+ export const createObservabilityAdapterConformanceCases =
21
+ (): readonly ObservabilityAdapterConformanceCase[] => [
22
+ {
23
+ check(adapter): void {
24
+ const sink = adapter.createSink();
25
+ expect(sink.name).toBe(adapter.name);
26
+ sink.write({
27
+ category: 'conformance',
28
+ level: 'silent',
29
+ message: 'must not reach the foreign logger',
30
+ metadata: {},
31
+ timestamp: new Date('2026-07-13T00:00:00.000Z'),
32
+ });
33
+ },
34
+ name: 'creates the named sink and accepts silent records',
35
+ },
36
+ ];
37
+
38
+ export const runConformance = (
39
+ adapter: ObservabilityAdapterConformanceAdapter,
40
+ cases: readonly ObservabilityAdapterConformanceCase[]
41
+ ): void => {
42
+ describe(`${adapter.name} observability adapter conformance`, () => {
43
+ for (const conformanceCase of cases) {
44
+ test(conformanceCase.name, () => {
45
+ conformanceCase.check(adapter);
46
+ });
47
+ }
48
+ });
49
+ };