autotel 2.25.5 → 2.26.1

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 (123) hide show
  1. package/README.md +47 -1
  2. package/dist/attribute-redacting-processor.cjs +14 -6
  3. package/dist/attribute-redacting-processor.d.cts +63 -1
  4. package/dist/attribute-redacting-processor.d.ts +63 -1
  5. package/dist/attribute-redacting-processor.js +1 -1
  6. package/dist/attributes.cjs +21 -21
  7. package/dist/attributes.js +2 -2
  8. package/dist/auto.cjs +8 -8
  9. package/dist/auto.js +6 -6
  10. package/dist/{chunk-RUD7KS4R.js → chunk-3SDILILG.js} +3 -3
  11. package/dist/{chunk-RUD7KS4R.js.map → chunk-3SDILILG.js.map} +1 -1
  12. package/dist/{chunk-B33XPEKY.js → chunk-55ER2KD5.js} +4 -4
  13. package/dist/chunk-55ER2KD5.js.map +1 -0
  14. package/dist/{chunk-ITYASFHQ.cjs → chunk-563EL6O6.cjs} +153 -15
  15. package/dist/chunk-563EL6O6.cjs.map +1 -0
  16. package/dist/{chunk-EEJGUBWV.cjs → chunk-6YGUN7IY.cjs} +5 -5
  17. package/dist/{chunk-EEJGUBWV.cjs.map → chunk-6YGUN7IY.cjs.map} +1 -1
  18. package/dist/{chunk-XDKK53OL.js → chunk-A4E5AQFK.js} +3 -3
  19. package/dist/{chunk-XDKK53OL.js.map → chunk-A4E5AQFK.js.map} +1 -1
  20. package/dist/{chunk-XB2GITM5.js → chunk-BJ2XPN77.js} +3 -3
  21. package/dist/{chunk-XB2GITM5.js.map → chunk-BJ2XPN77.js.map} +1 -1
  22. package/dist/{chunk-KZEC4CHV.cjs → chunk-CEAQK2QY.cjs} +5 -5
  23. package/dist/{chunk-KZEC4CHV.cjs.map → chunk-CEAQK2QY.cjs.map} +1 -1
  24. package/dist/chunk-CMNGGTQL.cjs +349 -0
  25. package/dist/chunk-CMNGGTQL.cjs.map +1 -0
  26. package/dist/{chunk-VYA6QDNA.js → chunk-DPSA4QLA.js} +4 -2
  27. package/dist/chunk-DPSA4QLA.js.map +1 -0
  28. package/dist/{chunk-M4US3P4K.js → chunk-ER43K7ES.js} +3 -3
  29. package/dist/{chunk-M4US3P4K.js.map → chunk-ER43K7ES.js.map} +1 -1
  30. package/dist/{chunk-AZ24DJAG.cjs → chunk-FU6R566Y.cjs} +4 -4
  31. package/dist/chunk-FU6R566Y.cjs.map +1 -0
  32. package/dist/{chunk-OPTGXEVN.js → chunk-HPUGKUMZ.js} +4 -4
  33. package/dist/{chunk-OPTGXEVN.js.map → chunk-HPUGKUMZ.js.map} +1 -1
  34. package/dist/{chunk-XRBP4RYL.cjs → chunk-JKIMEPI2.cjs} +4 -4
  35. package/dist/{chunk-XRBP4RYL.cjs.map → chunk-JKIMEPI2.cjs.map} +1 -1
  36. package/dist/{chunk-I6JPSD4R.cjs → chunk-OBWXM4NN.cjs} +9 -9
  37. package/dist/{chunk-I6JPSD4R.cjs.map → chunk-OBWXM4NN.cjs.map} +1 -1
  38. package/dist/{chunk-OFPZULMQ.cjs → chunk-OC6X2VIN.cjs} +8 -8
  39. package/dist/{chunk-OFPZULMQ.cjs.map → chunk-OC6X2VIN.cjs.map} +1 -1
  40. package/dist/{chunk-QDREXAD7.js → chunk-QC5MNKVF.js} +4 -4
  41. package/dist/{chunk-QDREXAD7.js.map → chunk-QC5MNKVF.js.map} +1 -1
  42. package/dist/chunk-TDNKIHKT.js +341 -0
  43. package/dist/chunk-TDNKIHKT.js.map +1 -0
  44. package/dist/{chunk-CMUM4JQI.js → chunk-U54FTVFH.js} +3 -3
  45. package/dist/{chunk-CMUM4JQI.js.map → chunk-U54FTVFH.js.map} +1 -1
  46. package/dist/{chunk-QQLP4M6W.cjs → chunk-UTZR7P7E.cjs} +36 -36
  47. package/dist/{chunk-QQLP4M6W.cjs.map → chunk-UTZR7P7E.cjs.map} +1 -1
  48. package/dist/{chunk-7FIGORWI.cjs → chunk-VH77IPJN.cjs} +4 -2
  49. package/dist/chunk-VH77IPJN.cjs.map +1 -0
  50. package/dist/{chunk-MNBAXRVG.js → chunk-W35FVJBC.js} +144 -9
  51. package/dist/chunk-W35FVJBC.js.map +1 -0
  52. package/dist/{chunk-A5ZUL2RZ.cjs → chunk-WZOKY3PW.cjs} +13 -13
  53. package/dist/{chunk-A5ZUL2RZ.cjs.map → chunk-WZOKY3PW.cjs.map} +1 -1
  54. package/dist/{chunk-MN6PZ4AN.cjs → chunk-YEVCD6DR.cjs} +7 -7
  55. package/dist/{chunk-MN6PZ4AN.cjs.map → chunk-YEVCD6DR.cjs.map} +1 -1
  56. package/dist/{chunk-WYP6OOCT.js → chunk-YN7USLHW.js} +3 -3
  57. package/dist/{chunk-WYP6OOCT.js.map → chunk-YN7USLHW.js.map} +1 -1
  58. package/dist/decorators.cjs +7 -7
  59. package/dist/decorators.js +7 -7
  60. package/dist/event.cjs +10 -10
  61. package/dist/event.js +7 -7
  62. package/dist/functional.cjs +14 -14
  63. package/dist/functional.js +7 -7
  64. package/dist/index.cjs +214 -96
  65. package/dist/index.cjs.map +1 -1
  66. package/dist/index.d.cts +8 -2
  67. package/dist/index.d.ts +8 -2
  68. package/dist/index.js +142 -33
  69. package/dist/index.js.map +1 -1
  70. package/dist/{init-CIzpC5kZ.d.cts → init-CMuTaFAV.d.cts} +49 -1
  71. package/dist/{init-C_PiC_Su.d.ts → init-D6JfWEjL.d.ts} +49 -1
  72. package/dist/instrumentation.cjs +14 -14
  73. package/dist/instrumentation.js +6 -6
  74. package/dist/logger.cjs +8 -8
  75. package/dist/logger.js +1 -1
  76. package/dist/messaging.cjs +11 -11
  77. package/dist/messaging.js +8 -8
  78. package/dist/metric.cjs +1 -1
  79. package/dist/metric.js +1 -1
  80. package/dist/sampling.cjs +15 -15
  81. package/dist/sampling.js +2 -2
  82. package/dist/semantic-helpers.cjs +12 -12
  83. package/dist/semantic-helpers.js +8 -8
  84. package/dist/tail-sampling-processor.cjs +4 -4
  85. package/dist/tail-sampling-processor.js +3 -3
  86. package/dist/testing.cjs +1 -1
  87. package/dist/testing.js +1 -1
  88. package/dist/webhook.cjs +9 -8
  89. package/dist/webhook.cjs.map +1 -1
  90. package/dist/webhook.js +8 -7
  91. package/dist/webhook.js.map +1 -1
  92. package/dist/workflow-distributed.cjs +9 -9
  93. package/dist/workflow-distributed.js +7 -7
  94. package/dist/workflow.cjs +12 -12
  95. package/dist/workflow.js +8 -8
  96. package/dist/yaml-config.cjs +6 -6
  97. package/dist/yaml-config.d.cts +1 -1
  98. package/dist/yaml-config.d.ts +1 -1
  99. package/dist/yaml-config.js +3 -3
  100. package/package.json +41 -41
  101. package/src/attribute-redacting-processor.test.ts +81 -16
  102. package/src/attribute-redacting-processor.ts +278 -24
  103. package/src/autotel-logger.ts +2 -2
  104. package/src/devtools.ts +60 -0
  105. package/src/hook.mjs +2 -2
  106. package/src/index.ts +2 -1
  107. package/src/init.customization.test.ts +81 -0
  108. package/src/init.ts +188 -3
  109. package/src/request-logger.test.ts +266 -1
  110. package/src/request-logger.ts +115 -16
  111. package/src/shutdown.test.ts +35 -1
  112. package/src/shutdown.ts +3 -1
  113. package/src/structured-error.ts +54 -1
  114. package/dist/chunk-7FIGORWI.cjs.map +0 -1
  115. package/dist/chunk-AZ24DJAG.cjs.map +0 -1
  116. package/dist/chunk-B33XPEKY.js.map +0 -1
  117. package/dist/chunk-ELW34S4C.cjs +0 -173
  118. package/dist/chunk-ELW34S4C.cjs.map +0 -1
  119. package/dist/chunk-ITYASFHQ.cjs.map +0 -1
  120. package/dist/chunk-MNBAXRVG.js.map +0 -1
  121. package/dist/chunk-SNINLBEE.js +0 -167
  122. package/dist/chunk-SNINLBEE.js.map +0 -1
  123. package/dist/chunk-VYA6QDNA.js.map +0 -1
