@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/src/dev.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Developer-state observability tools.
3
+ *
4
+ * This subpath owns local trace storage, query trails, and sampling state.
5
+ * Core retains the runtime trace record and sink registry contracts.
6
+ */
7
+ export { DEFAULT_SAMPLING, shouldSample } from './dev/sampling.js';
8
+ export type { SamplingConfig } from './dev/sampling.js';
9
+ export { tracingResource } from './dev/tracing-resource.js';
10
+ export { tracingQuery } from './dev/trails/tracing-query.js';
11
+ export { tracingStatus } from './dev/trails/tracing-status.js';
12
+ export {
13
+ clearTraceStore,
14
+ clearTracingState,
15
+ getTraceStore,
16
+ getTracingState,
17
+ registerTraceStore,
18
+ registerTracingState,
19
+ } from './dev/tracing-state.js';
20
+ export type { TracingState } from './dev/tracing-state.js';
21
+ export { createDevStore, toTraceStore } from './dev/store.js';
22
+ export type {
23
+ DevStore,
24
+ DevStoreOptions,
25
+ DevStoreQueryOptions,
26
+ TraceStore,
27
+ } from './dev/store.js';
28
+ export {
29
+ applyTraceCleanup,
30
+ countTraceRecords,
31
+ DEFAULT_MAX_AGE,
32
+ DEFAULT_MAX_RECORDS,
33
+ ensureTraceSchema,
34
+ previewTraceCleanup,
35
+ withTraceStoreDb,
36
+ } from './dev/internal/dev-state.js';
37
+ export type { TraceCleanupReport } from './dev/internal/dev-state.js';
@@ -0,0 +1,160 @@
1
+ import type { LogFormatter, LogRecord } from '@ontrails/core';
2
+
3
+ export interface PrettyFormatterOptions {
4
+ /** Show timestamps. Defaults to true. */
5
+ readonly timestamps?: boolean | undefined;
6
+ /** Use ANSI colors. Defaults to false for deterministic output. */
7
+ readonly colors?: boolean | undefined;
8
+ }
9
+
10
+ /**
11
+ * Build a JSON.stringify replacer that:
12
+ * - serializes BigInt as a decimal string,
13
+ * - replaces functions/symbols with a tagged sentinel,
14
+ * - breaks circular references with a sentinel rather than throwing.
15
+ *
16
+ * Circular detection tracks the *ancestor path* from root to the current
17
+ * value rather than the set of all previously visited objects. This lets
18
+ * a metadata payload reuse the same nested object in multiple sibling
19
+ * positions (for example `{ a: shared, b: shared }`) without the second
20
+ * occurrence being mislabelled as `[Circular]`. JSON.stringify invokes
21
+ * the replacer with `this` bound to the immediate parent, so we can
22
+ * maintain a path stack by popping entries until the top matches the
23
+ * current parent before deciding whether the new value is on that path.
24
+ *
25
+ * The replacer is created per `JSON.stringify` invocation so the path
26
+ * stack does not leak across log records or between metadata entries.
27
+ */
28
+ const createSafeReplacer = (): ((
29
+ this: unknown,
30
+ key: string,
31
+ value: unknown
32
+ ) => unknown) => {
33
+ const path: object[] = [];
34
+ return function safeReplacer(
35
+ this: unknown,
36
+ _key: string,
37
+ value: unknown
38
+ ): unknown {
39
+ if (typeof value === 'bigint') {
40
+ return value.toString();
41
+ }
42
+ if (typeof value === 'function') {
43
+ return '[Function]';
44
+ }
45
+ if (typeof value === 'symbol') {
46
+ return value.toString();
47
+ }
48
+ if (typeof value !== 'object' || value === null) {
49
+ return value;
50
+ }
51
+ // Pop entries that are no longer ancestors of the current value.
52
+ // JSON.stringify's depth-first walk binds `this` to the immediate
53
+ // parent, so anything above `this` on the stack has been left.
54
+ while (path.length > 0 && path.at(-1) !== this) {
55
+ path.pop();
56
+ }
57
+ if (path.includes(value)) {
58
+ return '[Circular]';
59
+ }
60
+ path.push(value);
61
+ return value;
62
+ };
63
+ };
64
+
65
+ /**
66
+ * Format log records as newline-delimited JSON objects.
67
+ *
68
+ * Metadata values that JSON.stringify cannot natively serialize are sanitized
69
+ * to safe representations: `BigInt` becomes its decimal string, functions and
70
+ * symbols are tagged sentinels, and circular references are replaced with
71
+ * `"[Circular]"` rather than throwing. The formatter never throws on a
72
+ * structurally valid `LogRecord`.
73
+ */
74
+ export const createJsonFormatter = (): LogFormatter => ({
75
+ format(record: LogRecord): string {
76
+ const { category, level, message, metadata, timestamp } = record;
77
+ return JSON.stringify(
78
+ {
79
+ ...metadata,
80
+ category,
81
+ level,
82
+ message,
83
+ timestamp: timestamp.toISOString(),
84
+ },
85
+ createSafeReplacer()
86
+ );
87
+ },
88
+ });
89
+
90
+ const LEVEL_COLORS: Record<string, string> = {
91
+ debug: '\u001B[36m',
92
+ error: '\u001B[31m',
93
+ fatal: '\u001B[35m',
94
+ info: '\u001B[32m',
95
+ trace: '\u001B[90m',
96
+ warn: '\u001B[33m',
97
+ };
98
+
99
+ const RESET = '\u001B[0m';
100
+
101
+ const formatTimestamp = (timestamp: Date): string =>
102
+ `${timestamp.toISOString().slice(11, 19)} `;
103
+
104
+ const formatMetadata = (metadata: Record<string, unknown>): string => {
105
+ const entries = Object.entries(metadata);
106
+ if (entries.length === 0) {
107
+ return '';
108
+ }
109
+ // Use the same safe replacer as the JSON formatter so BigInt, functions,
110
+ // symbols, and circular references never throw from the pretty path.
111
+ // The replacer is created per `format` call so the ancestor path stack
112
+ // does not leak across log records. The stack self-resets at the start
113
+ // of each `JSON.stringify` invocation because the new call's wrapper is
114
+ // a fresh object that does not match anything left on the stack, so the
115
+ // pop-loop drains it before the first value is inspected. Wrapping each
116
+ // value in an array ensures the replacer observes top-level BigInt,
117
+ // function, and symbol values, which `JSON.stringify` would otherwise
118
+ // drop or throw on before invoking it.
119
+ const replacer = createSafeReplacer();
120
+ return ` ${entries
121
+ .map(([key, value]) => {
122
+ if (typeof value === 'string') {
123
+ return `${key}=${value}`;
124
+ }
125
+ const wrapped = JSON.stringify([value], replacer);
126
+ // Strip the surrounding `[` and `]` from the wrapped array form.
127
+ const display = wrapped.slice(1, -1);
128
+ return `${key}=${display}`;
129
+ })
130
+ .join(' ')}`;
131
+ };
132
+
133
+ const formatLevel = (level: string, useColors: boolean): string => {
134
+ const label = level.toUpperCase().padEnd(5);
135
+ if (!useColors) {
136
+ return label;
137
+ }
138
+ const color = LEVEL_COLORS[level] ?? '';
139
+ return `${color}${label}${RESET}`;
140
+ };
141
+
142
+ /** Format log records for human-readable local output. */
143
+ export const createPrettyFormatter = (
144
+ options: PrettyFormatterOptions = {}
145
+ ): LogFormatter => {
146
+ const showTimestamps = options.timestamps !== false;
147
+ const useColors = options.colors === true;
148
+
149
+ return {
150
+ format(record: LogRecord): string {
151
+ const prefix = showTimestamps ? formatTimestamp(record.timestamp) : '';
152
+ const level = formatLevel(record.level, useColors);
153
+ const metadata = formatMetadata(record.metadata);
154
+ const body = `${level} [${record.category}] ${record.message}`;
155
+ return metadata.length > 0
156
+ ? `${prefix}${body}${metadata}`
157
+ : `${prefix}${body}`;
158
+ },
159
+ };
160
+ };
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Primitive observability API for Trails.
3
+ *
4
+ * `@ontrails/observability` is the public package for log and trace sink contracts.
5
+ * Adapter packages should build on these types instead of importing
6
+ * framework internals directly.
7
+ *
8
+ * @see {@link https://github.com/outfitter-dev/trails/blob/main/docs/adr/0041-unified-observability.md | ADR-0041 Unified Observability}
9
+ */
10
+ export { combine } from './combine.js';
11
+ export { createJsonFormatter, createPrettyFormatter } from './formatters.js';
12
+ export {
13
+ DEFAULT_MEMORY_SINK_MAX_RECORDS,
14
+ createBoundedMemorySink,
15
+ createMemorySink,
16
+ } from './memory.js';
17
+ export { renderTraceTree } from './renderer.js';
18
+ export { createConsoleSink, createFileSink } from './sinks.js';
19
+ export type { CombinedSink } from './combine.js';
20
+ export type { PrettyFormatterOptions } from './formatters.js';
21
+ export type { MemorySinkOptions, MemoryTraceSink } from './memory.js';
22
+ export type {
23
+ ConsoleSinkOptions,
24
+ FileLogSink,
25
+ FileSinkConfig,
26
+ FileSinkOptions,
27
+ } from './sinks.js';
28
+
29
+ export type {
30
+ Logger,
31
+ LogFormatter,
32
+ LogLevel,
33
+ LogRecord,
34
+ LogSink,
35
+ ObserveCapabilities,
36
+ ObserveConfig,
37
+ ObserveInput,
38
+ TraceContext,
39
+ TraceRecord,
40
+ TraceSink,
41
+ } from '@ontrails/core';
package/src/memory.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { TraceRecord, TraceSink } from '@ontrails/core';
2
+
3
+ export const DEFAULT_MEMORY_SINK_MAX_RECORDS = 1000;
4
+
5
+ export interface MemorySinkOptions {
6
+ /** Maximum records retained in memory. Defaults to {@link DEFAULT_MEMORY_SINK_MAX_RECORDS}. */
7
+ readonly maxRecords?: number | undefined;
8
+ }
9
+
10
+ export interface MemoryTraceSink extends TraceSink {
11
+ /** Maximum retained records before older entries are dropped. */
12
+ readonly maxRecords: number;
13
+ /** Number of records dropped since the last clear. */
14
+ readonly droppedCount: number;
15
+ /** Remove retained records and reset the dropped counter. */
16
+ clear(): void;
17
+ /** Return retained records, oldest first, as a stable snapshot. */
18
+ records(): readonly TraceRecord[];
19
+ }
20
+
21
+ const normalizeMaxRecords = (value: number | undefined): number => {
22
+ const maxRecords = value ?? DEFAULT_MEMORY_SINK_MAX_RECORDS;
23
+ if (!Number.isInteger(maxRecords) || maxRecords < 1) {
24
+ throw new RangeError(
25
+ 'Memory trace sink maxRecords must be a positive integer'
26
+ );
27
+ }
28
+ return maxRecords;
29
+ };
30
+
31
+ /** Bounded in-memory trace sink for tests, dogfood tooling, and local trace rendering. */
32
+ export const createMemorySink = (
33
+ options: MemorySinkOptions = {}
34
+ ): MemoryTraceSink => {
35
+ const maxRecords = normalizeMaxRecords(options.maxRecords);
36
+ const retained: TraceRecord[] = [];
37
+ let droppedCount = 0;
38
+
39
+ return {
40
+ clear() {
41
+ retained.length = 0;
42
+ droppedCount = 0;
43
+ },
44
+ get droppedCount() {
45
+ return droppedCount;
46
+ },
47
+ maxRecords,
48
+ records: () => [...retained],
49
+ write(record) {
50
+ retained.push(record);
51
+ const overflow = retained.length - maxRecords;
52
+ if (overflow > 0) {
53
+ retained.splice(0, overflow);
54
+ droppedCount += overflow;
55
+ }
56
+ },
57
+ };
58
+ };
59
+
60
+ /** @alias */
61
+ export const createBoundedMemorySink = createMemorySink;
package/src/otel.ts ADDED
@@ -0,0 +1,274 @@
1
+ import type { TraceRecord, TraceSink } from '@ontrails/core';
2
+
3
+ type OtelAttributeValue = string | number | boolean;
4
+
5
+ /** OTel span representation produced by the adapter. */
6
+ export interface OtelSpan {
7
+ readonly traceId: string;
8
+ readonly spanId: string;
9
+ readonly parentSpanId?: string | undefined;
10
+ readonly operationName: string;
11
+ readonly startTime: number;
12
+ readonly endTime?: number | undefined;
13
+ readonly status: 'OK' | 'ERROR' | 'UNSET';
14
+ readonly kind: 'INTERNAL' | 'SERVER';
15
+ readonly attributes: Readonly<Record<string, OtelAttributeValue>>;
16
+ }
17
+
18
+ /** Callback that receives translated OTel spans. */
19
+ export type OtelExporter = (spans: readonly OtelSpan[]) => void | Promise<void>;
20
+
21
+ /** Configuration for the OTel adapter. */
22
+ export interface OtelAdapterOptions {
23
+ readonly exporter: OtelExporter;
24
+ readonly batchSize?: number;
25
+ }
26
+
27
+ /** Map from TraceRecord status to OTel status. */
28
+ const STATUS_MAP: Record<TraceRecord['status'], OtelSpan['status']> = {
29
+ cancelled: 'UNSET',
30
+ err: 'ERROR',
31
+ ok: 'OK',
32
+ };
33
+
34
+ /** Derive OTel span kind from parentId presence. */
35
+ const deriveKind = (parentId: string | undefined): OtelSpan['kind'] =>
36
+ parentId === undefined ? 'SERVER' : 'INTERNAL';
37
+
38
+ const SAFE_SIGNAL_PAYLOAD_ATTRIBUTE_KEYS = new Set([
39
+ 'trails.signal.payload.byte_length',
40
+ 'trails.signal.payload.digest',
41
+ 'trails.signal.payload.redacted',
42
+ 'trails.signal.payload.shape',
43
+ 'trails.signal.payload.top_level_entry_count',
44
+ ]);
45
+
46
+ const UNSAFE_SENSITIVE_ATTRIBUTE_SEGMENTS = new Set([
47
+ 'authorization',
48
+ 'cookie',
49
+ 'password',
50
+ 'secret',
51
+ 'token',
52
+ ]);
53
+ const UNSAFE_RAW_ATTRIBUTE_NAMES = new Set([
54
+ 'body',
55
+ 'input',
56
+ 'output',
57
+ 'payload',
58
+ ]);
59
+
60
+ const UNSAFE_ATTRIBUTE_KEYS = new Set([
61
+ 'error.message',
62
+ 'error.stack',
63
+ 'exception.message',
64
+ 'exception.stacktrace',
65
+ 'message',
66
+ 'stack',
67
+ 'stacktrace',
68
+ ]);
69
+
70
+ const normalizeAttributeKey = (key: string): string =>
71
+ key.replaceAll(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase();
72
+
73
+ const splitAttributeKey = (key: string): readonly string[] =>
74
+ normalizeAttributeKey(key).split(/[._-]+/u);
75
+
76
+ const isUnsafeCustomAttributeKey = (key: string): boolean => {
77
+ const normalized = normalizeAttributeKey(key);
78
+ if (SAFE_SIGNAL_PAYLOAD_ATTRIBUTE_KEYS.has(normalized)) {
79
+ return false;
80
+ }
81
+ if (
82
+ UNSAFE_ATTRIBUTE_KEYS.has(normalized) ||
83
+ normalized.endsWith('.error.message') ||
84
+ normalized.endsWith('.error.stack') ||
85
+ normalized.endsWith('.exception.message') ||
86
+ normalized.endsWith('.exception.stacktrace')
87
+ ) {
88
+ return true;
89
+ }
90
+ const parts = splitAttributeKey(normalized);
91
+ if (parts.some((part) => UNSAFE_SENSITIVE_ATTRIBUTE_SEGMENTS.has(part))) {
92
+ return true;
93
+ }
94
+ const last = parts.at(-1);
95
+ if (last !== undefined && UNSAFE_RAW_ATTRIBUTE_NAMES.has(last)) {
96
+ return true;
97
+ }
98
+ return parts.includes('payload');
99
+ };
100
+
101
+ const isOtelAttributeValue = (value: unknown): value is OtelAttributeValue =>
102
+ typeof value === 'string' ||
103
+ typeof value === 'number' ||
104
+ typeof value === 'boolean';
105
+
106
+ /** Attribute mapping: record field → OTel attribute key + extractor. */
107
+ const ATTR_MAP: readonly {
108
+ key: string;
109
+ get: (r: TraceRecord) => OtelAttributeValue | undefined;
110
+ }[] = [
111
+ {
112
+ get: (r) => (r.kind === 'activation' ? r.name : undefined),
113
+ key: 'trails.activation.event',
114
+ },
115
+ { get: (r) => r.errorCategory, key: 'trails.error.category' },
116
+ { get: (r) => r.intent, key: 'trails.intent' },
117
+ { get: (r) => r.permit?.id, key: 'trails.permit.id' },
118
+ { get: (r) => r.permit?.tenantId, key: 'trails.permit.tenant_id' },
119
+ { get: (r) => r.kind, key: 'trails.record.kind' },
120
+ { get: (r) => r.name, key: 'trails.record.name' },
121
+ { get: (r) => r.sampled, key: 'trails.sampled' },
122
+ {
123
+ get: (r) => (r.kind === 'signal' ? r.name : undefined),
124
+ key: 'trails.signal.event',
125
+ },
126
+ { get: (r) => r.id, key: 'trails.span.id' },
127
+ { get: (r) => r.parentId, key: 'trails.span.parent_id' },
128
+ { get: (r) => r.rootId, key: 'trails.span.root_id' },
129
+ { get: (r) => r.status, key: 'trails.status' },
130
+ { get: (r) => r.surface, key: 'trails.surface' },
131
+ {
132
+ get: (r) => (r.endedAt === undefined ? undefined : r.endedAt - r.startedAt),
133
+ key: 'trails.timing.duration_ms',
134
+ },
135
+ { get: (r) => r.endedAt, key: 'trails.timing.ended_at_ms' },
136
+ { get: (r) => r.startedAt, key: 'trails.timing.started_at_ms' },
137
+ { get: (r) => r.traceId, key: 'trails.trace.id' },
138
+ { get: (r) => r.trailId, key: 'trails.trail.id' },
139
+ ];
140
+
141
+ const STABLE_ATTRIBUTE_KEYS = new Set(ATTR_MAP.map(({ key }) => key));
142
+
143
+ /** Build the trails-namespaced attributes from a TraceRecord. */
144
+ const buildAttributes = (
145
+ record: TraceRecord
146
+ ): Record<string, OtelAttributeValue> => {
147
+ const attrs: Record<string, OtelAttributeValue> = {};
148
+ for (const { key, get } of ATTR_MAP) {
149
+ const val = get(record);
150
+ if (val !== undefined) {
151
+ attrs[key] = val;
152
+ }
153
+ }
154
+ for (const [key, val] of Object.entries(record.attrs)) {
155
+ if (
156
+ attrs[key] === undefined &&
157
+ !STABLE_ATTRIBUTE_KEYS.has(key) &&
158
+ !isUnsafeCustomAttributeKey(key) &&
159
+ isOtelAttributeValue(val)
160
+ ) {
161
+ attrs[key] = val;
162
+ }
163
+ }
164
+ return attrs;
165
+ };
166
+
167
+ /** Translate a TraceRecord into an OTel span. */
168
+ const toOtelSpan = (record: TraceRecord): OtelSpan => ({
169
+ attributes: buildAttributes(record),
170
+ endTime: record.endedAt,
171
+ kind: deriveKind(record.parentId),
172
+ operationName: record.name,
173
+ parentSpanId: record.parentId,
174
+ spanId: record.id,
175
+ startTime: record.startedAt,
176
+ status: STATUS_MAP[record.status],
177
+ traceId: record.traceId,
178
+ });
179
+
180
+ /** A TraceSink extended with an explicit flush for shutdown. */
181
+ export interface OtelSink extends TraceSink {
182
+ /**
183
+ * Flush any remaining buffered spans to the exporter.
184
+ *
185
+ * Call this during shutdown after the app stops accepting new work. Concurrent
186
+ * flush calls await the same in-flight export. If the exporter rejects, the
187
+ * failed batch is restored to the buffer so a later flush can retry it.
188
+ */
189
+ readonly flush: () => Promise<void>;
190
+ }
191
+
192
+ /**
193
+ * Create a TraceSink that translates Tracing to OTel spans.
194
+ *
195
+ * The adapter maps Trails-native fields to OpenTelemetry span attributes
196
+ * under a `trails.*` namespace. Pass any OTel-compatible exporter callback
197
+ * to forward spans to your collector.
198
+ *
199
+ * Translates and exports spans on each write. Call `flush()` on shutdown
200
+ * to send any remaining buffered spans.
201
+ */
202
+ export const createOtelAdapter = (options: OtelAdapterOptions): OtelSink => {
203
+ const batchSize = options.batchSize ?? 1;
204
+ if (!(Number.isSafeInteger(batchSize) && batchSize > 0)) {
205
+ throw new RangeError('OTel adapter batchSize must be a positive integer');
206
+ }
207
+ const buffer: OtelSpan[] = [];
208
+ let activeFlush: Promise<void> | undefined;
209
+ let flushAllRequested = false;
210
+
211
+ const exportBuffered = async (): Promise<void> => {
212
+ const batch = buffer.splice(0);
213
+ try {
214
+ await options.exporter(batch);
215
+ } catch (error) {
216
+ // Restore batch on exporter failure so data is not lost.
217
+ buffer.unshift(...batch);
218
+ throw error;
219
+ }
220
+ };
221
+
222
+ const startFlush = (request?: {
223
+ readonly flushAll?: boolean;
224
+ }): Promise<void> => {
225
+ if (request?.flushAll === true) {
226
+ flushAllRequested = true;
227
+ }
228
+ if (activeFlush !== undefined) {
229
+ return activeFlush;
230
+ }
231
+ if (
232
+ buffer.length === 0 ||
233
+ (request?.flushAll !== true && buffer.length < batchSize)
234
+ ) {
235
+ if (buffer.length === 0) {
236
+ flushAllRequested = false;
237
+ }
238
+ return Promise.resolve();
239
+ }
240
+ activeFlush = (async () => {
241
+ try {
242
+ let shouldContinue = true;
243
+ do {
244
+ await exportBuffered();
245
+ shouldContinue =
246
+ buffer.length > 0 &&
247
+ (flushAllRequested || buffer.length >= batchSize);
248
+ } while (shouldContinue);
249
+ } finally {
250
+ activeFlush = undefined;
251
+ if (buffer.length === 0) {
252
+ flushAllRequested = false;
253
+ }
254
+ }
255
+ })();
256
+ return activeFlush;
257
+ };
258
+
259
+ const flush = (): Promise<void> => startFlush({ flushAll: true });
260
+
261
+ const maybeFlush = async (): Promise<void> => {
262
+ while (buffer.length >= batchSize) {
263
+ await startFlush();
264
+ }
265
+ };
266
+
267
+ return {
268
+ flush,
269
+ write: async (record: TraceRecord): Promise<void> => {
270
+ buffer.push(toOtelSpan(record));
271
+ await maybeFlush();
272
+ },
273
+ };
274
+ };