autotel 2.26.3 → 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 +50 -23
- 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.d.cts +3 -3
- package/dist/attributes.d.ts +3 -3
- package/dist/attributes.js +2 -2
- package/dist/auto.cjs +3 -3
- package/dist/auto.js +2 -2
- package/dist/business-baggage.d.cts +1 -1
- package/dist/business-baggage.d.ts +1 -1
- package/dist/chunk-4P6ZOARG.cjs +33 -0
- package/dist/chunk-4P6ZOARG.cjs.map +1 -0
- package/dist/{chunk-U54FTVFH.js → chunk-52PUSFC2.js} +3 -3
- package/dist/{chunk-U54FTVFH.js.map → chunk-52PUSFC2.js.map} +1 -1
- package/dist/{chunk-YEVCD6DR.cjs → chunk-7SMNC4LS.cjs} +7 -7
- package/dist/{chunk-YEVCD6DR.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-WZOKY3PW.cjs → chunk-DAZ7EGR4.cjs} +19 -19
- package/dist/{chunk-WZOKY3PW.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-B3ZHLLMP.js → chunk-DSMSIVTG.js} +2 -2
- package/dist/chunk-DSMSIVTG.js.map +1 -0
- package/dist/{chunk-OBWXM4NN.cjs → chunk-HKZHUGGN.cjs} +15 -14
- package/dist/chunk-HKZHUGGN.cjs.map +1 -0
- package/dist/{chunk-TDNKIHKT.js → chunk-JVWJDHDB.js} +13 -4
- package/dist/chunk-JVWJDHDB.js.map +1 -0
- package/dist/{chunk-YN7USLHW.js → chunk-K7HSRLP5.js} +11 -10
- package/dist/chunk-K7HSRLP5.js.map +1 -0
- package/dist/chunk-KIL5CUN6.js +31 -0
- package/dist/chunk-KIL5CUN6.js.map +1 -0
- package/dist/chunk-KKGM42RQ.cjs +1207 -0
- package/dist/chunk-KKGM42RQ.cjs.map +1 -0
- package/dist/{chunk-6YGUN7IY.cjs → chunk-MOO75VE4.cjs} +18 -17
- package/dist/chunk-MOO75VE4.cjs.map +1 -0
- package/dist/{chunk-GML3FBOT.cjs → chunk-NCSMD3TK.cjs} +2 -2
- package/dist/chunk-NCSMD3TK.cjs.map +1 -0
- package/dist/{chunk-CMNGGTQL.cjs → chunk-NXLRY2CE.cjs} +13 -4
- package/dist/chunk-NXLRY2CE.cjs.map +1 -0
- package/dist/{chunk-BJ2XPN77.js → chunk-OM4OSBOP.js} +5 -5
- package/dist/{chunk-BJ2XPN77.js.map → chunk-OM4OSBOP.js.map} +1 -1
- package/dist/{chunk-HPUGKUMZ.js → chunk-PMRWMRXY.js} +13 -640
- package/dist/chunk-PMRWMRXY.js.map +1 -0
- package/dist/{chunk-UTZR7P7E.cjs → chunk-QPH5ZKP5.cjs} +43 -673
- package/dist/chunk-QPH5ZKP5.cjs.map +1 -0
- package/dist/chunk-SEO6NAQT.js +14 -0
- package/dist/chunk-SEO6NAQT.js.map +1 -0
- package/dist/{chunk-QC5MNKVF.js → chunk-TFRZOUTV.js} +13 -12
- package/dist/chunk-TFRZOUTV.js.map +1 -0
- package/dist/chunk-VQTCQKHQ.cjs +17 -0
- package/dist/chunk-VQTCQKHQ.cjs.map +1 -0
- package/dist/chunk-Z7VAOK5X.js +1183 -0
- package/dist/chunk-Z7VAOK5X.js.map +1 -0
- package/dist/{chunk-W35FVJBC.js → chunk-ZDPIWKWD.js} +9 -5
- package/dist/chunk-ZDPIWKWD.js.map +1 -0
- package/dist/correlation-id.cjs +22 -10
- package/dist/correlation-id.js +14 -2
- package/dist/decorators.cjs +7 -8
- package/dist/decorators.cjs.map +1 -1
- package/dist/decorators.d.cts +1 -1
- package/dist/decorators.d.ts +1 -1
- package/dist/decorators.js +6 -7
- package/dist/decorators.js.map +1 -1
- package/dist/event.cjs +8 -9
- package/dist/event.js +5 -6
- package/dist/functional.cjs +13 -14
- package/dist/functional.d.cts +1 -1
- package/dist/functional.d.ts +1 -1
- package/dist/functional.js +6 -7
- package/dist/http.cjs +13 -2
- package/dist/http.cjs.map +1 -1
- package/dist/http.js +12 -1
- package/dist/http.js.map +1 -1
- package/dist/index.cjs +305 -280
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -10
- package/dist/index.d.ts +89 -10
- package/dist/index.js +180 -181
- package/dist/index.js.map +1 -1
- package/dist/instrumentation.cjs +9 -9
- package/dist/instrumentation.js +2 -2
- package/dist/messaging-adapters.d.cts +1 -1
- package/dist/messaging-adapters.d.ts +1 -1
- package/dist/messaging-testing.d.cts +1 -1
- package/dist/messaging-testing.d.ts +1 -1
- package/dist/messaging.cjs +11 -11
- package/dist/messaging.d.cts +1 -1
- package/dist/messaging.d.ts +1 -1
- package/dist/messaging.js +8 -8
- package/dist/semantic-helpers.cjs +11 -12
- package/dist/semantic-helpers.d.cts +1 -1
- package/dist/semantic-helpers.d.ts +1 -1
- package/dist/semantic-helpers.js +7 -8
- package/dist/{trace-context-t5X1AP-e.d.cts → trace-context-DbGKd1Rn.d.cts} +18 -5
- package/dist/{trace-context-t5X1AP-e.d.ts → trace-context-DbGKd1Rn.d.ts} +18 -5
- package/dist/trace-helpers.cjs +13 -13
- package/dist/trace-helpers.d.cts +2 -2
- package/dist/trace-helpers.d.ts +2 -2
- package/dist/trace-helpers.js +1 -1
- package/dist/{utils-CbUkl8r1.d.cts → utils-BahBCFtJ.d.cts} +1 -1
- package/dist/{utils-Buel3cj0.d.ts → utils-CLKwaUlG.d.ts} +1 -1
- package/dist/webhook.cjs +21 -12
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +20 -11
- package/dist/webhook.js.map +1 -1
- package/dist/workflow-distributed.cjs +25 -21
- package/dist/workflow-distributed.cjs.map +1 -1
- package/dist/workflow-distributed.d.cts +1 -1
- package/dist/workflow-distributed.d.ts +1 -1
- package/dist/workflow-distributed.js +23 -19
- package/dist/workflow-distributed.js.map +1 -1
- package/dist/workflow.cjs +12 -12
- package/dist/workflow.d.cts +1 -1
- package/dist/workflow.d.ts +1 -1
- package/dist/workflow.js +8 -8
- package/package.json +43 -45
- package/skills/analyze-traces/SKILL.md +178 -0
- package/skills/autotel-core/SKILL.md +2 -7
- package/skills/autotel-events/SKILL.md +2 -6
- package/skills/autotel-frameworks/SKILL.md +2 -9
- package/skills/autotel-instrumentation/SKILL.md +2 -7
- package/skills/autotel-request-logging/SKILL.md +2 -8
- package/skills/autotel-structured-errors/SKILL.md +2 -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/correlated-events.test.ts +151 -0
- package/src/correlated-events.ts +47 -0
- 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.ts +2 -0
- package/src/gen-ai-events.ts +14 -5
- package/src/index.ts +39 -4
- package/src/messaging.ts +10 -9
- 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 +40 -5
- package/src/structured-error.test.ts +86 -1
- package/src/structured-error.ts +9 -2
- package/src/trace-context.ts +39 -11
- package/src/trace-helpers.ts +2 -2
- package/src/trace-hybrid.test.ts +42 -0
- package/src/trace-hybrid.ts +37 -0
- package/src/webhook.ts +16 -7
- package/src/workflow-distributed.ts +18 -13
- package/src/workflow.ts +7 -6
- package/bin/intent.js +0 -6
- package/dist/chunk-563EL6O6.cjs.map +0 -1
- package/dist/chunk-6YGUN7IY.cjs.map +0 -1
- package/dist/chunk-B3ZHLLMP.js.map +0 -1
- package/dist/chunk-BBBWDIYQ.js +0 -211
- package/dist/chunk-BBBWDIYQ.js.map +0 -1
- package/dist/chunk-CMNGGTQL.cjs.map +0 -1
- package/dist/chunk-D5LMF53P.cjs +0 -150
- package/dist/chunk-D5LMF53P.cjs.map +0 -1
- package/dist/chunk-GML3FBOT.cjs.map +0 -1
- package/dist/chunk-HPUGKUMZ.js.map +0 -1
- package/dist/chunk-HZ3FYBJG.cjs +0 -217
- package/dist/chunk-HZ3FYBJG.cjs.map +0 -1
- package/dist/chunk-JSNUWSBH.cjs +0 -62
- package/dist/chunk-JSNUWSBH.cjs.map +0 -1
- package/dist/chunk-OBWXM4NN.cjs.map +0 -1
- package/dist/chunk-QC5MNKVF.js.map +0 -1
- package/dist/chunk-S4OFEXLA.js +0 -53
- package/dist/chunk-S4OFEXLA.js.map +0 -1
- package/dist/chunk-TDNKIHKT.js.map +0 -1
- package/dist/chunk-UTZR7P7E.cjs.map +0 -1
- package/dist/chunk-W35FVJBC.js.map +0 -1
- package/dist/chunk-WD4RP6IV.js +0 -146
- package/dist/chunk-WD4RP6IV.js.map +0 -1
- package/dist/chunk-YN7USLHW.js.map +0 -1
- package/src/package-manifest.test.ts +0 -24
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
emitCorrelatedEvent,
|
|
4
|
+
type CorrelatedEventTarget,
|
|
5
|
+
} from './correlated-events';
|
|
6
|
+
|
|
7
|
+
function makeTarget(opts: { withAddEvent: boolean }): {
|
|
8
|
+
target: CorrelatedEventTarget;
|
|
9
|
+
setAttribute: ReturnType<typeof vi.fn>;
|
|
10
|
+
setAttributes: ReturnType<typeof vi.fn>;
|
|
11
|
+
addEvent: ReturnType<typeof vi.fn> | undefined;
|
|
12
|
+
} {
|
|
13
|
+
const setAttribute = vi.fn();
|
|
14
|
+
const setAttributes = vi.fn();
|
|
15
|
+
const addEvent = opts.withAddEvent ? vi.fn() : undefined;
|
|
16
|
+
const target: CorrelatedEventTarget = addEvent
|
|
17
|
+
? { setAttribute, setAttributes, addEvent }
|
|
18
|
+
: { setAttribute, setAttributes };
|
|
19
|
+
return { target, setAttribute, setAttributes, addEvent };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('emitCorrelatedEvent', () => {
|
|
23
|
+
describe('addEvent path', () => {
|
|
24
|
+
it('forwards to addEvent when present and skips the attribute fallback', () => {
|
|
25
|
+
const { target, setAttribute, setAttributes, addEvent } = makeTarget({
|
|
26
|
+
withAddEvent: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
emitCorrelatedEvent(target, 'gen_ai.prompt.sent', {
|
|
30
|
+
'gen_ai.system': 'openai',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(addEvent).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(addEvent).toHaveBeenCalledWith('gen_ai.prompt.sent', {
|
|
35
|
+
'gen_ai.system': 'openai',
|
|
36
|
+
});
|
|
37
|
+
expect(setAttribute).not.toHaveBeenCalled();
|
|
38
|
+
expect(setAttributes).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('sanitizes the event name before forwarding', () => {
|
|
42
|
+
const { target, addEvent } = makeTarget({ withAddEvent: true });
|
|
43
|
+
|
|
44
|
+
emitCorrelatedEvent(target, 'gen ai/prompt sent!', {});
|
|
45
|
+
|
|
46
|
+
expect(addEvent).toHaveBeenCalledWith('gen_ai_prompt_sent_', {});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('preserves `this` when calling addEvent (works for prototype methods)', () => {
|
|
50
|
+
const captured: { self: unknown; args: unknown[] } = {
|
|
51
|
+
self: null,
|
|
52
|
+
args: [],
|
|
53
|
+
};
|
|
54
|
+
const target = {
|
|
55
|
+
setAttribute: vi.fn(),
|
|
56
|
+
setAttributes: vi.fn(),
|
|
57
|
+
addEvent(this: unknown, ...args: unknown[]) {
|
|
58
|
+
captured.self = this;
|
|
59
|
+
captured.args = args;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
emitCorrelatedEvent(target, 'evt', { k: 'v' });
|
|
64
|
+
|
|
65
|
+
expect(captured.self).toBe(target);
|
|
66
|
+
expect(captured.args).toEqual(['evt', { k: 'v' }]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('attribute fallback', () => {
|
|
71
|
+
it('writes flat, sequence-prefixed attributes when addEvent is missing', () => {
|
|
72
|
+
const { target, setAttributes } = makeTarget({ withAddEvent: false });
|
|
73
|
+
|
|
74
|
+
emitCorrelatedEvent(target, 'workflow.started', {
|
|
75
|
+
'workflow.id': 'wf-1',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(setAttributes).toHaveBeenCalledTimes(1);
|
|
79
|
+
const written = setAttributes.mock.calls[0]![0] as Record<
|
|
80
|
+
string,
|
|
81
|
+
unknown
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
expect(written['autotel.event.1.workflow.started.name']).toBe(
|
|
85
|
+
'workflow.started',
|
|
86
|
+
);
|
|
87
|
+
expect(typeof written['autotel.event.1.workflow.started.ts']).toBe(
|
|
88
|
+
'string',
|
|
89
|
+
);
|
|
90
|
+
expect(written['autotel.event.1.workflow.started.workflow.id']).toBe(
|
|
91
|
+
'wf-1',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not overwrite earlier events when the same name fires twice', () => {
|
|
96
|
+
const { target, setAttributes } = makeTarget({ withAddEvent: false });
|
|
97
|
+
|
|
98
|
+
emitCorrelatedEvent(target, 'step_retry', { 'workflow.step.attempt': 1 });
|
|
99
|
+
emitCorrelatedEvent(target, 'step_retry', { 'workflow.step.attempt': 2 });
|
|
100
|
+
|
|
101
|
+
expect(setAttributes).toHaveBeenCalledTimes(2);
|
|
102
|
+
const first = setAttributes.mock.calls[0]![0] as Record<string, unknown>;
|
|
103
|
+
const second = setAttributes.mock.calls[1]![0] as Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
expect(first['autotel.event.1.step_retry.workflow.step.attempt']).toBe(1);
|
|
106
|
+
expect(second['autotel.event.2.step_retry.workflow.step.attempt']).toBe(
|
|
107
|
+
2,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Different keys: second call cannot overwrite the first when both
|
|
111
|
+
// attribute sets are merged on the same span.
|
|
112
|
+
expect(
|
|
113
|
+
Object.keys(first).every((k) => !Object.keys(second).includes(k)),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('sanitizes attribute keys in the fallback path', () => {
|
|
118
|
+
const { target, setAttributes } = makeTarget({ withAddEvent: false });
|
|
119
|
+
|
|
120
|
+
emitCorrelatedEvent(target, 'evt', { 'has spaces/and-bad!': 1 });
|
|
121
|
+
|
|
122
|
+
const written = setAttributes.mock.calls[0]![0] as Record<
|
|
123
|
+
string,
|
|
124
|
+
unknown
|
|
125
|
+
>;
|
|
126
|
+
const key = Object.keys(written).find((k) =>
|
|
127
|
+
k.endsWith('has_spaces_and-bad_'),
|
|
128
|
+
);
|
|
129
|
+
expect(key).toBeDefined();
|
|
130
|
+
expect(written[key!]).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('keeps separate sequences for separate targets', () => {
|
|
134
|
+
const a = makeTarget({ withAddEvent: false });
|
|
135
|
+
const b = makeTarget({ withAddEvent: false });
|
|
136
|
+
|
|
137
|
+
emitCorrelatedEvent(a.target, 'evt', {});
|
|
138
|
+
emitCorrelatedEvent(b.target, 'evt', {});
|
|
139
|
+
|
|
140
|
+
const aKeys = Object.keys(
|
|
141
|
+
a.setAttributes.mock.calls[0]![0] as Record<string, unknown>,
|
|
142
|
+
);
|
|
143
|
+
const bKeys = Object.keys(
|
|
144
|
+
b.setAttributes.mock.calls[0]![0] as Record<string, unknown>,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(aKeys.some((k) => k.startsWith('autotel.event.1.'))).toBe(true);
|
|
148
|
+
expect(bKeys.some((k) => k.startsWith('autotel.event.1.'))).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AttributeValue } from './trace-context';
|
|
2
|
+
|
|
3
|
+
export interface CorrelatedEventTarget {
|
|
4
|
+
setAttribute(key: string, value: AttributeValue): unknown;
|
|
5
|
+
setAttributes(attrs: Record<string, AttributeValue>): unknown;
|
|
6
|
+
addEvent?(name: string, attrs?: Record<string, AttributeValue>): unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// OTel attribute keys are dot-namespaced flat strings; we keep `.`/`-`/`_` and
|
|
10
|
+
// drop everything else so user-supplied event names can't break attribute keys.
|
|
11
|
+
function sanitizeEventKey(input: string): string {
|
|
12
|
+
return input.replaceAll(/[^a-zA-Z0-9_.-]/g, '_');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Per-target sequence so the fallback path can encode multiple events with the
|
|
16
|
+
// same name without one overwriting the previous (attributes are
|
|
17
|
+
// last-write-wins; events are not). Today the addEvent path is always taken;
|
|
18
|
+
// this keeps the fallback correct if/when the runtime stops binding addEvent.
|
|
19
|
+
const sequenceByTarget = new WeakMap<object, number>();
|
|
20
|
+
|
|
21
|
+
function nextSequence(target: object): number {
|
|
22
|
+
const n = (sequenceByTarget.get(target) ?? 0) + 1;
|
|
23
|
+
sequenceByTarget.set(target, n);
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emitCorrelatedEvent(
|
|
28
|
+
ctx: CorrelatedEventTarget,
|
|
29
|
+
name: string,
|
|
30
|
+
attrs: Record<string, AttributeValue> = {},
|
|
31
|
+
): void {
|
|
32
|
+
const eventName = sanitizeEventKey(name);
|
|
33
|
+
if (typeof ctx.addEvent === 'function') {
|
|
34
|
+
ctx.addEvent.call(ctx, eventName, attrs);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const seq = nextSequence(ctx);
|
|
38
|
+
const prefix = `autotel.event.${seq}.${eventName}`;
|
|
39
|
+
const flattened: Record<string, AttributeValue> = {
|
|
40
|
+
[`${prefix}.name`]: eventName,
|
|
41
|
+
[`${prefix}.ts`]: new Date().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
44
|
+
flattened[`${prefix}.${sanitizeEventKey(k)}`] = v;
|
|
45
|
+
}
|
|
46
|
+
ctx.setAttributes(flattened);
|
|
47
|
+
}
|
|
@@ -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.ts
CHANGED
|
@@ -422,6 +422,8 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
|
422
422
|
function createDummyCtx<
|
|
423
423
|
TBaggage extends Record<string, unknown> | undefined = undefined,
|
|
424
424
|
>(): TraceContext<TBaggage> {
|
|
425
|
+
// `recordException` / `addEvent` are no-op shims kept for the same
|
|
426
|
+
// compatibility window as `createTraceContext` (see trace-context.ts).
|
|
425
427
|
return {
|
|
426
428
|
traceId: '',
|
|
427
429
|
spanId: '',
|
package/src/gen-ai-events.ts
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import type { TraceContext } from './trace-context';
|
|
45
|
+
import { emitCorrelatedEvent } from './correlated-events';
|
|
45
46
|
|
|
46
47
|
type EventAttrs = Record<string, string | number | boolean>;
|
|
47
48
|
|
|
@@ -102,7 +103,7 @@ export function recordPromptSent(
|
|
|
102
103
|
ctx: TraceContext,
|
|
103
104
|
event: PromptSentEvent = {},
|
|
104
105
|
): void {
|
|
105
|
-
ctx
|
|
106
|
+
emitCorrelatedEvent(ctx, 'gen_ai.prompt.sent', buildPromptSentAttrs(event));
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/**
|
|
@@ -113,7 +114,11 @@ export function recordResponseReceived(
|
|
|
113
114
|
ctx: TraceContext,
|
|
114
115
|
event: ResponseReceivedEvent = {},
|
|
115
116
|
): void {
|
|
116
|
-
|
|
117
|
+
emitCorrelatedEvent(
|
|
118
|
+
ctx,
|
|
119
|
+
'gen_ai.response.received',
|
|
120
|
+
buildResponseAttrs(event),
|
|
121
|
+
);
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/**
|
|
@@ -122,7 +127,7 @@ export function recordResponseReceived(
|
|
|
122
127
|
* decision was made.
|
|
123
128
|
*/
|
|
124
129
|
export function recordRetry(ctx: TraceContext, event: RetryEvent): void {
|
|
125
|
-
ctx
|
|
130
|
+
emitCorrelatedEvent(ctx, 'gen_ai.retry', buildRetryAttrs(event));
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
/**
|
|
@@ -131,7 +136,7 @@ export function recordRetry(ctx: TraceContext, event: RetryEvent): void {
|
|
|
131
136
|
* several tool calls within a single provider response.
|
|
132
137
|
*/
|
|
133
138
|
export function recordToolCall(ctx: TraceContext, event: ToolCallEvent): void {
|
|
134
|
-
ctx
|
|
139
|
+
emitCorrelatedEvent(ctx, 'gen_ai.tool.call', buildToolCallAttrs(event));
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
/**
|
|
@@ -143,7 +148,11 @@ export function recordStreamFirstToken(
|
|
|
143
148
|
ctx: TraceContext,
|
|
144
149
|
event: StreamFirstTokenEvent = {},
|
|
145
150
|
): void {
|
|
146
|
-
|
|
151
|
+
emitCorrelatedEvent(
|
|
152
|
+
ctx,
|
|
153
|
+
'gen_ai.stream.first_token',
|
|
154
|
+
buildStreamFirstTokenAttrs(event),
|
|
155
|
+
);
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
// ---- Attribute builders -------------------------------------------------
|