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.
- package/README.md +21 -4
- package/dist/attribute-redacting-processor.cjs +8 -8
- package/dist/attribute-redacting-processor.d.cts +10 -1
- package/dist/attribute-redacting-processor.d.ts +10 -1
- package/dist/attribute-redacting-processor.js +1 -1
- package/dist/attributes.cjs +21 -21
- package/dist/attributes.js +2 -2
- package/dist/auto.cjs +3 -3
- package/dist/auto.js +2 -2
- package/dist/{chunk-7HNQYHK4.js → chunk-52PUSFC2.js} +3 -3
- package/dist/{chunk-7HNQYHK4.js.map → chunk-52PUSFC2.js.map} +1 -1
- package/dist/{chunk-L7JDUDJD.cjs → chunk-7SMNC4LS.cjs} +7 -7
- package/dist/{chunk-L7JDUDJD.cjs.map → chunk-7SMNC4LS.cjs.map} +1 -1
- package/dist/{chunk-563EL6O6.cjs → chunk-BPO2PQ3T.cjs} +12 -8
- package/dist/chunk-BPO2PQ3T.cjs.map +1 -0
- package/dist/{chunk-ZSABTI3C.cjs → chunk-DAZ7EGR4.cjs} +17 -17
- package/dist/{chunk-ZSABTI3C.cjs.map → chunk-DAZ7EGR4.cjs.map} +1 -1
- package/dist/{chunk-ER43K7ES.js → chunk-DDXIUZEG.js} +3 -3
- package/dist/{chunk-ER43K7ES.js.map → chunk-DDXIUZEG.js.map} +1 -1
- package/dist/{chunk-JKIMEPI2.cjs → chunk-DQ2SUROF.cjs} +4 -4
- package/dist/{chunk-JKIMEPI2.cjs.map → chunk-DQ2SUROF.cjs.map} +1 -1
- package/dist/{chunk-KHGA4OST.cjs → chunk-HKZHUGGN.cjs} +5 -5
- package/dist/{chunk-KHGA4OST.cjs.map → chunk-HKZHUGGN.cjs.map} +1 -1
- package/dist/{chunk-TDNKIHKT.js → chunk-JVWJDHDB.js} +13 -4
- package/dist/chunk-JVWJDHDB.js.map +1 -0
- package/dist/{chunk-3QMFLJHJ.js → chunk-K7HSRLP5.js} +3 -3
- package/dist/{chunk-3QMFLJHJ.js.map → chunk-K7HSRLP5.js.map} +1 -1
- package/dist/{chunk-CJ4PD2TZ.cjs → chunk-KKGM42RQ.cjs} +13 -13
- package/dist/{chunk-CJ4PD2TZ.cjs.map → chunk-KKGM42RQ.cjs.map} +1 -1
- package/dist/{chunk-DWOBIBLY.cjs → chunk-MOO75VE4.cjs} +5 -5
- package/dist/{chunk-DWOBIBLY.cjs.map → chunk-MOO75VE4.cjs.map} +1 -1
- package/dist/{chunk-CMNGGTQL.cjs → chunk-NXLRY2CE.cjs} +13 -4
- package/dist/chunk-NXLRY2CE.cjs.map +1 -0
- package/dist/{chunk-4DAG3RFS.js → chunk-OM4OSBOP.js} +4 -4
- package/dist/{chunk-4DAG3RFS.js.map → chunk-OM4OSBOP.js.map} +1 -1
- package/dist/{chunk-DAAJLUTO.js → chunk-PMRWMRXY.js} +4 -4
- package/dist/{chunk-DAAJLUTO.js.map → chunk-PMRWMRXY.js.map} +1 -1
- package/dist/{chunk-MOK3E54E.cjs → chunk-QPH5ZKP5.cjs} +32 -32
- package/dist/{chunk-MOK3E54E.cjs.map → chunk-QPH5ZKP5.cjs.map} +1 -1
- package/dist/{chunk-IUDXKLS4.js → chunk-TFRZOUTV.js} +3 -3
- package/dist/{chunk-IUDXKLS4.js.map → chunk-TFRZOUTV.js.map} +1 -1
- package/dist/{chunk-QG3U5ONP.js → chunk-Z7VAOK5X.js} +3 -3
- package/dist/{chunk-QG3U5ONP.js.map → chunk-Z7VAOK5X.js.map} +1 -1
- package/dist/{chunk-W35FVJBC.js → chunk-ZDPIWKWD.js} +9 -5
- package/dist/chunk-ZDPIWKWD.js.map +1 -0
- package/dist/correlation-id.cjs +11 -11
- package/dist/correlation-id.js +3 -3
- package/dist/decorators.cjs +5 -5
- package/dist/decorators.js +4 -4
- package/dist/event.cjs +7 -7
- package/dist/event.js +4 -4
- package/dist/functional.cjs +11 -11
- package/dist/functional.js +4 -4
- package/dist/http.cjs +4 -4
- package/dist/http.js +3 -3
- package/dist/index.cjs +226 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -3
- package/dist/index.d.ts +67 -3
- package/dist/index.js +138 -15
- package/dist/index.js.map +1 -1
- package/dist/instrumentation.cjs +9 -9
- package/dist/instrumentation.js +2 -2
- package/dist/messaging.cjs +8 -8
- package/dist/messaging.js +5 -5
- package/dist/semantic-helpers.cjs +9 -9
- package/dist/semantic-helpers.js +5 -5
- package/dist/webhook.cjs +6 -6
- package/dist/webhook.js +4 -4
- package/dist/workflow-distributed.cjs +6 -6
- package/dist/workflow-distributed.js +4 -4
- package/dist/workflow.cjs +9 -9
- package/dist/workflow.js +5 -5
- package/package.json +43 -45
- package/skills/analyze-traces/SKILL.md +178 -0
- package/skills/autotel-core/SKILL.md +0 -7
- package/skills/autotel-events/SKILL.md +0 -6
- package/skills/autotel-frameworks/SKILL.md +0 -9
- package/skills/autotel-instrumentation/SKILL.md +0 -7
- package/skills/autotel-request-logging/SKILL.md +0 -8
- package/skills/autotel-structured-errors/SKILL.md +0 -7
- package/skills/build-audit-trails/SKILL.md +302 -0
- package/skills/debug-missing-spans/SKILL.md +248 -0
- package/skills/migrate-to-autotel/SKILL.md +268 -0
- package/skills/review-otel-patterns/SKILL.md +488 -0
- package/skills/review-otel-patterns/references/code-review.md +75 -0
- package/skills/review-otel-patterns/references/processor-pipeline.md +205 -0
- package/skills/review-otel-patterns/references/structured-errors.md +102 -0
- package/skills/review-otel-patterns/references/wide-spans.md +85 -0
- package/skills/tune-sampling/SKILL.md +210 -0
- package/src/attribute-redacting-processor.test.ts +6 -4
- package/src/attribute-redacting-processor.ts +11 -2
- package/src/drain-toolkit.test.ts +113 -0
- package/src/drain-toolkit.ts +129 -0
- package/src/enricher-toolkit.test.ts +67 -0
- package/src/enricher-toolkit.ts +79 -0
- package/src/index.ts +19 -0
- package/src/redact-values.test.ts +24 -10
- package/src/redact-values.ts +9 -2
- package/src/request-logger.test.ts +91 -0
- package/src/request-logger.ts +36 -2
- package/src/structured-error.test.ts +4 -1
- package/bin/intent.js +0 -6
- package/dist/chunk-563EL6O6.cjs.map +0 -1
- package/dist/chunk-CMNGGTQL.cjs.map +0 -1
- package/dist/chunk-TDNKIHKT.js.map +0 -1
- package/dist/chunk-W35FVJBC.js.map +0 -1
- 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('
|
|
13
|
+
it('smart-masks emails', () => {
|
|
14
14
|
expect(redact('Contact user@example.com for info')).toBe(
|
|
15
|
-
'Contact
|
|
15
|
+
'Contact u***@***.com for info',
|
|
16
16
|
);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
it('
|
|
20
|
-
expect(redact('Call
|
|
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('
|
|
24
|
-
expect(redact('
|
|
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('
|
|
53
|
+
it('smart-masks JWTs', () => {
|
|
40
54
|
const jwt =
|
|
41
55
|
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_-def';
|
|
42
|
-
expect(redact(`Token: ${jwt}`)).toBe('Token:
|
|
56
|
+
expect(redact(`Token: ${jwt}`)).toBe('Token: eyJ***.***');
|
|
43
57
|
});
|
|
44
58
|
|
|
45
|
-
it('
|
|
59
|
+
it('smart-masks bearer tokens', () => {
|
|
46
60
|
expect(redact('Authorization: Bearer abc123.xyz')).toBe(
|
|
47
|
-
'Authorization:
|
|
61
|
+
'Authorization: Bearer ***',
|
|
48
62
|
);
|
|
49
63
|
});
|
|
50
64
|
});
|
package/src/redact-values.ts
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
package/src/request-logger.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
});
|