package/src/init.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  } from './span-name-normalizer';
61
61
  import {
62
62
  AttributeRedactingProcessor,
63
+ normalizeAttributeRedactorConfig,
63
64
  type AttributeRedactorConfig,
64
65
  type AttributeRedactorPreset,
65
66
  } from './attribute-redacting-processor';
@@ -73,6 +74,7 @@ import {
73
74
  type CanonicalLogLineOptions,
74
75
  } from './processors/canonical-log-line-processor';
75
76
  import type { EventsConfig } from './events-config';
77
+ import { resolveDevtoolsConfig, type AutotelDevtoolsConfig } from './devtools';
76
78
 
77
79
  /**
78
80
  * Silent logger (no-op) - used as default when user doesn't provide one.
@@ -297,6 +299,21 @@ export interface AutotelConfig {
297
299
  /** Service name (required) */
298
300
  service: string;
299
301
 
302
+ /**
303
+ * Local developer UX for autotel-devtools.
304
+ *
305
+ * - `true`: send traces, metrics, and logs to `http://127.0.0.1:4318`
306
+ * - `{ embedded: true }`: attempt to start `autotel-devtools` automatically
307
+ *
308
+ * When enabled:
309
+ * - `endpoint` defaults to the local devtools URL
310
+ * - `logs` default to `true` unless explicitly set
311
+ *
312
+ * This keeps production config unchanged while making local debugging
313
+ * effectively zero-config.
314
+ */
315
+ devtools?: boolean | AutotelDevtoolsConfig;
316
+
300
317
  /** Event subscribers - bring your own (PostHog, Mixpanel, etc.) */
