autotel 3.0.0 → 3.0.4
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-IUDXKLS4.js → chunk-34X3TKHA.js} +3 -3
- package/dist/{chunk-IUDXKLS4.js.map → chunk-34X3TKHA.js.map} +1 -1
- package/dist/{chunk-3QMFLJHJ.js → chunk-4LF6FV2V.js} +3 -3
- package/dist/{chunk-3QMFLJHJ.js.map → chunk-4LF6FV2V.js.map} +1 -1
- package/dist/{chunk-L7JDUDJD.cjs → chunk-AAYCDHH6.cjs} +7 -7
- package/dist/{chunk-L7JDUDJD.cjs.map → chunk-AAYCDHH6.cjs.map} +1 -1
- package/dist/{chunk-DWOBIBLY.cjs → chunk-AY2SY3MO.cjs} +5 -5
- package/dist/{chunk-DWOBIBLY.cjs.map → chunk-AY2SY3MO.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-DAAJLUTO.js → chunk-F3TNRW2P.js} +6 -5
- package/dist/chunk-F3TNRW2P.js.map +1 -0
- package/dist/{chunk-7HNQYHK4.js → chunk-HBLWOI6P.js} +3 -3
- package/dist/{chunk-7HNQYHK4.js.map → chunk-HBLWOI6P.js.map} +1 -1
- package/dist/{chunk-TDNKIHKT.js → chunk-JVWJDHDB.js} +13 -4
- package/dist/chunk-JVWJDHDB.js.map +1 -0
- 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-KHGA4OST.cjs → chunk-LMFPZHI4.cjs} +5 -5
- package/dist/{chunk-KHGA4OST.cjs.map → chunk-LMFPZHI4.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-MOK3E54E.cjs → chunk-WSGAHSZQ.cjs} +34 -33
- package/dist/chunk-WSGAHSZQ.cjs.map +1 -0
- 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.d.cts +20 -17
- package/dist/functional.d.ts +20 -17
- 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/functional.test.ts +18 -0
- package/src/functional.ts +32 -20
- 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-DAAJLUTO.js.map +0 -1
- package/dist/chunk-MOK3E54E.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/functional.test.ts
CHANGED
|
@@ -62,6 +62,24 @@ describe('Functional API', () => {
|
|
|
62
62
|
expect(promise).toBeInstanceOf(Promise);
|
|
63
63
|
await expect(promise).resolves.toBe(84);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it('accepts a string name as first argument (sync)', () => {
|
|
67
|
+
const result = span('sync-name-shorthand', () => 'ok');
|
|
68
|
+
expect(result).toBe('ok');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('accepts a string name as first argument (async)', async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
span('async-name-shorthand', async () => 'ok'),
|
|
74
|
+
).resolves.toBe('ok');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('records spans created via the string-name shorthand', async () => {
|
|
78
|
+
const collector = createTraceCollector();
|
|
79
|
+
await span('shorthand.recorded', async () => undefined);
|
|
80
|
+
const names = collector.getSpans().map((s) => s.name);
|
|
81
|
+
expect(names).toContain('shorthand.recorded');
|
|
82
|
+
});
|
|
65
83
|
});
|
|
66
84
|
|
|
67
85
|
describe('trace()', () => {
|
package/src/functional.ts
CHANGED
|
@@ -2124,44 +2124,56 @@ export interface SpanOptions {
|
|
|
2124
2124
|
* Useful for adding tracing to specific code blocks without wrapping
|
|
2125
2125
|
* the entire function. Supports both synchronous and asynchronous functions.
|
|
2126
2126
|
*
|
|
2127
|
+
* Mirrors `trace()`: pass a span name as the first argument for the common
|
|
2128
|
+
* case, or full `SpanOptions` when you need to attach attributes.
|
|
2129
|
+
*
|
|
2127
2130
|
* @example
|
|
2128
2131
|
* ```typescript
|
|
2129
|
-
* //
|
|
2130
|
-
* async
|
|
2131
|
-
* await
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2132
|
+
* // Name shorthand
|
|
2133
|
+
* await span('payment.charge', async (span) => {
|
|
2134
|
+
* await chargeCustomer(order);
|
|
2135
|
+
* })
|
|
2136
|
+
*
|
|
2137
|
+
* // Full options when attributes are needed
|
|
2138
|
+
* await span(
|
|
2139
|
+
* { name: 'payment.charge', attributes: { amount: order.total } },
|
|
2140
|
+
* async (span) => {
|
|
2135
2141
|
* await chargeCustomer(order);
|
|
2136
|
-
* }
|
|
2137
|
-
*
|
|
2142
|
+
* },
|
|
2143
|
+
* )
|
|
2138
2144
|
*
|
|
2139
|
-
* // Sync
|
|
2140
|
-
*
|
|
2141
|
-
* return
|
|
2142
|
-
*
|
|
2143
|
-
* attributes: { itemCount: items.length }
|
|
2144
|
-
* }, (span) => {
|
|
2145
|
-
* return items.reduce((sum, item) => sum + item.price, 0);
|
|
2146
|
-
* })
|
|
2147
|
-
* }
|
|
2145
|
+
* // Sync
|
|
2146
|
+
* const total = span('calculateTotal', (span) => {
|
|
2147
|
+
* return items.reduce((sum, item) => sum + item.price, 0);
|
|
2148
|
+
* })
|
|
2148
2149
|
* ```
|
|
2149
2150
|
*/
|
|
2150
|
-
//
|
|
2151
|
+
// Overloads — sync first (more specific match), then async.
|
|
2152
|
+
// Each shape is offered with a string name OR a full SpanOptions object so
|
|
2153
|
+
// span() aligns with trace()'s argument flexibility.
|
|
2154
|
+
export function span<T = unknown>(
|
|
2155
|
+
name: string,
|
|
2156
|
+
fn: (span: Span) => T,
|
|
2157
|
+
): T;
|
|
2158
|
+
export function span<T = unknown>(
|
|
2159
|
+
name: string,
|
|
2160
|
+
fn: (span: Span) => Promise<T>,
|
|
2161
|
+
): Promise<T>;
|
|
2151
2162
|
export function span<T = unknown>(
|
|
2152
2163
|
options: SpanOptions,
|
|
2153
2164
|
fn: (span: Span) => T,
|
|
2154
2165
|
): T;
|
|
2155
|
-
// Overload for async functions
|
|
2156
2166
|
export function span<T = unknown>(
|
|
2157
2167
|
options: SpanOptions,
|
|
2158
2168
|
fn: (span: Span) => Promise<T>,
|
|
2159
2169
|
): Promise<T>;
|
|
2160
2170
|
// Implementation
|
|
2161
2171
|
export function span<T = unknown>(
|
|
2162
|
-
|
|
2172
|
+
nameOrOptions: string | SpanOptions,
|
|
2163
2173
|
fn: (span: Span) => T | Promise<T>,
|
|
2164
2174
|
): T | Promise<T> {
|
|
2175
|
+
const options: SpanOptions =
|
|
2176
|
+
typeof nameOrOptions === 'string' ? { name: nameOrOptions } : nameOrOptions;
|
|
2165
2177
|
const config = getConfig();
|
|
2166
2178
|
const tracer = config.tracer;
|
|
2167
2179
|
const { name, attributes } = options;
|
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
|
};
|