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.
Files changed (191) hide show
  1. package/README.md +50 -23
  2. package/dist/attribute-redacting-processor.cjs +8 -8
  3. package/dist/attribute-redacting-processor.d.cts +10 -1
  4. package/dist/attribute-redacting-processor.d.ts +10 -1
  5. package/dist/attribute-redacting-processor.js +1 -1
  6. package/dist/attributes.cjs +21 -21
  7. package/dist/attributes.d.cts +3 -3
  8. package/dist/attributes.d.ts +3 -3
  9. package/dist/attributes.js +2 -2
  10. package/dist/auto.cjs +3 -3
  11. package/dist/auto.js +2 -2
  12. package/dist/business-baggage.d.cts +1 -1
  13. package/dist/business-baggage.d.ts +1 -1
  14. package/dist/chunk-4P6ZOARG.cjs +33 -0
  15. package/dist/chunk-4P6ZOARG.cjs.map +1 -0
  16. package/dist/{chunk-U54FTVFH.js → chunk-52PUSFC2.js} +3 -3
  17. package/dist/{chunk-U54FTVFH.js.map → chunk-52PUSFC2.js.map} +1 -1
  18. package/dist/{chunk-YEVCD6DR.cjs → chunk-7SMNC4LS.cjs} +7 -7
  19. package/dist/{chunk-YEVCD6DR.cjs.map → chunk-7SMNC4LS.cjs.map} +1 -1
  20. package/dist/{chunk-563EL6O6.cjs → chunk-BPO2PQ3T.cjs} +12 -8
  21. package/dist/chunk-BPO2PQ3T.cjs.map +1 -0
  22. package/dist/{chunk-WZOKY3PW.cjs → chunk-DAZ7EGR4.cjs} +19 -19
  23. package/dist/{chunk-WZOKY3PW.cjs.map → chunk-DAZ7EGR4.cjs.map} +1 -1
  24. package/dist/{chunk-ER43K7ES.js → chunk-DDXIUZEG.js} +3 -3
  25. package/dist/{chunk-ER43K7ES.js.map → chunk-DDXIUZEG.js.map} +1 -1
  26. package/dist/{chunk-JKIMEPI2.cjs → chunk-DQ2SUROF.cjs} +4 -4
  27. package/dist/{chunk-JKIMEPI2.cjs.map → chunk-DQ2SUROF.cjs.map} +1 -1
  28. package/dist/{chunk-B3ZHLLMP.js → chunk-DSMSIVTG.js} +2 -2
  29. package/dist/chunk-DSMSIVTG.js.map +1 -0
  30. package/dist/{chunk-OBWXM4NN.cjs → chunk-HKZHUGGN.cjs} +15 -14
  31. package/dist/chunk-HKZHUGGN.cjs.map +1 -0
  32. package/dist/{chunk-TDNKIHKT.js → chunk-JVWJDHDB.js} +13 -4
  33. package/dist/chunk-JVWJDHDB.js.map +1 -0
  34. package/dist/{chunk-YN7USLHW.js → chunk-K7HSRLP5.js} +11 -10
  35. package/dist/chunk-K7HSRLP5.js.map +1 -0
  36. package/dist/chunk-KIL5CUN6.js +31 -0
  37. package/dist/chunk-KIL5CUN6.js.map +1 -0
  38. package/dist/chunk-KKGM42RQ.cjs +1207 -0
  39. package/dist/chunk-KKGM42RQ.cjs.map +1 -0
  40. package/dist/{chunk-6YGUN7IY.cjs → chunk-MOO75VE4.cjs} +18 -17
  41. package/dist/chunk-MOO75VE4.cjs.map +1 -0
  42. package/dist/{chunk-GML3FBOT.cjs → chunk-NCSMD3TK.cjs} +2 -2
  43. package/dist/chunk-NCSMD3TK.cjs.map +1 -0
  44. package/dist/{chunk-CMNGGTQL.cjs → chunk-NXLRY2CE.cjs} +13 -4
  45. package/dist/chunk-NXLRY2CE.cjs.map +1 -0
  46. package/dist/{chunk-BJ2XPN77.js → chunk-OM4OSBOP.js} +5 -5
  47. package/dist/{chunk-BJ2XPN77.js.map → chunk-OM4OSBOP.js.map} +1 -1
  48. package/dist/{chunk-HPUGKUMZ.js → chunk-PMRWMRXY.js} +13 -640
  49. package/dist/chunk-PMRWMRXY.js.map +1 -0
  50. package/dist/{chunk-UTZR7P7E.cjs → chunk-QPH5ZKP5.cjs} +43 -673
  51. package/dist/chunk-QPH5ZKP5.cjs.map +1 -0
  52. package/dist/chunk-SEO6NAQT.js +14 -0
  53. package/dist/chunk-SEO6NAQT.js.map +1 -0
  54. package/dist/{chunk-QC5MNKVF.js → chunk-TFRZOUTV.js} +13 -12
  55. package/dist/chunk-TFRZOUTV.js.map +1 -0
  56. package/dist/chunk-VQTCQKHQ.cjs +17 -0
  57. package/dist/chunk-VQTCQKHQ.cjs.map +1 -0
  58. package/dist/chunk-Z7VAOK5X.js +1183 -0
  59. package/dist/chunk-Z7VAOK5X.js.map +1 -0
  60. package/dist/{chunk-W35FVJBC.js → chunk-ZDPIWKWD.js} +9 -5
  61. package/dist/chunk-ZDPIWKWD.js.map +1 -0
  62. package/dist/correlation-id.cjs +22 -10
  63. package/dist/correlation-id.js +14 -2
  64. package/dist/decorators.cjs +7 -8
  65. package/dist/decorators.cjs.map +1 -1
  66. package/dist/decorators.d.cts +1 -1
  67. package/dist/decorators.d.ts +1 -1
  68. package/dist/decorators.js +6 -7
  69. package/dist/decorators.js.map +1 -1
  70. package/dist/event.cjs +8 -9
  71. package/dist/event.js +5 -6
  72. package/dist/functional.cjs +13 -14
  73. package/dist/functional.d.cts +1 -1
  74. package/dist/functional.d.ts +1 -1
  75. package/dist/functional.js +6 -7
  76. package/dist/http.cjs +13 -2
  77. package/dist/http.cjs.map +1 -1
  78. package/dist/http.js +12 -1
  79. package/dist/http.js.map +1 -1
  80. package/dist/index.cjs +305 -280
  81. package/dist/index.cjs.map +1 -1
  82. package/dist/index.d.cts +89 -10
  83. package/dist/index.d.ts +89 -10
  84. package/dist/index.js +180 -181
  85. package/dist/index.js.map +1 -1
  86. package/dist/instrumentation.cjs +9 -9
  87. package/dist/instrumentation.js +2 -2
  88. package/dist/messaging-adapters.d.cts +1 -1
  89. package/dist/messaging-adapters.d.ts +1 -1
  90. package/dist/messaging-testing.d.cts +1 -1
  91. package/dist/messaging-testing.d.ts +1 -1
  92. package/dist/messaging.cjs +11 -11
  93. package/dist/messaging.d.cts +1 -1
  94. package/dist/messaging.d.ts +1 -1
  95. package/dist/messaging.js +8 -8
  96. package/dist/semantic-helpers.cjs +11 -12
  97. package/dist/semantic-helpers.d.cts +1 -1
  98. package/dist/semantic-helpers.d.ts +1 -1
  99. package/dist/semantic-helpers.js +7 -8
  100. package/dist/{trace-context-t5X1AP-e.d.cts → trace-context-DbGKd1Rn.d.cts} +18 -5
  101. package/dist/{trace-context-t5X1AP-e.d.ts → trace-context-DbGKd1Rn.d.ts} +18 -5
  102. package/dist/trace-helpers.cjs +13 -13
  103. package/dist/trace-helpers.d.cts +2 -2
  104. package/dist/trace-helpers.d.ts +2 -2
  105. package/dist/trace-helpers.js +1 -1
  106. package/dist/{utils-CbUkl8r1.d.cts → utils-BahBCFtJ.d.cts} +1 -1
  107. package/dist/{utils-Buel3cj0.d.ts → utils-CLKwaUlG.d.ts} +1 -1
  108. package/dist/webhook.cjs +21 -12
  109. package/dist/webhook.cjs.map +1 -1
  110. package/dist/webhook.d.cts +1 -1
  111. package/dist/webhook.d.ts +1 -1
  112. package/dist/webhook.js +20 -11
  113. package/dist/webhook.js.map +1 -1
  114. package/dist/workflow-distributed.cjs +25 -21
  115. package/dist/workflow-distributed.cjs.map +1 -1
  116. package/dist/workflow-distributed.d.cts +1 -1
  117. package/dist/workflow-distributed.d.ts +1 -1
  118. package/dist/workflow-distributed.js +23 -19
  119. package/dist/workflow-distributed.js.map +1 -1
  120. package/dist/workflow.cjs +12 -12
  121. package/dist/workflow.d.cts +1 -1
  122. package/dist/workflow.d.ts +1 -1
  123. package/dist/workflow.js +8 -8
  124. package/package.json +43 -45
  125. package/skills/analyze-traces/SKILL.md +178 -0
  126. package/skills/autotel-core/SKILL.md +2 -7
  127. package/skills/autotel-events/SKILL.md +2 -6
  128. package/skills/autotel-frameworks/SKILL.md +2 -9
  129. package/skills/autotel-instrumentation/SKILL.md +2 -7
  130. package/skills/autotel-request-logging/SKILL.md +2 -8
  131. package/skills/autotel-structured-errors/SKILL.md +2 -7
  132. package/skills/build-audit-trails/SKILL.md +302 -0
  133. package/skills/debug-missing-spans/SKILL.md +248 -0
  134. package/skills/migrate-to-autotel/SKILL.md +268 -0
  135. package/skills/review-otel-patterns/SKILL.md +488 -0
  136. package/skills/review-otel-patterns/references/code-review.md +75 -0
  137. package/skills/review-otel-patterns/references/processor-pipeline.md +205 -0
  138. package/skills/review-otel-patterns/references/structured-errors.md +102 -0
  139. package/skills/review-otel-patterns/references/wide-spans.md +85 -0
  140. package/skills/tune-sampling/SKILL.md +210 -0
  141. package/src/attribute-redacting-processor.test.ts +6 -4
  142. package/src/attribute-redacting-processor.ts +11 -2
  143. package/src/correlated-events.test.ts +151 -0
  144. package/src/correlated-events.ts +47 -0
  145. package/src/drain-toolkit.test.ts +113 -0
  146. package/src/drain-toolkit.ts +129 -0
  147. package/src/enricher-toolkit.test.ts +67 -0
  148. package/src/enricher-toolkit.ts +79 -0
  149. package/src/functional.ts +2 -0
  150. package/src/gen-ai-events.ts +14 -5
  151. package/src/index.ts +39 -4
  152. package/src/messaging.ts +10 -9
  153. package/src/redact-values.test.ts +24 -10
  154. package/src/redact-values.ts +9 -2
  155. package/src/request-logger.test.ts +91 -0
  156. package/src/request-logger.ts +40 -5
  157. package/src/structured-error.test.ts +86 -1
  158. package/src/structured-error.ts +9 -2
  159. package/src/trace-context.ts +39 -11
  160. package/src/trace-helpers.ts +2 -2
  161. package/src/trace-hybrid.test.ts +42 -0
  162. package/src/trace-hybrid.ts +37 -0
  163. package/src/webhook.ts +16 -7
  164. package/src/workflow-distributed.ts +18 -13
  165. package/src/workflow.ts +7 -6
  166. package/bin/intent.js +0 -6
  167. package/dist/chunk-563EL6O6.cjs.map +0 -1
  168. package/dist/chunk-6YGUN7IY.cjs.map +0 -1
  169. package/dist/chunk-B3ZHLLMP.js.map +0 -1
  170. package/dist/chunk-BBBWDIYQ.js +0 -211
  171. package/dist/chunk-BBBWDIYQ.js.map +0 -1
  172. package/dist/chunk-CMNGGTQL.cjs.map +0 -1
  173. package/dist/chunk-D5LMF53P.cjs +0 -150
  174. package/dist/chunk-D5LMF53P.cjs.map +0 -1
  175. package/dist/chunk-GML3FBOT.cjs.map +0 -1
  176. package/dist/chunk-HPUGKUMZ.js.map +0 -1
  177. package/dist/chunk-HZ3FYBJG.cjs +0 -217
  178. package/dist/chunk-HZ3FYBJG.cjs.map +0 -1
  179. package/dist/chunk-JSNUWSBH.cjs +0 -62
  180. package/dist/chunk-JSNUWSBH.cjs.map +0 -1
  181. package/dist/chunk-OBWXM4NN.cjs.map +0 -1
  182. package/dist/chunk-QC5MNKVF.js.map +0 -1
  183. package/dist/chunk-S4OFEXLA.js +0 -53
  184. package/dist/chunk-S4OFEXLA.js.map +0 -1
  185. package/dist/chunk-TDNKIHKT.js.map +0 -1
  186. package/dist/chunk-UTZR7P7E.cjs.map +0 -1
  187. package/dist/chunk-W35FVJBC.js.map +0 -1
  188. package/dist/chunk-WD4RP6IV.js +0 -146
  189. package/dist/chunk-WD4RP6IV.js.map +0 -1
  190. package/dist/chunk-YN7USLHW.js.map +0 -1
  191. 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: '',
@@ -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.addEvent('gen_ai.prompt.sent', buildPromptSentAttrs(event));
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
- ctx.addEvent('gen_ai.response.received', buildResponseAttrs(event));
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.addEvent('gen_ai.retry', buildRetryAttrs(event));
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.addEvent('gen_ai.tool.call', buildToolCallAttrs(event));
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
- ctx.addEvent('gen_ai.stream.first_token', buildStreamFirstTokenAttrs(event));
151
+ emitCorrelatedEvent(
152
+ ctx,
153
+ 'gen_ai.stream.first_token',
154
+ buildStreamFirstTokenAttrs(event),
155
+ );
147
156
  }
148
157
 
149
158
  // ---- Attribute builders -------------------------------------------------