301
318
  subscribers?: EventSubscriber[];
302
319
 
@@ -1126,10 +1143,28 @@ export interface AutotelConfig {
1126
1143
  */
1127
1144
  pretty?: boolean;
1128
1145
  };
1146
+
1147
+ /**
1148
+ * Suppress console output while keeping OTel exporters running.
1149
+ * Useful for platforms like GCP Cloud Run / AWS Lambda where stdout
1150
+ * is managed externally by the platform's log collector.
1151
+ *
1152
+ * @default false
1153
+ */
1154
+ silent?: boolean;
1155
+
1156
+ /**
1157
+ * Minimum log level for internal autotel diagnostic messages.
1158
+ * Messages below this level are dropped before processing.
1159
+ *
1160
+ * @default 'info'
1161
+ */
1162
+ minLevel?: 'debug' | 'info' | 'warn' | 'error';
1129
1163
  }
1130
1164
 
1131
1165
  // Internal state
1132
1166
  let initialized = false;
1167
+ let locked = false;
1133
1168
  let config: AutotelConfig | null = null;
1134
1169
  let sdk: NodeSDK | null = null;
1135
1170
  let warnedOnce = false;
@@ -1138,6 +1173,86 @@ let validationConfig: Partial<ValidationConfig> | null = null;
1138
1173
  let eventsConfig: EventsConfig | null = null;
