autotel 2.22.0 → 2.23.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.
Files changed (110) hide show
  1. package/README.md +112 -6
  2. package/dist/auto.cjs +3 -3
  3. package/dist/auto.js +2 -2
  4. package/dist/{chunk-EWH2542B.js → chunk-3AMR5XLZ.js} +3 -3
  5. package/dist/{chunk-EWH2542B.js.map → chunk-3AMR5XLZ.js.map} +1 -1
  6. package/dist/chunk-3QXBFGKP.js +344 -0
  7. package/dist/chunk-3QXBFGKP.js.map +1 -0
  8. package/dist/{chunk-VQFF2WMP.cjs → chunk-3ZFDJJWZ.cjs} +37 -29
  9. package/dist/chunk-3ZFDJJWZ.cjs.map +1 -0
  10. package/dist/{chunk-CQC6RVLR.cjs → chunk-4RZ4JUBY.cjs} +5 -5
  11. package/dist/{chunk-CQC6RVLR.cjs.map → chunk-4RZ4JUBY.cjs.map} +1 -1
  12. package/dist/{chunk-PAVYKPCQ.js → chunk-5XUEHX7J.js} +3 -3
  13. package/dist/{chunk-PAVYKPCQ.js.map → chunk-5XUEHX7J.js.map} +1 -1
  14. package/dist/chunk-6S5RUKU3.cjs +347 -0
  15. package/dist/chunk-6S5RUKU3.cjs.map +1 -0
  16. package/dist/{chunk-BS757SL2.js → chunk-724XLWR3.js} +9 -4
  17. package/dist/chunk-724XLWR3.js.map +1 -0
  18. package/dist/chunk-7EQ4G4SI.cjs +146 -0
  19. package/dist/chunk-7EQ4G4SI.cjs.map +1 -0
  20. package/dist/{chunk-CQP5SQT4.cjs → chunk-AXFWWJF3.cjs} +7 -7
  21. package/dist/{chunk-CQP5SQT4.cjs.map → chunk-AXFWWJF3.cjs.map} +1 -1
  22. package/dist/{chunk-7NH625MS.cjs → chunk-BSZP4URK.cjs} +5 -5
  23. package/dist/{chunk-7NH625MS.cjs.map → chunk-BSZP4URK.cjs.map} +1 -1
  24. package/dist/{chunk-GZFH6P5U.js → chunk-GY4CRZSV.js} +14 -6
  25. package/dist/chunk-GY4CRZSV.js.map +1 -0
  26. package/dist/{chunk-QKUGUDXJ.cjs → chunk-HSEIUH7F.cjs} +10 -5
  27. package/dist/chunk-HSEIUH7F.cjs.map +1 -0
  28. package/dist/{chunk-DTW3WB7Z.js → chunk-IPKXURBW.js} +3 -3
  29. package/dist/{chunk-DTW3WB7Z.js.map → chunk-IPKXURBW.js.map} +1 -1
  30. package/dist/chunk-J7VGRIAJ.js +64 -0
  31. package/dist/chunk-J7VGRIAJ.js.map +1 -0
  32. package/dist/chunk-KFOHQK7X.js +144 -0
  33. package/dist/chunk-KFOHQK7X.js.map +1 -0
  34. package/dist/{chunk-4UYR46UP.cjs → chunk-MSUHW2I4.cjs} +13 -13
  35. package/dist/{chunk-4UYR46UP.cjs.map → chunk-MSUHW2I4.cjs.map} +1 -1
  36. package/dist/chunk-T4B5LB6E.cjs +66 -0
  37. package/dist/chunk-T4B5LB6E.cjs.map +1 -0
  38. package/dist/{chunk-QHT4MUED.js → chunk-WCIIFRGL.js} +3 -3
  39. package/dist/{chunk-QHT4MUED.js.map → chunk-WCIIFRGL.js.map} +1 -1
  40. package/dist/decorators.cjs +3 -3
  41. package/dist/decorators.js +3 -3
  42. package/dist/drain-pipeline.cjs +13 -0
  43. package/dist/drain-pipeline.cjs.map +1 -0
  44. package/dist/drain-pipeline.d.cts +37 -0
  45. package/dist/drain-pipeline.d.ts +37 -0
  46. package/dist/drain-pipeline.js +4 -0
  47. package/dist/drain-pipeline.js.map +1 -0
  48. package/dist/event.cjs +6 -6
  49. package/dist/event.js +3 -3
  50. package/dist/functional.cjs +10 -10
  51. package/dist/functional.js +3 -3
  52. package/dist/index.cjs +256 -41
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.cts +72 -3
  55. package/dist/index.d.ts +72 -3
  56. package/dist/index.js +210 -11
  57. package/dist/index.js.map +1 -1
  58. package/dist/{init-BMiXSJNM.d.cts → init-BC5aN8bh.d.cts} +18 -0
  59. package/dist/{init-ByRbNTRo.d.ts → init-_FG4IbhF.d.ts} +18 -0
  60. package/dist/instrumentation.cjs +9 -9
  61. package/dist/instrumentation.js +2 -2
  62. package/dist/messaging.cjs +7 -7
  63. package/dist/messaging.js +4 -4
  64. package/dist/parse-error.cjs +13 -0
  65. package/dist/parse-error.cjs.map +1 -0
  66. package/dist/parse-error.d.cts +13 -0
  67. package/dist/parse-error.d.ts +13 -0
  68. package/dist/parse-error.js +4 -0
  69. package/dist/parse-error.js.map +1 -0
  70. package/dist/processors.cjs +2 -2
  71. package/dist/processors.d.cts +40 -4
  72. package/dist/processors.d.ts +40 -4
  73. package/dist/processors.js +1 -1
  74. package/dist/semantic-helpers.cjs +8 -8
  75. package/dist/semantic-helpers.js +4 -4
  76. package/dist/webhook.cjs +4 -4
  77. package/dist/webhook.js +3 -3
  78. package/dist/workflow-distributed.cjs +5 -5
  79. package/dist/workflow-distributed.js +3 -3
  80. package/dist/workflow.cjs +8 -8
  81. package/dist/workflow.js +4 -4
  82. package/dist/yaml-config.d.cts +2 -1
  83. package/dist/yaml-config.d.ts +2 -1
  84. package/package.json +11 -1
  85. package/src/drain-pipeline.test.ts +68 -0
  86. package/src/drain-pipeline.ts +199 -0
  87. package/src/flatten-attributes.test.ts +76 -0
  88. package/src/flatten-attributes.ts +80 -0
  89. package/src/functional.test.ts +63 -0
  90. package/src/functional.ts +11 -3
  91. package/src/index.ts +33 -0
  92. package/src/init.ts +22 -0
  93. package/src/parse-error.test.ts +73 -0
  94. package/src/parse-error.ts +112 -0
  95. package/src/pretty-log-formatter.test.ts +123 -0
  96. package/src/pretty-log-formatter.ts +210 -0
  97. package/src/processors/canonical-log-line-processor.test.ts +81 -25
  98. package/src/processors/canonical-log-line-processor.ts +130 -42
  99. package/src/request-logger.test.ts +124 -0
  100. package/src/request-logger.ts +140 -0
  101. package/src/structured-error.test.ts +76 -0
  102. package/src/structured-error.ts +86 -0
  103. package/dist/chunk-2RQDNGV3.js +0 -126
  104. package/dist/chunk-2RQDNGV3.js.map +0 -1
  105. package/dist/chunk-BS757SL2.js.map +0 -1
  106. package/dist/chunk-GZFH6P5U.js.map +0 -1
  107. package/dist/chunk-ONK2Y22L.cjs +0 -128
  108. package/dist/chunk-ONK2Y22L.cjs.map +0 -1
  109. package/dist/chunk-QKUGUDXJ.cjs.map +0 -1
  110. package/dist/chunk-VQFF2WMP.cjs.map +0 -1
