autotel 3.0.0 → 3.0.3

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 (108) hide show
  1. package/README.md +21 -4
  2. package/dist/attribute-redacting-processor.cjs +8 -8
  3. package/dist/attribute-redacting-processor.d.cts +10 -1
  4. package/dist/attribute-redacting-processor.d.ts +10 -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 +3 -3
  9. package/dist/auto.js +2 -2
  10. package/dist/{chunk-7HNQYHK4.js → chunk-52PUSFC2.js} +3 -3
  11. package/dist/{chunk-7HNQYHK4.js.map → chunk-52PUSFC2.js.map} +1 -1
  12. package/dist/{chunk-L7JDUDJD.cjs → chunk-7SMNC4LS.cjs} +7 -7
  13. package/dist/{chunk-L7JDUDJD.cjs.map → chunk-7SMNC4LS.cjs.map} +1 -1
  14. package/dist/{chunk-563EL6O6.cjs → chunk-BPO2PQ3T.cjs} +12 -8
  15. package/dist/chunk-BPO2PQ3T.cjs.map +1 -0
  16. package/dist/{chunk-ZSABTI3C.cjs → chunk-DAZ7EGR4.cjs} +17 -17
  17. package/dist/{chunk-ZSABTI3C.cjs.map → chunk-DAZ7EGR4.cjs.map} +1 -1
  18. package/dist/{chunk-ER43K7ES.js → chunk-DDXIUZEG.js} +3 -3
  19. package/dist/{chunk-ER43K7ES.js.map → chunk-DDXIUZEG.js.map} +1 -1
  20. package/dist/{chunk-JKIMEPI2.cjs → chunk-DQ2SUROF.cjs} +4 -4
  21. package/dist/{chunk-JKIMEPI2.cjs.map → chunk-DQ2SUROF.cjs.map} +1 -1
  22. package/dist/{chunk-KHGA4OST.cjs → chunk-HKZHUGGN.cjs} +5 -5
  23. package/dist/{chunk-KHGA4OST.cjs.map → chunk-HKZHUGGN.cjs.map} +1 -1
  24. package/dist/{chunk-TDNKIHKT.js → chunk-JVWJDHDB.js} +13 -4
  25. package/dist/chunk-JVWJDHDB.js.map +1 -0
  26. package/dist/{chunk-3QMFLJHJ.js → chunk-K7HSRLP5.js} +3 -3
  27. package/dist/{chunk-3QMFLJHJ.js.map → chunk-K7HSRLP5.js.map} +1 -1
  28. package/dist/{chunk-CJ4PD2TZ.cjs → chunk-KKGM42RQ.cjs} +13 -13
  29. package/dist/{chunk-CJ4PD2TZ.cjs.map → chunk-KKGM42RQ.cjs.map} +1 -1
  30. package/dist/{chunk-DWOBIBLY.cjs → chunk-MOO75VE4.cjs} +5 -5
  31. package/dist/{chunk-DWOBIBLY.cjs.map → chunk-MOO75VE4.cjs.map} +1 -1
  32. package/dist/{chunk-CMNGGTQL.cjs → chunk-NXLRY2CE.cjs} +13 -4
  33. package/dist/chunk-NXLRY2CE.cjs.map +1 -0
  34. package/dist/{chunk-4DAG3RFS.js → chunk-OM4OSBOP.js} +4 -4
  35. package/dist/{chunk-4DAG3RFS.js.map → chunk-OM4OSBOP.js.map} +1 -1
  36. package/dist/{chunk-DAAJLUTO.js → chunk-PMRWMRXY.js} +4 -4
  37. package/dist/{chunk-DAAJLUTO.js.map → chunk-PMRWMRXY.js.map} +1 -1
  38. package/dist/{chunk-MOK3E54E.cjs → chunk-QPH5ZKP5.cjs} +32 -32
  39. package/dist/{chunk-MOK3E54E.cjs.map → chunk-QPH5ZKP5.cjs.map} +1 -1
  40. package/dist/{chunk-IUDXKLS4.js → chunk-TFRZOUTV.js} +3 -3
  41. package/dist/{chunk-IUDXKLS4.js.map → chunk-TFRZOUTV.js.map} +1 -1
  42. package/dist/{chunk-QG3U5ONP.js → chunk-Z7VAOK5X.js} +3 -3
  43. package/dist/{chunk-QG3U5ONP.js.map → chunk-Z7VAOK5X.js.map} +1 -1
  44. package/dist/{chunk-W35FVJBC.js → chunk-ZDPIWKWD.js} +9 -5
  45. package/dist/chunk-ZDPIWKWD.js.map +1 -0
  46. package/dist/correlation-id.cjs +11 -11
  47. package/dist/correlation-id.js +3 -3
  48. package/dist/decorators.cjs +5 -5
  49. package/dist/decorators.js +4 -4
  50. package/dist/event.cjs +7 -7
  51. package/dist/event.js +4 -4
  52. package/dist/functional.cjs +11 -11
  53. package/dist/functional.js +4 -4
  54. package/dist/http.cjs +4 -4
  55. package/dist/http.js +3 -3
  56. package/dist/index.cjs +226 -92
  57. package/dist/index.cjs.map +1 -1
  58. package/dist/index.d.cts +67 -3
  59. package/dist/index.d.ts +67 -3
  60. package/dist/index.js +138 -15
  61. package/dist/index.js.map +1 -1
  62. package/dist/instrumentation.cjs +9 -9
  63. package/dist/instrumentation.js +2 -2
  64. package/dist/messaging.cjs +8 -8
  65. package/dist/messaging.js +5 -5
  66. package/dist/semantic-helpers.cjs +9 -9
  67. package/dist/semantic-helpers.js +5 -5
  68. package/dist/webhook.cjs +6 -6
  69. package/dist/webhook.js +4 -4
  70. package/dist/workflow-distributed.cjs +6 -6
  71. package/dist/workflow-distributed.js +4 -4
  72. package/dist/workflow.cjs +9 -9
  73. package/dist/workflow.js +5 -5
  74. package/package.json +43 -45
  75. package/skills/analyze-traces/SKILL.md +178 -0
  76. package/skills/autotel-core/SKILL.md +0 -7
  77. package/skills/autotel-events/SKILL.md +0 -6
  78. package/skills/autotel-frameworks/SKILL.md +0 -9
  79. package/skills/autotel-instrumentation/SKILL.md +0 -7
  80. package/skills/autotel-request-logging/SKILL.md +0 -8
  81. package/skills/autotel-structured-errors/SKILL.md +0 -7
  82. package/skills/build-audit-trails/SKILL.md +302 -0
  83. package/skills/debug-missing-spans/SKILL.md +248 -0
  84. package/skills/migrate-to-autotel/SKILL.md +268 -0
  85. package/skills/review-otel-patterns/SKILL.md +488 -0
  86. package/skills/review-otel-patterns/references/code-review.md +75 -0
  87. package/skills/review-otel-patterns/references/processor-pipeline.md +205 -0
  88. package/skills/review-otel-patterns/references/structured-errors.md +102 -0
  89. package/skills/review-otel-patterns/references/wide-spans.md +85 -0
  90. package/skills/tune-sampling/SKILL.md +210 -0
  91. package/src/attribute-redacting-processor.test.ts +6 -4
  92. package/src/attribute-redacting-processor.ts +11 -2
  93. package/src/drain-toolkit.test.ts +113 -0
  94. package/src/drain-toolkit.ts +129 -0
  95. package/src/enricher-toolkit.test.ts +67 -0
  96. package/src/enricher-toolkit.ts +79 -0
  97. package/src/index.ts +19 -0
  98. package/src/redact-values.test.ts +24 -10
  99. package/src/redact-values.ts +9 -2
  100. package/src/request-logger.test.ts +91 -0
  101. package/src/request-logger.ts +36 -2
  102. package/src/structured-error.test.ts +4 -1
  103. package/bin/intent.js +0 -6
  104. package/dist/chunk-563EL6O6.cjs.map +0 -1
  105. package/dist/chunk-CMNGGTQL.cjs.map +0 -1
  106. package/dist/chunk-TDNKIHKT.js.map +0 -1
  107. package/dist/chunk-W35FVJBC.js.map +0 -1
  108. package/src/package-manifest.test.ts +0 -24
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { defineDrain, defineHttpDrain } from './drain-toolkit';
3
+
4
+ describe('defineDrain', () => {
5
+ it('calls send with transformed payloads', async () => {
6
+ const send = vi.fn(async () => {});
7
+ const drain = defineDrain<
8
+ { event: { id: string } },
9
+ { key: string },
10
+ string
11
+ >({
12
+ name: 'test',
13
+ resolve: async () => ({ key: 'k' }),
14
+ transform: (contexts) => contexts.map((c) => c.event.id),
15
+ send,
16
+ });
17
+
18
+ await drain({ event: { id: 'a' } });
19
+ expect(send).toHaveBeenCalledWith(['a'], { key: 'k' });
20
+ });
21
+
22
+ it('skips send when resolve returns null', async () => {
23
+ const send = vi.fn(async () => {});
24
+ const drain = defineDrain({
25
+ name: 'test',
26
+ resolve: async () => null,
27
+ send,
28
+ });
29
+
30
+ await drain({ event: { id: 'a' } });
31
+ expect(send).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it('isolates send errors', async () => {
35
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
36
+ const drain = defineDrain({
37
+ name: 'test',
38
+ resolve: async () => ({ ok: true }),
39
+ send: async () => {
40
+ throw new Error('fail');
41
+ },
42
+ });
43
+
44
+ await expect(drain({ event: { id: 'a' } })).resolves.toBeUndefined();
45
+ expect(spy).toHaveBeenCalled();
46
+ spy.mockRestore();
47
+ });
48
+ });
49
+
50
+ describe('defineHttpDrain', () => {
51
+ const originalFetch = globalThis.fetch;
52
+
53
+ beforeEach(() => {
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ afterEach(() => {
58
+ globalThis.fetch = originalFetch;
59
+ });
60
+
61
+ it('encodes payload and posts via fetch', async () => {
62
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
63
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
64
+
65
+ const drain = defineHttpDrain<
66
+ { event: { id: string } },
67
+ { token: string },
68
+ { id: string }
69
+ >({
70
+ name: 'http-drain',
71
+ resolve: async () => ({ token: 't' }),
72
+ transform: (contexts) => contexts.map((c) => c.event),
73
+ encode: (payloads, config) => ({
74
+ url: 'https://example.com/ingest',
75
+ headers: {
76
+ 'content-type': 'application/json',
77
+ authorization: `Bearer ${config.token}`,
78
+ },
79
+ body: JSON.stringify(payloads),
80
+ }),
81
+ retries: 1,
82
+ timeoutMs: 2000,
83
+ });
84
+
85
+ await drain({ event: { id: 'evt_1' } });
86
+
87
+ expect(fetchMock).toHaveBeenCalledTimes(1);
88
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/ingest');
89
+ });
90
+
91
+ it('retries failed requests', async () => {
92
+ const fetchMock = vi
93
+ .fn()
94
+ .mockRejectedValueOnce(new Error('network'))
95
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
96
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
97
+
98
+ const drain = defineHttpDrain({
99
+ name: 'http-drain',
100
+ resolve: async () => ({ ok: true }),
101
+ encode: () => ({
102
+ url: 'https://example.com/ingest',
103
+ headers: { 'content-type': 'application/json' },
104
+ body: '[]',
105
+ }),
106
+ retries: 2,
107
+ timeoutMs: 2000,
108
+ });
109
+
110
+ await drain({ event: { id: 'evt_1' } });
111
+ expect(fetchMock).toHaveBeenCalledTimes(2);
112
+ });
113
+ });
@@ -0,0 +1,129 @@
1
+ export interface DrainOptions<TContext, TConfig, TPayload = TContext> {
2
+ /** Stable identifier used in error logs. */
3
+ name: string;
4
+ /** Return null to skip draining (e.g. missing API key in dev). */
5
+ resolve: () => TConfig | null | Promise<TConfig | null>;
6
+ /** Transform contexts into payloads. Defaults to identity. */
7
+ transform?: (contexts: TContext[]) => TPayload[];
8
+ /** Transport implementation. */
9
+ send: (payloads: TPayload[], config: TConfig) => Promise<void>;
10
+ }
11
+
12
+ export interface HttpDrainRequest {
13
+ url: string;
14
+ headers: Record<string, string>;
15
+ body: string;
16
+ }
17
+
18
+ export interface HttpDrainOptions<
19
+ TContext,
20
+ TConfig,
21
+ TPayload = TContext,
22
+ > extends Omit<DrainOptions<TContext, TConfig, TPayload>, 'send'> {
23
+ encode: (payloads: TPayload[], config: TConfig) => HttpDrainRequest | null;
24
+ timeoutMs?: number;
25
+ retries?: number;
26
+ resolveTimeoutMs?: (config: TConfig) => number | undefined;
27
+ resolveRetries?: (config: TConfig) => number | undefined;
28
+ }
29
+
30
+ const DEFAULT_TIMEOUT_MS = 5000;
31
+ const DEFAULT_RETRIES = 2;
32
+
33
+ function delay(ms: number): Promise<void> {
34
+ return new Promise((resolve) => {
35
+ const t = setTimeout(resolve, ms);
36
+ t.unref?.();
37
+ });
38
+ }
39
+
40
+ async function postWithRetry(options: {
41
+ name: string;
42
+ request: HttpDrainRequest;
43
+ timeoutMs: number;
44
+ retries: number;
45
+ }): Promise<void> {
46
+ const { name, request, timeoutMs, retries } = options;
47
+ const attempts = Math.max(1, retries);
48
+ let lastError: unknown;
49
+
50
+ for (let attempt = 1; attempt <= attempts; attempt++) {
51
+ const controller = new AbortController();
52
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
53
+ timeout.unref?.();
54
+ try {
55
+ const response = await fetch(request.url, {
56
+ method: 'POST',
57
+ headers: request.headers,
58
+ body: request.body,
59
+ signal: controller.signal,
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(
63
+ `[autotel/${name}] HTTP ${response.status} draining ${request.url}`,
64
+ );
65
+ }
66
+ return;
67
+ } catch (error) {
68
+ lastError = error;
69
+ if (attempt < attempts) {
70
+ await delay(100 * attempt);
71
+ }
72
+ } finally {
73
+ clearTimeout(timeout);
74
+ }
75
+ }
76
+
77
+ throw lastError;
78
+ }
79
+
80
+ export function defineDrain<TContext, TConfig, TPayload = TContext>(
81
+ options: DrainOptions<TContext, TConfig, TPayload>,
82
+ ): (ctx: TContext | TContext[]) => Promise<void> {
83
+ return async (ctx: TContext | TContext[]) => {
84
+ const contexts = Array.isArray(ctx) ? ctx : [ctx];
85
+ if (contexts.length === 0) return;
86
+
87
+ const config = await options.resolve();
88
+ if (!config) return;
89
+
90
+ const payloads = options.transform
91
+ ? options.transform(contexts)
92
+ : (contexts as unknown as TPayload[]);
93
+
94
+ if (payloads.length === 0) return;
95
+
96
+ try {
97
+ await options.send(payloads, config);
98
+ } catch (error) {
99
+ console.error(`[autotel/${options.name}] drain failed:`, error);
100
+ }
101
+ };
102
+ }
103
+
104
+ export function defineHttpDrain<TContext, TConfig, TPayload = TContext>(
105
+ options: HttpDrainOptions<TContext, TConfig, TPayload>,
106
+ ): (ctx: TContext | TContext[]) => Promise<void> {
107
+ return defineDrain<TContext, TConfig, TPayload>({
108
+ name: options.name,
109
+ resolve: options.resolve,
110
+ transform: options.transform,
111
+ send: async (payloads, config) => {
112
+ const request = options.encode(payloads, config);
113
+ if (!request) return;
114
+ const timeoutMs =
115
+ options.resolveTimeoutMs?.(config) ??
116
+ options.timeoutMs ??
117
+ DEFAULT_TIMEOUT_MS;
118
+ const retries =
119
+ options.resolveRetries?.(config) ?? options.retries ?? DEFAULT_RETRIES;
120
+
121
+ await postWithRetry({
122
+ name: options.name,
123
+ request,
124
+ timeoutMs,
125
+ retries,
126
+ });
127
+ },
128
+ });
129
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { defineEnricher } from './enricher-toolkit';
3
+
4
+ describe('defineEnricher', () => {
5
+ it('merges computed values into existing field by default', () => {
6
+ const enricher = defineEnricher({
7
+ name: 'tenant-enricher',
8
+ field: 'tenant',
9
+ compute: () => ({ plan: 'pro' }),
10
+ });
11
+
12
+ const ctx = {
13
+ event: { tenant: { id: 't_1' } },
14
+ };
15
+
16
+ enricher(ctx);
17
+ expect(ctx.event).toEqual({ tenant: { id: 't_1', plan: 'pro' } });
18
+ });
19
+
20
+ it('overwrites field when overwrite=true', () => {
21
+ const enricher = defineEnricher(
22
+ {
23
+ name: 'tenant-enricher',
24
+ field: 'tenant',
25
+ compute: () => ({ plan: 'pro' }),
26
+ },
27
+ { overwrite: true },
28
+ );
29
+
30
+ const ctx = {
31
+ event: { tenant: { id: 't_1' } },
32
+ };
33
+
34
+ enricher(ctx);
35
+ expect(ctx.event).toEqual({ tenant: { plan: 'pro' } });
36
+ });
37
+
38
+ it('skips enrichment when compute returns undefined', () => {
39
+ const enricher = defineEnricher({
40
+ name: 'noop-enricher',
41
+ field: 'tenant',
42
+ compute: () => undefined,
43
+ });
44
+
45
+ const ctx = { event: { a: 1 } as Record<string, unknown> };
46
+ enricher(ctx);
47
+ expect(ctx.event).toEqual({ a: 1 });
48
+ });
49
+
50
+ it('isolates compute errors and logs them', () => {
51
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
52
+ const enricher = defineEnricher({
53
+ name: 'broken-enricher',
54
+ field: 'tenant',
55
+ compute: () => {
56
+ throw new Error('boom');
57
+ },
58
+ });
59
+
60
+ const ctx = { event: {} as Record<string, unknown> };
61
+ enricher(ctx);
62
+
63
+ expect(ctx.event).toEqual({});
64
+ expect(spy).toHaveBeenCalled();
65
+ spy.mockRestore();
66
+ });
67
+ });
@@ -0,0 +1,79 @@
1
+ export interface EnrichContext<TEvent extends Record<string, unknown>> {
2
+ event: TEvent;
3
+ request?: {
4
+ method?: string;
5
+ path?: string;
6
+ requestId?: string;
7
+ };
8
+ response?: {
9
+ status?: number;
10
+ };
11
+ headers?: Record<string, string>;
12
+ }
13
+
14
+ export interface EnricherDefinition<
15
+ TEvent extends Record<string, unknown>,
16
+ TValue extends object,
17
+ > {
18
+ /** Stable identifier used in error logs. */
19
+ name: string;
20
+ /** Top-level field to merge computed values into. */
21
+ field: keyof TEvent & string;
22
+ /** Return undefined to skip enrichment. */
23
+ compute: (ctx: EnrichContext<TEvent>) => TValue | undefined;
24
+ }
25
+
26
+ export interface EnricherOptions {
27
+ /** Replace existing field value instead of merge. Default false. */
28
+ overwrite?: boolean;
29
+ }
30
+
31
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function mergeInto(
36
+ target: Record<string, unknown>,
37
+ source: Record<string, unknown>,
38
+ ): void {
39
+ for (const key in source) {
40
+ const sourceVal = source[key];
41
+ if (sourceVal === undefined) continue;
42
+ const targetVal = target[key];
43
+ if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
44
+ mergeInto(targetVal, sourceVal);
45
+ } else {
46
+ target[key] = sourceVal;
47
+ }
48
+ }
49
+ }
50
+
51
+ export function defineEnricher<
52
+ TEvent extends Record<string, unknown>,
53
+ TValue extends object,
54
+ >(
55
+ def: EnricherDefinition<TEvent, TValue>,
56
+ options: EnricherOptions = {},
57
+ ): (ctx: EnrichContext<TEvent>) => void {
58
+ return (ctx: EnrichContext<TEvent>) => {
59
+ let computed: TValue | undefined;
60
+ try {
61
+ computed = def.compute(ctx);
62
+ } catch (error) {
63
+ console.error(`[autotel/${def.name}] enrich failed:`, error);
64
+ return;
65
+ }
66
+
67
+ if (!computed) return;
68
+
69
+ if (options.overwrite || !isPlainObject(ctx.event[def.field])) {
70
+ (ctx.event as Record<string, unknown>)[def.field] = computed;
71
+ return;
72
+ }
73
+
74
+ mergeInto(
75
+ ctx.event[def.field] as unknown as Record<string, unknown>,
76
+ computed as unknown as Record<string, unknown>,
77
+ );
78
+ };
79
+ }
package/src/index.ts CHANGED
@@ -60,12 +60,16 @@ export {
60
60
  AttributeRedactingProcessor,
61
61
  REDACTOR_PATTERNS,
62
62
  REDACTOR_PRESETS,
63
+ builtinPatterns,
63
64
  createAttributeRedactor,
64
65
  createRedactedSpan,
66
+ normalizeAttributeRedactorConfig,
65
67
  type AttributeRedactorFn,
66
68
  type AttributeRedactorPreset,
67
69
  type AttributeRedactorConfig,
68
70
  type AttributeRedactingProcessorOptions,
71
+ type BuiltinPatternName,
72
+ type MaskFn,
69
73
  type ValuePatternConfig,
70
74
  } from './attribute-redacting-processor';
71
75
 
@@ -123,6 +127,8 @@ export {
123
127
  type RequestLogger,
124
128
  type RequestLogSnapshot,
125
129
  type RequestLoggerOptions,
130
+ type ForkLifecycle,
131
+ type ForkOptions,
126
132
  } from './request-logger';
127
133
 
128
134
  // Structured errors
@@ -147,6 +153,19 @@ export {
147
153
  type DrainPipelineOptions,
148
154
  type PipelineDrainFn,
149
155
  } from './drain-pipeline';
156
+ export {
157
+ defineDrain,
158
+ defineHttpDrain,
159
+ type DrainOptions,
160
+ type HttpDrainOptions,
161
+ type HttpDrainRequest,
162
+ } from './drain-toolkit';
163
+ export {
164
+ defineEnricher,
165
+ type EnricherDefinition,
166
+ type EnrichContext,
167
+ type EnricherOptions,
168
+ } from './enricher-toolkit';
150
169
 
151
170
  // Pretty log formatting
152
171
  export { formatDuration } from './pretty-log-formatter';
@@ -10,18 +10,32 @@ describe('createStringRedactor', () => {
10
10
  redact = createStringRedactor('default');
11
11
  });
12
12
 
13
- it('redacts emails', () => {
13
+ it('smart-masks emails', () => {
14
14
  expect(redact('Contact user@example.com for info')).toBe(
15
- 'Contact [REDACTED] for info',
15
+ 'Contact u***@***.com for info',
16
16
  );
17
17
  });
18
18
 
19
- it('redacts phone numbers', () => {
20
- expect(redact('Call 555-123-4567 now')).toBe('Call [REDACTED] now');
19
+ it('smart-masks international phone numbers (country code + last 2 digits)', () => {
20
+ expect(redact('Call +33 1 23 45 67 89 now')).toBe('Call +33******89 now');
21
21
  });
22
22
 
23
- it('redacts credit card numbers', () => {
24
- expect(redact('Card: 4111-1111-1111-1111')).toBe('Card: [REDACTED]');
23
+ it('smart-masks phone numbers with parens (last 2 digits)', () => {
24
+ expect(redact('Call (415) 555-1234 now')).toBe('Call ********34 now');
25
+ });
26
+
27
+ it('smart-masks common US phone formats', () => {
28
+ expect(redact('Call 555-123-4567 now')).toBe('Call ********67 now');
29
+ expect(redact('Call 5551234567 now')).toBe('Call ********67 now');
30
+ });
31
+
32
+ it('does not mistake bare digit runs for phone numbers', () => {
33
+ // UUIDs, order ids etc. should pass through untouched.
34
+ expect(redact('Order: 12345678 ok')).toBe('Order: 12345678 ok');
35
+ });
36
+
37
+ it('smart-masks credit card numbers (last four digits preserved)', () => {
38
+ expect(redact('Card: 4111-1111-1111-1111')).toBe('Card: ****1111');
25
39
  });
26
40
 
27
41
  it('returns input unchanged when no patterns match', () => {
@@ -36,15 +50,15 @@ describe('createStringRedactor', () => {
36
50
  redact = createStringRedactor('strict');
37
51
  });
38
52
 
39
- it('redacts JWTs', () => {
53
+ it('smart-masks JWTs', () => {
40
54
  const jwt =
41
55
  'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_-def';
42
- expect(redact(`Token: ${jwt}`)).toBe('Token: [REDACTED]');
56
+ expect(redact(`Token: ${jwt}`)).toBe('Token: eyJ***.***');
43
57
  });
44
58
 
45
- it('redacts bearer tokens', () => {
59
+ it('smart-masks bearer tokens', () => {
46
60
  expect(redact('Authorization: Bearer abc123.xyz')).toBe(
47
- 'Authorization: [REDACTED]',
61
+ 'Authorization: Bearer ***',
48
62
  );
49
63
  });
50
64
  });
@@ -18,9 +18,16 @@ export function createStringRedactor(
18
18
 
19
19
  return (value: string): string => {
20
20
  let result = value;
21
- for (const { pattern, replacement } of valuePatterns) {
21
+ for (const { pattern, replacement, mask } of valuePatterns) {
22
22
  pattern.lastIndex = 0;
23
- result = result.replaceAll(pattern, replacement ?? defaultReplacement);
23
+ // Smart masks (e.g. email a***@***.com) take precedence over the
24
+ // static replacement so callers see the same output as the
25
+ // span-attribute redactor does.
26
+ if (mask) {
27
+ result = result.replaceAll(pattern, (match) => mask(match));
28
+ } else {
29
+ result = result.replaceAll(pattern, replacement ?? defaultReplacement);
30
+ }
24
31
  }
25
32
  return result;
26
33
  };
@@ -254,6 +254,97 @@ describe('log.fork()', () => {
254
254
  expect(ctx.setAttributes).not.toHaveBeenCalled();
255
255
  expect(childSpan.end).toHaveBeenCalledTimes(1);
256
256
  });
257
+
258
+ it('fork lifecycle hooks fire around child handler', async () => {
259
+ const ctx = createMockContext();
260
+ const log = getRequestLogger(ctx);
261
+ const childSpan = {
262
+ spanContext: () => ({
263
+ traceId: 'a'.repeat(32),
264
+ spanId: 'b'.repeat(16),
265
+ }),
266
+ setAttribute: vi.fn(),
267
+ setAttributes: vi.fn(),
268
+ setStatus: vi.fn(),
269
+ recordException: vi.fn(),
270
+ addEvent: vi.fn(),
271
+ addLink: vi.fn(),
272
+ addLinks: vi.fn(),
273
+ updateName: vi.fn(),
274
+ isRecording: vi.fn(() => true),
275
+ end: vi.fn(),
276
+ };
277
+
278
+ vi.spyOn(otelTrace, 'getTracer').mockReturnValue({
279
+ startActiveSpan: (
280
+ _name: string,
281
+ cb: (span: typeof childSpan) => Promise<void>,
282
+ ) => cb(childSpan),
283
+ } as unknown as ReturnType<typeof otelTrace.getTracer>);
284
+
285
+ const calls: string[] = [];
286
+ const onChildEnter = vi.fn(() => calls.push('enter'));
287
+ const onChildExit = vi.fn(() => calls.push('exit'));
288
+
289
+ log.fork(
290
+ 'bg',
291
+ async () => {
292
+ calls.push('handler');
293
+ },
294
+ { lifecycle: { onChildEnter, onChildExit } },
295
+ );
296
+
297
+ await new Promise((resolve) => setImmediate(resolve));
298
+ await new Promise((resolve) => setImmediate(resolve));
299
+
300
+ expect(onChildEnter).toHaveBeenCalledTimes(1);
301
+ expect(onChildExit).toHaveBeenCalledTimes(1);
302
+ expect(calls).toEqual(['enter', 'handler', 'exit']);
303
+ });
304
+
305
+ it('fork onChildExit hook errors do not crash fork cleanup', async () => {
306
+ const ctx = createMockContext();
307
+ const log = getRequestLogger(ctx);
308
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
309
+ const childSpan = {
310
+ spanContext: () => ({
311
+ traceId: 'a'.repeat(32),
312
+ spanId: 'b'.repeat(16),
313
+ }),
314
+ setAttribute: vi.fn(),
315
+ setAttributes: vi.fn(),
316
+ setStatus: vi.fn(),
317
+ recordException: vi.fn(),
318
+ addEvent: vi.fn(),
319
+ addLink: vi.fn(),
320
+ addLinks: vi.fn(),
321
+ updateName: vi.fn(),
322
+ isRecording: vi.fn(() => true),
323
+ end: vi.fn(),
324
+ };
325
+
326
+ vi.spyOn(otelTrace, 'getTracer').mockReturnValue({
327
+ startActiveSpan: (
328
+ _name: string,
329
+ cb: (span: typeof childSpan) => Promise<void>,
330
+ ) => cb(childSpan),
331
+ } as unknown as ReturnType<typeof otelTrace.getTracer>);
332
+
333
+ log.fork('bg', async () => {}, {
334
+ lifecycle: {
335
+ onChildExit: () => {
336
+ throw new Error('hook exploded');
337
+ },
338
+ },
339
+ });
340
+
341
+ await new Promise((resolve) => setImmediate(resolve));
342
+ await new Promise((resolve) => setImmediate(resolve));
343
+
344
+ expect(childSpan.end).toHaveBeenCalledTimes(1);
345
+ expect(consoleSpy).toHaveBeenCalled();
346
+ consoleSpy.mockRestore();
347
+ });
257
348
  });
258
349
 
259
350
  describe('getRequestLogger', () => {
@@ -56,7 +56,11 @@ export interface RequestLogger {
56
56
  error(error: Error | string, fields?: Record<string, unknown>): void;
57
57
  getContext(): Record<string, unknown>;
58
58
  emitNow(overrides?: Record<string, unknown>): RequestLogSnapshot;
59
- fork(label: string, fn: () => void | Promise<void>): void;
59
+ fork(
60
+ label: string,
61
+ fn: () => void | Promise<void>,
62
+ options?: ForkOptions,
63
+ ): void;
60
64
  }
61
65
 
62
66
  export interface RequestLogSnapshot {
@@ -72,6 +76,21 @@ export interface RequestLoggerOptions {
72
76
  onEmit?: (snapshot: RequestLogSnapshot) => void | Promise<void>;
73
77
  }
74
78
 
79
+ /**
80
+ * Optional lifecycle hooks for adapters that need to track child loggers
81
+ * spawned by `log.fork()` (e.g. active logger maps in framework integrations).
82
+ */
83
+ export interface ForkLifecycle {
84
+ /** Called after the child logger is created, before `fn` runs. */
85
+ onChildEnter?: (child: RequestLogger) => void;
86
+ /** Called after the child has finished (emit + drain), success or failure. */
87
+ onChildExit?: (child: RequestLogger) => void;
88
+ }
89
+
90
+ export interface ForkOptions {
91
+ lifecycle?: ForkLifecycle;
92
+ }
93
+
75
94
  function resolveContext(ctx?: TraceContext): TraceContext {
76
95
  if (ctx) return ctx;
77
96
 
@@ -207,7 +226,11 @@ export function getRequestLogger(
207
226
  return snapshot;
208
227
  },
209
228
 
210
- fork(label: string, fn: () => void | Promise<void>): void {
229
+ fork(
230
+ label: string,
231
+ fn: () => void | Promise<void>,
232
+ forkOptions?: ForkOptions,
233
+ ): void {
211
234
  const parentRequestId = activeContext.correlationId;
212
235
  if (typeof parentRequestId !== 'string' || parentRequestId.length === 0) {
213
236
  throw new Error(
@@ -217,6 +240,7 @@ export function getRequestLogger(
217
240
  }
218
241
 
219
242
  const tracer = otelTrace.getTracer('autotel.request-logger');
243
+ const lifecycle = forkOptions?.lifecycle;
220
244
  void tracer.startActiveSpan(`request.fork:${label}`, (childSpan) => {
221
245
  const childContext: TraceContext = {
222
246
  ...createTraceContext(childSpan),
@@ -230,6 +254,8 @@ export function getRequestLogger(
230
254
  _parentCorrelationId: parentRequestId,
231
255
  });
232
256
 
257
+ lifecycle?.onChildEnter?.(childLog);
258
+
233
259
  void Promise.resolve()
234
260
  .then(() => fn())
235
261
  .then(() => {
@@ -241,6 +267,14 @@ export function getRequestLogger(
241
267
  childLog.emitNow();
242
268
  })
243
269
  .finally(() => {
270
+ try {
271
+ lifecycle?.onChildExit?.(childLog);
272
+ } catch (hookError) {
273
+ console.warn(
274
+ '[autotel] fork onChildExit hook threw:',
275
+ hookError,
276
+ );
277
+ }
244
278
  childSpan.end();
245
279
  });
246
280
  });