1139
1174
  let _stringRedactor: StringRedactor | null = null;
1140
1175
  let _optionalRequire: typeof safeRequire = safeRequire;
1176
+ let _devtoolsClose: (() => Promise<void> | void) | null = null;
1177
+
1178
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;
1179
+ type LogLevelKey = keyof typeof LOG_LEVELS;
1180
+
1181
+ /**
1182
+ * Lock the logger to prevent further `init()` calls.
1183
+ * Use this when framework plugins set up instrumentation and you want
1184
+ * to prevent accidental re-initialization from user code.
1185
+ */
1186
+ export function lockLogger(): void {
1187
+ locked = true;
1188
+ }
1189
+
1190
+ /**
1191
+ * Check if the logger has been locked.
1192
+ */
1193
+ export function isLoggerLocked(): boolean {
1194
+ return locked;
1195
+ }
1196
+
1197
+ function createSilentLogger(): Logger {
1198
+ return {
1199
+ info: () => {},
1200
+ warn: () => {},
1201
+ error: () => {},
1202
+ debug: () => {},
1203
+ };
1204
+ }
1205
+
1206
+ function wrapLogger(
1207
+ base: Logger,
1208
+ silent: boolean,
1209
+ minLevel: LogLevelKey,
1210
+ ): Logger {
1211
+ if (silent) return createSilentLogger();
1212
+ const threshold = LOG_LEVELS[minLevel];
1213
+ const wrap = (fn: Logger['info'], level: LogLevelKey): Logger['info'] => {
1214
+ if (LOG_LEVELS[level] < threshold) {
1215
+ return (() => {}) as Logger['info'];
1216
+ }
1217
+ return ((...args: Parameters<Logger['info']>) =>
1218
+ fn(...args)) as Logger['info'];
1219
+ };
1220
+ return {
1221
+ debug: wrap(base.debug, 'debug'),
1222
+ info: wrap(base.info, 'info'),
1223
+ warn: wrap(base.warn, 'warn'),
1224
+ error: wrap(base.error, 'error'),
1225
+ };
1226
+ }
1227
+
1228
+ function detectEnvironmentAttributes(): Record<string, string> {
1229
+ const attrs: Record<string, string> = {};
1230
+
1231
+ const commitSha =
1232
+ process.env.COMMIT_SHA ||
1233
+ process.env.GITHUB_SHA ||
1234
+ process.env.VERCEL_GIT_COMMIT_SHA ||
1235
+ process.env.CF_PAGES_COMMIT_SHA ||
1236
+ process.env.AWS_CODEPIPELINE_EXECUTION_ID;
1237
+ if (commitSha) attrs['service.commit.sha'] = commitSha;
1238
+
1239
+ const region =
1240
+ process.env.VERCEL_REGION ||
1241
+ process.env.AWS_REGION ||
1242
+ process.env.AWS_DEFAULT_REGION ||
1243
+ process.env.FLY_REGION ||
1244
+ process.env.CF_REGION ||
1245
+ process.env.GOOGLE_CLOUD_REGION;
1246
+ if (region) attrs['service.region'] = region;
1247
+
1248
+ const version =
1249
+ process.env.APP_VERSION ||
1250
+ process.env.HEROKU_RELEASE_VERSION ||
1251
+ process.env.VERCEL_GIT_COMMIT_REF;
1252
+ if (version) attrs['service.deploy.version'] = version;
1253
+
1254
+ return attrs;
1255
+ }
1141
1256
 