@@ -17,8 +17,6 @@ describe('CanonicalLogLineProcessor', () => {
17
17
 
18
18
  beforeEach(() => {
19
19
  logEntries = [];
20
- // Pino-native signature: (extra, message)
21
- // The processor ONLY calls with this order, so we can cast safely
22
20
  mockLogger = {
23
21
  info: vi.fn((extra, msg) => {
24
22
  logEntries.push({
@@ -63,7 +61,7 @@ describe('CanonicalLogLineProcessor', () => {
63
61
  spanId: '00f067aa0ba902b7',
64
62
  traceFlags: 1,
65
63
  }),
66
- parentSpanContext: undefined, // No parent by default (root span)
64
+ parentSpanContext: undefined,
67
65
  attributes: {
68
66
  'user.id': 'user-123',
69
67
  'cart.total_cents': 15_999,
@@ -73,7 +71,7 @@ describe('CanonicalLogLineProcessor', () => {
73
71
  duration: [0, 1_247_000_000], // 1.247 seconds in nanoseconds
74
72
  startTime: [1_703_044_800, 0], // Unix timestamp in nanoseconds
75
73
  endTime: [1_703_044_800, 1_247_000_000],
76
- kind: SpanKind.SERVER, // Default to SERVER (service entry point)
74
+ kind: SpanKind.SERVER,
77
75
  resource: resourceFromAttributes({
78
76
  'service.name': 'test-service',
79
77
  'service.version': '1.0.0',
@@ -100,7 +98,7 @@ describe('CanonicalLogLineProcessor', () => {
100
98
  operation: 'test.operation',
101
99
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
102
100
  spanId: '00f067aa0ba902b7',
103
- correlationId: '4bf92f3577b34da6', // First 16 chars of traceId
101
+ correlationId: '4bf92f3577b34da6',
104
102
  'user.id': 'user-123',
105
103
  'cart.total_cents': 15_999,
106
104
  'http.method': 'POST',
@@ -181,14 +179,12 @@ describe('CanonicalLogLineProcessor', () => {
181
179
  logger: mockLogger,
182
180
  rootSpansOnly: true,
183
181
  });
184
- // Create a span with a LOCAL parent (isRemote: false)
185
- // This is a child span within the same service and should be skipped
186
182
  const span = createMockSpan({
187
183
  parentSpanContext: {
188
184
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
189
185
  spanId: 'local-parent-span-id',
190
186
  traceFlags: 1,
191
- isRemote: false, // Local parent (same service)
187
+ isRemote: false,
192
188
  },
193
189
  });
194
190
 
@@ -202,20 +198,17 @@ describe('CanonicalLogLineProcessor', () => {
202
198
  logger: mockLogger,
203
199
  rootSpansOnly: true,
204
200
  });
205
- // Create a span with a REMOTE parent (isRemote: true)
206
- // This is a service entry point from distributed tracing
207
201
  const span = createMockSpan({
208
202
  parentSpanContext: {
209
203
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
210
204
  spanId: 'remote-parent-span-id',
211
205
  traceFlags: 1,
212
- isRemote: true, // Remote parent (from upstream service)
206
+ isRemote: true,
213
207
  },
214
208
  });
215
209
 
216
210
  processor.onEnd(span);
217
211
 
218
- // Should emit because this is a service entry point (remote parent)
219
212
  expect(mockLogger.info).toHaveBeenCalledTimes(1);
220
213
  });
221
214
 
@@ -224,13 +217,12 @@ describe('CanonicalLogLineProcessor', () => {
224
217
  logger: mockLogger,
225
218
  rootSpansOnly: false,
226
219
  });
227
- // Even a local child span should emit when rootSpansOnly is false
228
220
  const span = createMockSpan({
229
221
  parentSpanContext: {
230
222
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
231
223
  spanId: 'parent-span-id',
232
224
  traceFlags: 1,
233
- isRemote: false, // Local parent
225
+ isRemote: false,
234
226
  },
235
227
  });
236
228
 
@@ -255,10 +247,7 @@ describe('CanonicalLogLineProcessor', () => {
255
247
  expect(mockLogger.error).toHaveBeenCalledTimes(1);
256
248
  expect(logEntries[0].level).toBe('error');
257
249
  expect(logEntries[0].attrs.status_code).toBe(SpanStatusCode.ERROR);
258
- // status_message might be undefined if not set
259
- if (logEntries[0].attrs.status_message) {
260
- expect(logEntries[0].attrs.status_message).toBe('Something went wrong');
261
- }
250
+ expect(logEntries[0].attrs.status_message).toBe('Something went wrong');
262
251
  });
263
252
 
264
253
  it('should use info level for successful spans', () => {
@@ -272,6 +261,20 @@ describe('CanonicalLogLineProcessor', () => {
272
261
  expect(mockLogger.info).toHaveBeenCalledTimes(1);
273
262
  expect(logEntries[0].level).toBe('info');
274
263
  });
264
+
265
+ it('should use explicit autotel.log.level attribute when provided', () => {
266
+ const processor = new CanonicalLogLineProcessor({ logger: mockLogger });
267
+ const span = createMockSpan({
268
+ attributes: {
269
+ 'autotel.log.level': 'warn',
270
+ },
271
+ });
272
+
273
+ processor.onEnd(span);
274
+
275
+ expect(mockLogger.warn).toHaveBeenCalledTimes(1);
276
+ expect(logEntries[0].level).toBe('warn');
277
+ });
275
278
  });
276
279
 
277
280
  describe('minLevel option', () => {
@@ -280,12 +283,10 @@ describe('CanonicalLogLineProcessor', () => {
280
283
  logger: mockLogger,
281
284
  minLevel: 'info',
282
285
  });
283
- // Would need to force debug level, but for now just test the filter
284
286
  const span = createMockSpan();
285
287
 
286
288
  processor.onEnd(span);
287
289
 
288
- // Should still log info level
289
290
  expect(mockLogger.info).toHaveBeenCalledTimes(1);
290
291
  });
291
292
 
@@ -295,12 +296,11 @@ describe('CanonicalLogLineProcessor', () => {
295
296
  minLevel: 'warn',
296
297
  });
297
298
  const span = createMockSpan({
298
- status: { code: SpanStatusCode.OK }, // Would be info level
299
+ status: { code: SpanStatusCode.OK },
299
300
  });
300
301
 
301
302
  processor.onEnd(span);
302
303
 
303
- // Info is below warn, so should be skipped
304
304
  expect(mockLogger.info).not.toHaveBeenCalled();
305
305
  });
306
306
  });
@@ -322,6 +322,65 @@ describe('CanonicalLogLineProcessor', () => {
322
322
  });
323
323
  });
324
324
 
325
+ describe('emit control hooks', () => {
326
+ it('should skip emit when shouldEmit returns false', () => {
327
+ const shouldEmit = vi.fn(() => false);
328
+ const processor = new CanonicalLogLineProcessor({
329
+ logger: mockLogger,
330
+ shouldEmit,
331
+ });
332
+ const span = createMockSpan();
333
+
334
+ processor.onEnd(span);
335
+
336
+ expect(shouldEmit).toHaveBeenCalledTimes(1);
337
+ expect(mockLogger.info).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it('should call drain after emit with canonical event context', async () => {
341
+ const drain = vi.fn(async () => {});
342
+ const processor = new CanonicalLogLineProcessor({
343
+ logger: mockLogger,
344
+ drain,
345
+ });
346
+ const span = createMockSpan();
347
+
348
+ processor.onEnd(span);
349
+ await new Promise((resolve) => setImmediate(resolve));
350
+
351
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
352
+ expect(drain).toHaveBeenCalledTimes(1);
353
+ expect(drain.mock.calls[0][0]).toMatchObject({
354
+ level: 'info',
355
+ message: expect.stringContaining('test.operation'),
356
+ event: expect.objectContaining({
357
+ operation: 'test.operation',
358
+ traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
359
+ }),
360
+ });
361
+ });
362
+
363
+ it('should not keep below-threshold HTTP status when keep.status is configured', () => {
364
+ const processor = new CanonicalLogLineProcessor({
365
+ logger: mockLogger,
366
+ keep: [{ status: 500 }],
367
+ });
368
+ const span = createMockSpan({
369
+ status: { code: SpanStatusCode.ERROR },
370
+ attributes: {
371
+ 'http.response.status_code': 404,
372
+ },
373
+ });
374
+
375
+ processor.onEnd(span);
376
+
377
+ expect(mockLogger.error).not.toHaveBeenCalled();
378
+ expect(mockLogger.info).not.toHaveBeenCalled();
379
+ expect(mockLogger.warn).not.toHaveBeenCalled();
380
+ expect(mockLogger.debug).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
325
384
  describe('OTel Logs API fallback', () => {
326
385
  it('should use OTel Logs API when no logger provided', () => {
327
386
  // Mock the logs API
@@ -373,7 +432,6 @@ describe('CanonicalLogLineProcessor', () => {
373
432
 
374
433
  expect(mockLogger.info).toHaveBeenCalledTimes(1);
375
434
  const call = logEntries[0];
376
- // Should include all 100 attributes plus core fields
377
435
  expect(Object.keys(call.attrs).length).toBeGreaterThan(100);
378
436
  });
379
437
 
@@ -438,7 +496,6 @@ describe('CanonicalLogLineProcessor', () => {
438
496
  describe('attribute collision prevention', () => {
439
497
  it('should not allow span attributes to overwrite core metadata', () => {
440
498
  const processor = new CanonicalLogLineProcessor({ logger: mockLogger });
441
- // Create a span with attributes that match core metadata field names
442
499
  const span = createMockSpan({
443
500
  attributes: {
444
501
  traceId: 'malicious-trace-id',
@@ -454,7 +511,6 @@ describe('CanonicalLogLineProcessor', () => {
454
511
  processor.onEnd(span);
455
512
 
456
513
  const call = logEntries[0];
457
- // Core metadata should NOT be overwritten by span attributes
458
514
  expect(call.attrs.traceId).toBe('4bf92f3577b34da6a3ce929d0e0e4736');
459
515
  expect(call.attrs.spanId).toBe('00f067aa0ba902b7');
460
516
  expect(call.attrs.operation).toBe('test.operation');
@@ -29,6 +29,7 @@ import type {
29
29
  import type { Attributes, AttributeValue } from '@opentelemetry/api';
30
30
  import { logs, SeverityNumber } from '@opentelemetry/api-logs';
31
31
  import type { Logger } from '../logger';
32
+ import { formatPrettyLogLine, formatDuration } from '../pretty-log-formatter';
32
33
 
33
34
  /**
34
35
  * Function to redact sensitive attribute values
@@ -38,6 +39,22 @@ export type AttributeRedactorFn = (
38
39
  value: AttributeValue,
39
40
  ) => AttributeValue;
40
41
 
42
+ export interface CanonicalLogLineEvent {
43
+ span: ReadableSpan;
44
+ level: 'debug' | 'info' | 'warn' | 'error';
45
+ message: string;
46
+ event: Record<string, unknown>;
47
+ }
48
+
49
+ export interface KeepCondition {
50
+ /** Keep events where HTTP status >= this value. */
51
+ status?: number;
52
+ /** Keep events where duration_ms >= this value. */
53
+ durationMs?: number;
54
+ /** Keep events matching this path pattern (simple prefix match). */
55
+ path?: string;
56
+ }
57
+
41
58
  export interface CanonicalLogLineOptions {
42
59
  /** Logger to use for emitting canonical log lines (defaults to OTel Logs API) */
43
60
  logger?: Logger;
@@ -55,6 +72,25 @@ export interface CanonicalLogLineOptions {
55
72
  * matching the behavior of attributeRedactor in init().
56
73
  */
57
74
  attributeRedactor?: AttributeRedactorFn;
75
+ /** Predicate to decide whether to emit (runs after event is built). */
76
+ shouldEmit?: (ctx: CanonicalLogLineEvent) => boolean;
77
+ /**
78
+ * Declarative tail sampling conditions (OR logic). If any condition matches,
79
+ * the event is kept. Ignored when `shouldEmit` is provided.
80
+ *
81
+ * @example
82
+ * keep: [{ status: 500 }, { durationMs: 1000 }]
83
+ */
84
+ keep?: KeepCondition[];
85
+ /** Callback invoked after emit for custom fan-out. */
86
+ drain?: (ctx: CanonicalLogLineEvent) => void | Promise<void>;
87
+ /** Handler for drain failures. */
88
+ onDrainError?: (error: unknown, ctx: CanonicalLogLineEvent) => void;
89
+ /**
90
+ * Pretty-print canonical log lines to console in a tree format.
91
+ * Defaults to true when NODE_ENV is 'development'.
92
+ */
93
+ pretty?: boolean;
58
94
  }
59
95
 
60
96
  /**
@@ -121,6 +157,10 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
121
157
  private messageFormat: (span: ReadableSpan) => string;
122
158
  private includeResourceAttributes: boolean;
123
159
  private attributeRedactor?: AttributeRedactorFn;
160
+ private shouldEmit?: (ctx: CanonicalLogLineEvent) => boolean;
161
+ private drain?: (ctx: CanonicalLogLineEvent) => void | Promise<void>;
162
+ private onDrainError?: (error: unknown, ctx: CanonicalLogLineEvent) => void;
163
+ private pretty: boolean;
124
164
  private getOTelLogger: (() => ReturnType<typeof logs.getLogger>) | null =
125
165
  null;
126
166
 
@@ -132,25 +172,55 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
132
172
  options.messageFormat ?? ((span) => `[${span.name}] Request completed`);
133
173
  this.includeResourceAttributes = options.includeResourceAttributes ?? true;
134
174
  this.attributeRedactor = options.attributeRedactor;
175
+ this.shouldEmit =
176
+ options.shouldEmit ?? this.buildKeepPredicate(options.keep);
177
+ this.drain = options.drain;
178
+ this.onDrainError = options.onDrainError;
179
+ this.pretty =
180
+ options.pretty ??
181
+ (typeof process !== 'undefined' &&
182
+ process.env.NODE_ENV === 'development');
135
183
 
136
- // Lazy-load OTel logger if no custom logger provided
137
- // We can't initialize it here because logs API might not be ready
138
184
  if (!this.logger) {
139
185
  this.getOTelLogger = () => logs.getLogger('autotel.canonical-log-line');
140
186
  }
141
187
  }
142
188
 
189
+ private buildKeepPredicate(
190
+ keep?: KeepCondition[],
191
+ ): ((ctx: CanonicalLogLineEvent) => boolean) | undefined {
192
+ if (!keep || keep.length === 0) return undefined;
193
+
194
+ return (ctx: CanonicalLogLineEvent) => {
195
+ return keep.some((condition) => {
196
+ if (condition.status !== undefined) {
197
+ const httpStatus = Number(
198
+ ctx.event['http.response.status_code'] ?? 0,
199
+ );
200
+ if (httpStatus >= condition.status) return true;
201
+ }
202
+ if (
203
+ condition.durationMs !== undefined &&
204
+ Number(ctx.event.duration_ms ?? 0) >= condition.durationMs
205
+ ) {
206
+ return true;
207
+ }
208
+ if (condition.path !== undefined) {
209
+ const route = String(
210
+ ctx.event['http.route'] ?? ctx.event['url.path'] ?? '',
211
+ );
212
+ if (route.startsWith(condition.path)) return true;
213
+ }
214
+ return false;
215
+ });
216
+ };
217
+ }
218
+
143
219
  onStart(): void {
144
- // No-op - we only care about span end
220
+ // No-op
145
221
  }
146
222
 
147
223
  onEnd(span: ReadableSpan): void {
148
- // Skip if rootSpansOnly and this span has a LOCAL parent (same service)
149
- // We still emit for spans with REMOTE parents (from distributed tracing)
150
- // because those are the entry points ("roots") for THIS service.
151
- // Check if parent is remote (from another service via traceparent/b3 headers)
152
- // If isRemote is true, this span is a service entry point and should emit
153
- // If isRemote is false/undefined, this is a local child span and should be skipped
154
224
  if (
155
225
  this.rootSpansOnly &&
156
226
  span.parentSpanContext?.spanId &&
@@ -159,44 +229,55 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
159
229
  return;
160
230
  }
161
231
 
162
- // Determine log level from span status
163
232
  const level = this.getLogLevel(span);
164
233
  if (!this.shouldLog(level)) {
165
234
  return;
166
235
  }
167
236
 
168
- // Build canonical log line with ALL span attributes
169
237
  const canonicalLogLine = this.buildCanonicalLogLine(span);
238
+ const message = this.messageFormat(span);
239
+ const eventContext: CanonicalLogLineEvent = {
240
+ span,
241
+ level,
242
+ message,
243
+ event: canonicalLogLine,
244
+ };
245
+
246
+ if (this.shouldEmit && !this.shouldEmit(eventContext)) return;
247
+
248
+ if (this.pretty) {
249
+ console.log(formatPrettyLogLine(eventContext));
250
+ }
170
251
 
171
- // Emit via logger or OTel Logs API
172
252
  if (this.logger) {
173
- this.emitViaLogger(level, span, canonicalLogLine);
253
+ this.emitViaLogger(level, message, canonicalLogLine);
174
254
  } else if (this.getOTelLogger) {
175
255
  const otelLogger = this.getOTelLogger();
176
- this.emitViaOTel(level, span, canonicalLogLine, otelLogger);
256
+ this.emitViaOTel(level, message, canonicalLogLine, otelLogger);
257
+ }
258
+
259
+ if (this.drain) {
260
+ Promise.resolve(this.drain(eventContext)).catch((error) => {
261
+ if (this.onDrainError) {
262
+ this.onDrainError(error, eventContext);
263
+ return;
264
+ }
265
+ this.reportInternalWarning('canonicalLogLines.drain failed', error);
266
+ });
177
267
  }
178
268
  }
179
269
 
180
270
  private buildCanonicalLogLine(span: ReadableSpan): Record<string, unknown> {
181
- // Convert duration from [seconds, nanoseconds] to milliseconds
182
- // duration[0] is seconds, duration[1] is nanoseconds (fractional part)
183
271
  const durationMs = span.duration[0] * 1000 + span.duration[1] / 1_000_000;
184
-
185
- // Convert start time from [seconds, nanoseconds] to ISO string
186
- // startTime[0] is seconds, startTime[1] is nanoseconds (fractional part)
187
272
  const timestamp = new Date(
188
273
  span.startTime[0] * 1000 + span.startTime[1] / 1_000_000,
189
274
  ).toISOString();
190
275
 
191
- // Start with span attributes (potentially redacted)
192
- // We add these FIRST so core metadata fields below can't be overwritten
276
+ // Span attributes first so core metadata fields below take precedence
193
277
  const canonicalLogLine: Record<string, unknown> = {};
194
-
195
- // Apply redaction to span attributes if redactor is configured
196
278
  const attributes = this.redactAttributes(span.attributes);
197
279
  Object.assign(canonicalLogLine, attributes);
198
280
 
199
- // Include resource attributes (service-level context), also redacted
200
281
  if (this.includeResourceAttributes) {
201
282
  const resourceAttrs = this.redactAttributes(
202
283
  span.resource.attributes as Attributes,
@@ -204,13 +285,12 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
204
285
  Object.assign(canonicalLogLine, resourceAttrs);
205
286
  }
206
287
 
207
- // Set core metadata fields LAST to prevent span attributes from overwriting them
208
- // (e.g., if a span has an attribute named "traceId" or "timestamp")
209
288
  canonicalLogLine.operation = span.name;
210
289
  canonicalLogLine.traceId = span.spanContext().traceId;
211
290
  canonicalLogLine.spanId = span.spanContext().spanId;
212
291
  canonicalLogLine.correlationId = span.spanContext().traceId.slice(0, 16);
213
292
  canonicalLogLine.duration_ms = Math.round(durationMs * 100) / 100;
293
+ canonicalLogLine.duration = formatDuration(durationMs);
214
294
  canonicalLogLine.status_code = span.status.code;
215
295
  canonicalLogLine.status_message = span.status.message || undefined;
216
296
  canonicalLogLine.timestamp = timestamp;
@@ -218,12 +298,8 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
218
298
  return canonicalLogLine;
219
299
  }
220
300
 
221
- /**
222
- * Apply attribute redaction if a redactor is configured
223
- */
224
301
  private redactAttributes(attributes: Attributes): Record<string, unknown> {
225
302
  if (!this.attributeRedactor) {
226
- // No redaction configured, return as-is
227
303
  return { ...attributes };
228
304
  }
229
305
 
@@ -238,22 +314,18 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
238
314
 
239
315
  private emitViaLogger(
240
316
  level: 'debug' | 'info' | 'warn' | 'error',
241
- span: ReadableSpan,
317
+ message: string,
242
318
  canonicalLogLine: Record<string, unknown>,
243
319
  ): void {
244
- const message = this.messageFormat(span);
245
- // Pino-compatible signature: (extra, message)
246
320
  this.logger![level](canonicalLogLine, message);
247
321
  }
248
322
 
249
323
  private emitViaOTel(
250
324
  level: 'debug' | 'info' | 'warn' | 'error',
251
- span: ReadableSpan,
325
+ message: string,
252
326
  canonicalLogLine: Record<string, unknown>,
253
327
  otelLogger: ReturnType<typeof logs.getLogger>,
254
328
  ): void {
255
- const message = this.messageFormat(span);
256
- // Convert unknown values to strings for OTel Logs API compatibility
257
329
  const otelAttributes: Record<string, string | number | boolean> = {};
258
330
  for (const [key, value] of Object.entries(canonicalLogLine)) {
259
331
  if (
@@ -275,11 +347,17 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
275
347
  }
276
348
 
277
349
  private getLogLevel(span: ReadableSpan): 'debug' | 'info' | 'warn' | 'error' {
278
- // ERROR status code is 2
279
- if (span.status.code === 2) return 'error';
350
+ const explicitLevel = span.attributes['autotel.log.level'];
351
+ if (
352
+ explicitLevel === 'debug' ||
353
+ explicitLevel === 'info' ||
354
+ explicitLevel === 'warn' ||
355
+ explicitLevel === 'error'
356
+ ) {
357
+ return explicitLevel;
358
+ }
280
359
 
281
- // Could check for slow spans, etc. in the future
282
- // For now, default to info
360
+ if (span.status.code === 2) return 'error';
283
361
  return 'info';
284
362
  }
285
363
 
@@ -298,11 +376,21 @@ export class CanonicalLogLineProcessor implements SpanProcessor {
298
376
  return mapping[level] ?? SeverityNumber.INFO;
299
377
  }
300
378
 
379
+ private reportInternalWarning(message: string, error: unknown): void {
380
+ const err =
381
+ error instanceof Error ? error.message : String(error ?? 'unknown error');
382
+ if (this.logger) {
383
+ this.logger.warn({ error: err }, `[autotel] ${message}`);
384
+ return;
385
+ }
386
+ console.warn(`[autotel] ${message}: ${err}`);
387
+ }
388
+
301
389
  async forceFlush(): Promise<void> {
302
- // No-op - logging is fire-and-forget
390
+ // No-op
303
391
  }
304
392
 
305
393
  async shutdown(): Promise<void> {
306
- // No-op - logging is fire-and-forget
394
+ // No-op
307
395
  }
308
396
  }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRequestLogger } from './request-logger';
3
+ import type { TraceContext } from './trace-context';
4
+
5
+ function createMockContext(): TraceContext {
6
+ return {
7
+ traceId: 'trace-id',
8
+ spanId: 'span-id',
9
+ correlationId: 'corr-id',
10
+ setAttribute: vi.fn(),
11
+ setAttributes: vi.fn(),
12
+ setStatus: vi.fn(),
13
+ recordException: vi.fn(),
14
+ addEvent: vi.fn(),
15
+ addLink: vi.fn(),
16
+ addLinks: vi.fn(),
17
+ updateName: vi.fn(),
18
+ isRecording: vi.fn(() => true),
19
+ getBaggage: vi.fn(),
20
+ setBaggage: vi.fn(),
21
+ deleteBaggage: vi.fn(),
22
+ getAllBaggage: vi.fn(() => new Map()),
23
+ } as unknown as TraceContext;
24
+ }
25
+
26
+ describe('getRequestLogger', () => {
27
+ it('sets flattened fields onto active span context', () => {
28
+ const ctx = createMockContext();
29
+ const log = getRequestLogger(ctx);
30
+
31
+ log.set({
32
+ user: { id: 'u1', plan: 'pro' },
33
+ attempts: 3,
34
+ success: true,
35
+ });
36
+
37
+ expect(ctx.setAttributes).toHaveBeenCalledWith({
38
+ 'user.id': 'u1',
39
+ 'user.plan': 'pro',
40
+ attempts: 3,
41
+ success: true,
42
+ });
43
+ });
44
+
45
+ it('adds warning events and sets warning level marker', () => {
46
+ const ctx = createMockContext();
47
+ const log = getRequestLogger(ctx);
48
+
49
+ log.warn('slow request', {
50
+ http: { route: '/checkout' },
51
+ duration_ms: 1350,
52
+ });
53
+
54
+ expect(ctx.addEvent).toHaveBeenCalledWith('log.warn', {
55
+ message: 'slow request',
56
+ 'http.route': '/checkout',
57
+ duration_ms: 1350,
58
+ });
59
+ expect(ctx.setAttribute).toHaveBeenCalledWith('autotel.log.level', 'warn');
60
+ });
61
+
62
+ it('records and annotates errors with structured diagnostics', () => {
63
+ const ctx = createMockContext();
64
+ const log = getRequestLogger(ctx);
65
+
66
+ log.error(new Error('payment processor unavailable'), {
67
+ step: 'payment',
68
+ });
69
+
70
+ expect(ctx.recordException).toHaveBeenCalled();
71
+ expect(ctx.addEvent).toHaveBeenCalledWith('log.error', {
72
+ message: 'payment processor unavailable',
73
+ step: 'payment',
74
+ });
75
+ expect(ctx.setAttributes).toHaveBeenCalledWith(
76
+ expect.objectContaining({
77
+ 'error.message': 'payment processor unavailable',
78
+ }),
79
+ );
80
+ });
81
+
82
+ it('returns accumulated context via getContext()', () => {
83
+ const ctx = createMockContext();
84
+ const log = getRequestLogger(ctx);
85
+
86
+ log.set({ user: { id: 'u1' } });
87
+ log.info('step', { checkout: { step: 'payment' } });
88
+
89
+ expect(log.getContext()).toEqual({
90
+ user: { id: 'u1' },
91
+ checkout: { step: 'payment' },
92
+ });
93
+ });
94
+
95
+ it('emitNow records manual event and returns snapshot', async () => {
96
+ const ctx = createMockContext();
97
+ const onEmit = vi.fn(async () => {});
98
+ const log = getRequestLogger(ctx, { onEmit });
99
+
100
+ log.set({ user: { id: 'u1' } });
101
+ const snapshot = log.emitNow({ stage: 'preflight' });
102
+
103
+ expect(snapshot).toMatchObject({
104
+ traceId: 'trace-id',
105
+ spanId: 'span-id',
106
+ correlationId: 'corr-id',
107
+ context: {
108
+ user: { id: 'u1' },
109
+ stage: 'preflight',
110
+ },
111
+ timestamp: expect.any(String),
112
+ });
113
+ expect(ctx.addEvent).toHaveBeenCalledWith(
114
+ 'log.emit.manual',
115
+ expect.objectContaining({
116
+ 'user.id': 'u1',
117
+ stage: 'preflight',
118
+ }),
119
+ );
120
+
121
+ await new Promise((resolve) => setImmediate(resolve));
122
+ expect(onEmit).toHaveBeenCalledWith(snapshot);
123
+ });
124
+ });