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.
- package/README.md +112 -6
- package/dist/auto.cjs +3 -3
- package/dist/auto.js +2 -2
- package/dist/{chunk-EWH2542B.js → chunk-3AMR5XLZ.js} +3 -3
- package/dist/{chunk-EWH2542B.js.map → chunk-3AMR5XLZ.js.map} +1 -1
- package/dist/chunk-3QXBFGKP.js +344 -0
- package/dist/chunk-3QXBFGKP.js.map +1 -0
- package/dist/{chunk-VQFF2WMP.cjs → chunk-3ZFDJJWZ.cjs} +37 -29
- package/dist/chunk-3ZFDJJWZ.cjs.map +1 -0
- package/dist/{chunk-CQC6RVLR.cjs → chunk-4RZ4JUBY.cjs} +5 -5
- package/dist/{chunk-CQC6RVLR.cjs.map → chunk-4RZ4JUBY.cjs.map} +1 -1
- package/dist/{chunk-PAVYKPCQ.js → chunk-5XUEHX7J.js} +3 -3
- package/dist/{chunk-PAVYKPCQ.js.map → chunk-5XUEHX7J.js.map} +1 -1
- package/dist/chunk-6S5RUKU3.cjs +347 -0
- package/dist/chunk-6S5RUKU3.cjs.map +1 -0
- package/dist/{chunk-BS757SL2.js → chunk-724XLWR3.js} +9 -4
- package/dist/chunk-724XLWR3.js.map +1 -0
- package/dist/chunk-7EQ4G4SI.cjs +146 -0
- package/dist/chunk-7EQ4G4SI.cjs.map +1 -0
- package/dist/{chunk-CQP5SQT4.cjs → chunk-AXFWWJF3.cjs} +7 -7
- package/dist/{chunk-CQP5SQT4.cjs.map → chunk-AXFWWJF3.cjs.map} +1 -1
- package/dist/{chunk-7NH625MS.cjs → chunk-BSZP4URK.cjs} +5 -5
- package/dist/{chunk-7NH625MS.cjs.map → chunk-BSZP4URK.cjs.map} +1 -1
- package/dist/{chunk-GZFH6P5U.js → chunk-GY4CRZSV.js} +14 -6
- package/dist/chunk-GY4CRZSV.js.map +1 -0
- package/dist/{chunk-QKUGUDXJ.cjs → chunk-HSEIUH7F.cjs} +10 -5
- package/dist/chunk-HSEIUH7F.cjs.map +1 -0
- package/dist/{chunk-DTW3WB7Z.js → chunk-IPKXURBW.js} +3 -3
- package/dist/{chunk-DTW3WB7Z.js.map → chunk-IPKXURBW.js.map} +1 -1
- package/dist/chunk-J7VGRIAJ.js +64 -0
- package/dist/chunk-J7VGRIAJ.js.map +1 -0
- package/dist/chunk-KFOHQK7X.js +144 -0
- package/dist/chunk-KFOHQK7X.js.map +1 -0
- package/dist/{chunk-4UYR46UP.cjs → chunk-MSUHW2I4.cjs} +13 -13
- package/dist/{chunk-4UYR46UP.cjs.map → chunk-MSUHW2I4.cjs.map} +1 -1
- package/dist/chunk-T4B5LB6E.cjs +66 -0
- package/dist/chunk-T4B5LB6E.cjs.map +1 -0
- package/dist/{chunk-QHT4MUED.js → chunk-WCIIFRGL.js} +3 -3
- package/dist/{chunk-QHT4MUED.js.map → chunk-WCIIFRGL.js.map} +1 -1
- package/dist/decorators.cjs +3 -3
- package/dist/decorators.js +3 -3
- package/dist/drain-pipeline.cjs +13 -0
- package/dist/drain-pipeline.cjs.map +1 -0
- package/dist/drain-pipeline.d.cts +37 -0
- package/dist/drain-pipeline.d.ts +37 -0
- package/dist/drain-pipeline.js +4 -0
- package/dist/drain-pipeline.js.map +1 -0
- package/dist/event.cjs +6 -6
- package/dist/event.js +3 -3
- package/dist/functional.cjs +10 -10
- package/dist/functional.js +3 -3
- package/dist/index.cjs +256 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -3
- package/dist/index.d.ts +72 -3
- package/dist/index.js +210 -11
- package/dist/index.js.map +1 -1
- package/dist/{init-BMiXSJNM.d.cts → init-BC5aN8bh.d.cts} +18 -0
- package/dist/{init-ByRbNTRo.d.ts → init-_FG4IbhF.d.ts} +18 -0
- package/dist/instrumentation.cjs +9 -9
- package/dist/instrumentation.js +2 -2
- package/dist/messaging.cjs +7 -7
- package/dist/messaging.js +4 -4
- package/dist/parse-error.cjs +13 -0
- package/dist/parse-error.cjs.map +1 -0
- package/dist/parse-error.d.cts +13 -0
- package/dist/parse-error.d.ts +13 -0
- package/dist/parse-error.js +4 -0
- package/dist/parse-error.js.map +1 -0
- package/dist/processors.cjs +2 -2
- package/dist/processors.d.cts +40 -4
- package/dist/processors.d.ts +40 -4
- package/dist/processors.js +1 -1
- package/dist/semantic-helpers.cjs +8 -8
- package/dist/semantic-helpers.js +4 -4
- package/dist/webhook.cjs +4 -4
- package/dist/webhook.js +3 -3
- package/dist/workflow-distributed.cjs +5 -5
- package/dist/workflow-distributed.js +3 -3
- package/dist/workflow.cjs +8 -8
- package/dist/workflow.js +4 -4
- package/dist/yaml-config.d.cts +2 -1
- package/dist/yaml-config.d.ts +2 -1
- package/package.json +11 -1
- package/src/drain-pipeline.test.ts +68 -0
- package/src/drain-pipeline.ts +199 -0
- package/src/flatten-attributes.test.ts +76 -0
- package/src/flatten-attributes.ts +80 -0
- package/src/functional.test.ts +63 -0
- package/src/functional.ts +11 -3
- package/src/index.ts +33 -0
- package/src/init.ts +22 -0
- package/src/parse-error.test.ts +73 -0
- package/src/parse-error.ts +112 -0
- package/src/pretty-log-formatter.test.ts +123 -0
- package/src/pretty-log-formatter.ts +210 -0
- package/src/processors/canonical-log-line-processor.test.ts +81 -25
- package/src/processors/canonical-log-line-processor.ts +130 -42
- package/src/request-logger.test.ts +124 -0
- package/src/request-logger.ts +140 -0
- package/src/structured-error.test.ts +76 -0
- package/src/structured-error.ts +86 -0
- package/dist/chunk-2RQDNGV3.js +0 -126
- package/dist/chunk-2RQDNGV3.js.map +0 -1
- package/dist/chunk-BS757SL2.js.map +0 -1
- package/dist/chunk-GZFH6P5U.js.map +0 -1
- package/dist/chunk-ONK2Y22L.cjs +0 -128
- package/dist/chunk-ONK2Y22L.cjs.map +0 -1
- package/dist/chunk-QKUGUDXJ.cjs.map +0 -1
- 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,
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 },
|
|
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
|
|
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,
|
|
253
|
+
this.emitViaLogger(level, message, canonicalLogLine);
|
|
174
254
|
} else if (this.getOTelLogger) {
|
|
175
255
|
const otelLogger = this.getOTelLogger();
|
|
176
|
-
this.emitViaOTel(level,
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|
|
247
321
|
}
|
|
248
322
|
|
|
249
323
|
private emitViaOTel(
|
|
250
324
|
level: 'debug' | 'info' | 'warn' | 'error',
|
|
251
|
-
|
|
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
|
-
|
|
279
|
-
if (
|
|
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
|
-
|
|
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
|
|
390
|
+
// No-op
|
|
303
391
|
}
|
|
304
392
|
|
|
305
393
|
async shutdown(): Promise<void> {
|
|
306
|
-
// No-op
|
|
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
|
+
});
|