1142
1257
  /**
1143
1258
  * Resolve metrics flag with env var override support
@@ -1278,6 +1393,10 @@ function normalizeOtlpHeaders(
1278
1393
  */
1279
1394
 
1280
1395
  export function init(cfg: AutotelConfig): void {
1396
+ if (locked) {
1397
+ return;
1398
+ }
1399
+
1281
1400
  // Resolve configs in priority order: explicit > yaml > env > defaults
1282
1401
  const envConfig = resolveConfigFromEnv();
1283
1402
  const yamlConfig = loadYamlConfig() ?? {};
@@ -1291,14 +1410,32 @@ export function init(cfg: AutotelConfig): void {
1291
1410
  resourceAttributes: {
1292
1411
  ...envConfig.resourceAttributes,
1293
1412
  ...yamlConfig.resourceAttributes,
1413
+ ...detectEnvironmentAttributes(),
1294
1414
  ...cfg.resourceAttributes,
1295
1415
  },
1296
1416
  // Handle headers merge (can be string or object)
1297
1417
  headers: cfg.headers ?? yamlConfig.headers ?? envConfig.headers,
1298
1418
  } as AutotelConfig;
1299
1419
 
1300
- // Set logger (use provided or default to silent - no spam)
1301
- logger = mergedConfig.logger || silentLogger;
1420
+ if (mergedConfig.attributeRedactor !== undefined) {
1421
+ const normalizedRedactor = normalizeAttributeRedactorConfig(
1422
+ mergedConfig.attributeRedactor,
1423
+ );
1424
+ if (!normalizedRedactor) {
1425
+ throw new Error('Invalid attributeRedactor config');
1426
+ }
1427
+ mergedConfig.attributeRedactor = normalizedRedactor;
1428
+ }
1429
+
1430
+ const devtoolsConfig = resolveDevtoolsConfig(mergedConfig.devtools);
1431
+ if (devtoolsConfig.enabled && mergedConfig.logs === undefined) {
1432
+ mergedConfig.logs = true;
1433
+ }
1434
+
1435
+ const silent = mergedConfig.silent ?? false;
1436
+ const minLevel = mergedConfig.minLevel ?? 'info';
1437
+ const baseLogger = mergedConfig.logger || silentLogger;
1438
+ logger = wrapLogger(baseLogger, silent, minLevel);
1302
1439
 
1303
1440
  // Warn if re-initializing (same behavior in all environments)
1304
1441
  if (initialized) {
@@ -1314,7 +1451,7 @@ export function init(cfg: AutotelConfig): void {
1314
1451
 
1315
1452
  // Initialize OpenTelemetry
1316
1453
  // Only use endpoint if explicitly configured (no default fallback)
1317
- const endpoint = mergedConfig.endpoint;
1454
+ let endpoint = mergedConfig.endpoint ?? devtoolsConfig.endpoint;
1318
1455
  const otlpHeaders = normalizeOtlpHeaders(mergedConfig.headers);
1319
1456
  const version = mergedConfig.version || detectVersion();
1320
1457
  const environment =
@@ -1322,6 +1459,35 @@ export function init(cfg: AutotelConfig): void {
1322
1459
  const metricsEnabled = resolveMetricsFlag(mergedConfig.metrics);
1323
1460
  const logsEnabled = resolveLogsFlag(mergedConfig.logs);
1324
1461
 
1462
+ if (devtoolsConfig.enabled && devtoolsConfig.embedded) {
1463
+ const devtoolsModule = _optionalRequire<{
1464
+ createDevtools?: (options?: {
1465
+ port?: number;
1466
+ host?: string;
1467
+ verbose?: boolean;
1468
+ }) => { port: number; close: () => Promise<void> | void };
1469
+ }>('autotel-devtools');
1470
+
1471
+ if (devtoolsModule?.createDevtools) {
1472
+ const devtoolsInstance = devtoolsModule.createDevtools({
1473
+ port: devtoolsConfig.port,
1474
+ host: devtoolsConfig.host,
1475
+ verbose: devtoolsConfig.verbose,
1476
+ });
1477
+ _devtoolsClose = devtoolsInstance.close;
1478
+ endpoint = `http://${devtoolsConfig.host}:${devtoolsInstance.port}`;
1479
+ logger.info(
1480
+ {},
1481
+ `[autotel] autotel-devtools embedded server started at ${endpoint}`,
1482
+ );
1483
+ } else {
1484
+ logger.warn(
1485
+ {},
1486
+ '[autotel] devtools.embedded requested but autotel-devtools is not installed. Falling back to endpoint-only mode.',
1487
+ );
1488
+ }
1489
+ }
1490
+
1325
1491
  // Detect hostname for proper Datadog correlation and Service Catalog discovery
1326
1492
  const hostname = detectHostname();
1327
1493
 
@@ -2013,6 +2179,25 @@ export function _resetOptionalRequireForTesting(): void {
2013
2179
  _optionalRequire = safeRequire;
2014
2180
  }
2015
2181
 
2182
+ /**
2183
+ * @internal Close embedded devtools if running.
2184
+ */
2185
+ export async function _closeEmbeddedDevtools(): Promise<void> {
2186
+ if (_devtoolsClose) {
2187
+ await _devtoolsClose();
2188
+ _devtoolsClose = null;
2189
+ }
2190
+ }
2191
+
2192
+ /**
2193
+ * @internal Get embedded devtools close handle.
2194
+ */
2195
+ export function _getEmbeddedDevtoolsCloseForTesting():
2196
+ | (() => Promise<void> | void)
2197
+ | null {
2198
+ return _devtoolsClose;
2199
+ }
2200
+
2016
2201
  /**
2017
2202
  * Get SDK instance (for shutdown)
2018
2203
  */
@@ -1,4 +1,5 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { trace as otelTrace } from '@opentelemetry/api';
2
3
  import { getRequestLogger, runWithRequestContext } from './request-logger';
3
4
  import type { TraceContext } from './trace-context';
4
5
 
@@ -23,6 +24,238 @@ function createMockContext(): TraceContext {
23
24
  } as unknown as TraceContext;
24
25
  }
25
26
 
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ describe('post-emit warnings', () => {
32
+ let ctx: TraceContext;
33
+ let log: ReturnType<typeof getRequestLogger>;
34
+ let warnSpy: ReturnType<typeof vi.spyOn>;
35
+
36
+ beforeEach(() => {
37
+ ctx = createMockContext();
38
+ log = getRequestLogger(ctx);
39
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
40
+ });
41
+
42
+ afterEach(() => {
43
+ warnSpy.mockRestore();
44
+ });
45
+
46
+ it('warns when set() is called after emitNow()', () => {
47
+ log.emitNow();
48
+ log.set({ dropped: 'value' });
49
+
50
+ expect(warnSpy).toHaveBeenCalledWith(
51
+ expect.stringContaining(
52
+ '[autotel] log.set() called after the wide event was emitted',
53
+ ),
54
+ );
55
+ expect(warnSpy).toHaveBeenCalledWith(
56
+ expect.stringContaining('Keys dropped: dropped'),
57
+ );
58
+ });
59
+
60
+ it('warns when info() is called after emitNow()', () => {
61
+ log.emitNow();
62
+ log.info('after emit', { extra: 'data' });
63
+
64
+ expect(warnSpy).toHaveBeenCalledWith(
65
+ expect.stringContaining(
66
+ '[autotel] log.info() called after the wide event was emitted',
67
+ ),
68
+ );
69
+ expect(warnSpy).toHaveBeenCalledWith(
70
+ expect.stringContaining('Keys dropped: message, extra'),
71
+ );
72
+ });
73
+
74
+ it('warns when warn() is called after emitNow()', () => {
75
+ log.emitNow();
76
+ log.warn('after emit', { extra: 'data' });
77
+
78
+ expect(warnSpy).toHaveBeenCalledWith(
79
+ expect.stringContaining(
80
+ '[autotel] log.warn() called after the wide event was emitted',
81
+ ),
82
+ );
83
+ });
84
+
85
+ it('warns when error() is called after emitNow()', () => {
86
+ log.emitNow();
87
+ log.error(new Error('after emit'), { step: 'cleanup' });
88
+
89
+ expect(warnSpy).toHaveBeenCalledWith(
90
+ expect.stringContaining(
91
+ '[autotel] log.error() called after the wide event was emitted',
92
+ ),
93
+ );
94
+ expect(warnSpy).toHaveBeenCalledWith(
95
+ expect.stringContaining('Keys dropped: step, error'),
96
+ );
97
+ });
98
+
99
+ it('warns on duplicate emitNow()', () => {
100
+ const first = log.emitNow();
101
+ const second = log.emitNow();
102
+
103
+ expect(warnSpy).toHaveBeenCalledWith(
104
+ expect.stringContaining(
105
+ '[autotel] log.emitNow() called after the wide event was emitted',
106
+ ),
107
+ );
108
+ expect(warnSpy).toHaveBeenCalledWith(
109
+ expect.stringContaining('Ignoring duplicate emit'),
110
+ );
111
+ expect(second).toBe(first);
112
+ });
113
+
114
+ it('does not warn when calls are made before emitNow()', () => {
115
+ log.set({ user: { id: 'u1' } });
116
+ log.info('step', { stage: 'payment' });
117
+ log.warn('slow');
118
+ log.error(new Error('fail'));
119
+ log.emitNow();
120
+
121
+ expect(warnSpy).not.toHaveBeenCalled();
122
+ });
123
+ });
124
+
125
+ describe('array concatenation in set()', () => {
126
+ let ctx: TraceContext;
127
+ let log: ReturnType<typeof getRequestLogger>;
128
+
129
+ beforeEach(() => {
130
+ ctx = createMockContext();
131
+ log = getRequestLogger(ctx);
132
+ });
133
+
134
+ it('concatenates arrays when both values are arrays', () => {
135
+ log.set({ tags: ['initial', 'setup'] });
136
+ log.set({ tags: ['payment', 'complete'] });
137
+
138
+ expect(log.getContext()).toEqual({
139
+ tags: ['initial', 'setup', 'payment', 'complete'],
140
+ });
141
+ });
142
+
143
+ it('replaces array with non-array value', () => {
144
+ log.set({ tags: ['a', 'b'] });
145
+ log.set({ tags: 'single' });
146
+
147
+ expect(log.getContext()).toEqual({
148
+ tags: 'single',
149
+ });
150
+ });
151
+
152
+ it('replaces non-array with array', () => {
153
+ log.set({ tags: 'single' });
154
+ log.set({ tags: ['a', 'b'] });
155
+
156
+ expect(log.getContext()).toEqual({
157
+ tags: ['a', 'b'],
158
+ });
159
+ });
160
+
161
+ it('deep merges objects while concatenating nested arrays', () => {
162
+ log.set({
163
+ user: { id: 'u1', roles: ['admin'] },
164
+ });
165
+ log.set({
166
+ user: { name: 'Alice', roles: ['editor'] },
167
+ });
168
+
169
+ expect(log.getContext()).toEqual({
170
+ user: {
171
+ id: 'u1',
172
+ name: 'Alice',
173
+ roles: ['admin', 'editor'],
174
+ },
175
+ });
176
+ });
177
+ });
178
+
179
+ describe('log.fork()', () => {
180
+ let ctx: TraceContext;
181
+ let log: ReturnType<typeof getRequestLogger>;
182
+
183
+ beforeEach(() => {
184
+ ctx = createMockContext();
185
+ log = getRequestLogger(ctx);
186
+ });
187
+
188
+ it('throws when parent has no correlationId', () => {
189
+ const noCorrCtx = createMockContext();
190
+ (noCorrCtx as Record<string, unknown>).correlationId = '';
191
+ const noCorrLog = getRequestLogger(noCorrCtx);
192
+
193
+ expect(() => noCorrLog.fork('test', () => {})).toThrow(
194
+ '[autotel] log.fork() requires the parent logger to have a correlationId',
195
+ );
196
+ });
197
+
198
+ it('fork method exists on the logger interface', () => {
199
+ expect(typeof log.fork).toBe('function');
200
+ });
201
+
202
+ it('uses an isolated child span context for forked work', async () => {
203
+ const childSpan = {
204
+ spanContext: () => ({
205
+ traceId: 'a'.repeat(32),
206
+ spanId: 'b'.repeat(16),
207
+ }),
208
+ setAttribute: vi.fn(),
209
+ setAttributes: vi.fn(),
210
+ setStatus: vi.fn(),
211
+ recordException: vi.fn(),
212
+ addEvent: vi.fn(),
213
+ addLink: vi.fn(),
214
+ addLinks: vi.fn(),
215
+ updateName: vi.fn(),
216
+ isRecording: vi.fn(() => true),
217
+ end: vi.fn(),
218
+ };
219
+
220
+ const tracer = {
221
+ startActiveSpan: (
222
+ _name: string,
223
+ cb: (span: typeof childSpan) => void,
224
+ ) => {
225
+ cb(childSpan);
226
+ },
227
+ };
228
+
229
+ const tracerSpy = vi
230
+ .spyOn(otelTrace, 'getTracer')
231
+ .mockReturnValue(
232
+ tracer as unknown as ReturnType<typeof otelTrace.getTracer>,
233
+ );
234
+
235
+ log.fork('background-work', async () => {
236
+ const childLog = getRequestLogger();
237
+ childLog.info('running child task', { phase: 'upload' });
238
+ });
239
+
240
+ await new Promise((resolve) => setImmediate(resolve));
241
+ await new Promise((resolve) => setImmediate(resolve));
242
+
243
+ expect(tracerSpy).toHaveBeenCalledWith('autotel.request-logger');
244
+ expect(childSpan.setAttributes).toHaveBeenCalledWith(
245
+ expect.objectContaining({
246
+ operation: 'background-work',
247
+ _parentCorrelationId: 'corr-id',
248
+ }),
249
+ );
250
+ expect(childSpan.addEvent).toHaveBeenCalledWith(
251
+ 'log.emit.manual',
252
+ expect.any(Object),
253
+ );
254
+ expect(ctx.setAttributes).not.toHaveBeenCalled();
255
+ expect(childSpan.end).toHaveBeenCalledTimes(1);
256
+ });
257
+ });
258
+
26
259
  describe('getRequestLogger', () => {
27
260
  it('sets flattened fields onto active span context', () => {
28
261
  const ctx = createMockContext();
@@ -134,4 +367,36 @@ describe('getRequestLogger', () => {
134
367
  });
135
368
  });
136
369
  });
370
+
371
+ it('resolves context from the active OTel span when no args and no ALS context', () => {
372
+ const activeSpan = {
373
+ spanContext: () => ({
374
+ traceId: 'c'.repeat(32),
375
+ spanId: 'd'.repeat(16),
376
+ }),
377
+ setAttribute: vi.fn(),
378
+ setAttributes: vi.fn(),
379
+ setStatus: vi.fn(),
380
+ recordException: vi.fn(),
381
+ addEvent: vi.fn(),
382
+ addLink: vi.fn(),
383
+ addLinks: vi.fn(),
384
+ updateName: vi.fn(),
385
+ isRecording: vi.fn(() => true),
386
+ };
387
+
388
+ const spanSpy = vi
389
+ .spyOn(otelTrace, 'getActiveSpan')
390
+ .mockReturnValue(
391
+ activeSpan as unknown as ReturnType<typeof otelTrace.getActiveSpan>,
392
+ );
393
+
394
+ const log = getRequestLogger();
395
+ log.set({ order: { id: 'o-1' } });
396
+
397
+ expect(spanSpy).toHaveBeenCalled();
398
+ expect(activeSpan.setAttributes).toHaveBeenCalledWith({
399
+ 'order.id': 'o-1',
400
+ });
401
+ });
137
402
  });