autotel 2.22.0 → 2.23.1

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 (126) hide show
  1. package/README.md +112 -6
  2. package/bin/intent.js +6 -0
  3. package/dist/auto.cjs +3 -3
  4. package/dist/auto.js +2 -2
  5. package/dist/{chunk-EWH2542B.js → chunk-3AMR5XLZ.js} +3 -3
  6. package/dist/{chunk-EWH2542B.js.map → chunk-3AMR5XLZ.js.map} +1 -1
  7. package/dist/chunk-3QXBFGKP.js +344 -0
  8. package/dist/chunk-3QXBFGKP.js.map +1 -0
  9. package/dist/{chunk-VQFF2WMP.cjs → chunk-3ZFDJJWZ.cjs} +37 -29
  10. package/dist/chunk-3ZFDJJWZ.cjs.map +1 -0
  11. package/dist/{chunk-CQC6RVLR.cjs → chunk-4RZ4JUBY.cjs} +5 -5
  12. package/dist/{chunk-CQC6RVLR.cjs.map → chunk-4RZ4JUBY.cjs.map} +1 -1
  13. package/dist/{chunk-PAVYKPCQ.js → chunk-5XUEHX7J.js} +3 -3
  14. package/dist/{chunk-PAVYKPCQ.js.map → chunk-5XUEHX7J.js.map} +1 -1
  15. package/dist/chunk-6S5RUKU3.cjs +347 -0
  16. package/dist/chunk-6S5RUKU3.cjs.map +1 -0
  17. package/dist/{chunk-BS757SL2.js → chunk-724XLWR3.js} +9 -4
  18. package/dist/chunk-724XLWR3.js.map +1 -0
  19. package/dist/chunk-7EQ4G4SI.cjs +146 -0
  20. package/dist/chunk-7EQ4G4SI.cjs.map +1 -0
  21. package/dist/{chunk-CQP5SQT4.cjs → chunk-AXFWWJF3.cjs} +7 -7
  22. package/dist/{chunk-CQP5SQT4.cjs.map → chunk-AXFWWJF3.cjs.map} +1 -1
  23. package/dist/{chunk-7NH625MS.cjs → chunk-BSZP4URK.cjs} +5 -5
  24. package/dist/{chunk-7NH625MS.cjs.map → chunk-BSZP4URK.cjs.map} +1 -1
  25. package/dist/{chunk-GZFH6P5U.js → chunk-GY4CRZSV.js} +14 -6
  26. package/dist/chunk-GY4CRZSV.js.map +1 -0
  27. package/dist/{chunk-QKUGUDXJ.cjs → chunk-HSEIUH7F.cjs} +10 -5
  28. package/dist/chunk-HSEIUH7F.cjs.map +1 -0
  29. package/dist/{chunk-DTW3WB7Z.js → chunk-IPKXURBW.js} +3 -3
  30. package/dist/{chunk-DTW3WB7Z.js.map → chunk-IPKXURBW.js.map} +1 -1
  31. package/dist/chunk-J7VGRIAJ.js +64 -0
  32. package/dist/chunk-J7VGRIAJ.js.map +1 -0
  33. package/dist/chunk-KFOHQK7X.js +144 -0
  34. package/dist/chunk-KFOHQK7X.js.map +1 -0
  35. package/dist/{chunk-4UYR46UP.cjs → chunk-MSUHW2I4.cjs} +13 -13
  36. package/dist/{chunk-4UYR46UP.cjs.map → chunk-MSUHW2I4.cjs.map} +1 -1
  37. package/dist/chunk-T4B5LB6E.cjs +66 -0
  38. package/dist/chunk-T4B5LB6E.cjs.map +1 -0
  39. package/dist/{chunk-QHT4MUED.js → chunk-WCIIFRGL.js} +3 -3
  40. package/dist/{chunk-QHT4MUED.js.map → chunk-WCIIFRGL.js.map} +1 -1
  41. package/dist/decorators.cjs +3 -3
  42. package/dist/decorators.js +3 -3
  43. package/dist/drain-pipeline.cjs +13 -0
  44. package/dist/drain-pipeline.cjs.map +1 -0
  45. package/dist/drain-pipeline.d.cts +37 -0
  46. package/dist/drain-pipeline.d.ts +37 -0
  47. package/dist/drain-pipeline.js +4 -0
  48. package/dist/drain-pipeline.js.map +1 -0
  49. package/dist/enrichers.cjs +90 -0
  50. package/dist/enrichers.cjs.map +1 -0
  51. package/dist/enrichers.d.cts +23 -0
  52. package/dist/enrichers.d.ts +23 -0
  53. package/dist/enrichers.js +86 -0
  54. package/dist/enrichers.js.map +1 -0
  55. package/dist/event.cjs +6 -6
  56. package/dist/event.js +3 -3
  57. package/dist/functional.cjs +10 -10
  58. package/dist/functional.js +3 -3
  59. package/dist/index.cjs +274 -41
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.d.cts +73 -3
  62. package/dist/index.d.ts +73 -3
  63. package/dist/index.js +227 -11
  64. package/dist/index.js.map +1 -1
  65. package/dist/{init-BMiXSJNM.d.cts → init-BC5aN8bh.d.cts} +18 -0
  66. package/dist/{init-ByRbNTRo.d.ts → init-_FG4IbhF.d.ts} +18 -0
  67. package/dist/instrumentation.cjs +9 -9
  68. package/dist/instrumentation.js +2 -2
  69. package/dist/messaging.cjs +7 -7
  70. package/dist/messaging.js +4 -4
  71. package/dist/parse-error.cjs +13 -0
  72. package/dist/parse-error.cjs.map +1 -0
  73. package/dist/parse-error.d.cts +13 -0
  74. package/dist/parse-error.d.ts +13 -0
  75. package/dist/parse-error.js +4 -0
  76. package/dist/parse-error.js.map +1 -0
  77. package/dist/processors.cjs +2 -2
  78. package/dist/processors.d.cts +40 -4
  79. package/dist/processors.d.ts +40 -4
  80. package/dist/processors.js +1 -1
  81. package/dist/semantic-helpers.cjs +8 -8
  82. package/dist/semantic-helpers.js +4 -4
  83. package/dist/webhook.cjs +4 -4
  84. package/dist/webhook.js +3 -3
  85. package/dist/workflow-distributed.cjs +5 -5
  86. package/dist/workflow-distributed.js +3 -3
  87. package/dist/workflow.cjs +8 -8
  88. package/dist/workflow.js +4 -4
  89. package/dist/yaml-config.d.cts +2 -1
  90. package/dist/yaml-config.d.ts +2 -1
  91. package/package.json +55 -33
  92. package/skills/autotel-core/SKILL.md +162 -0
  93. package/skills/autotel-events/SKILL.md +88 -0
  94. package/skills/autotel-frameworks/SKILL.md +97 -0
  95. package/skills/autotel-instrumentation/SKILL.md +125 -0
  96. package/skills/autotel-request-logging/SKILL.md +100 -0
  97. package/skills/autotel-structured-errors/SKILL.md +130 -0
  98. package/src/drain-pipeline.test.ts +68 -0
  99. package/src/drain-pipeline.ts +199 -0
  100. package/src/enrichers.test.ts +150 -0
  101. package/src/enrichers.ts +145 -0
  102. package/src/flatten-attributes.test.ts +76 -0
  103. package/src/flatten-attributes.ts +80 -0
  104. package/src/functional.test.ts +63 -0
  105. package/src/functional.ts +11 -3
  106. package/src/index.ts +34 -0
  107. package/src/init.ts +22 -0
  108. package/src/package-manifest.test.ts +24 -0
  109. package/src/parse-error.test.ts +73 -0
  110. package/src/parse-error.ts +112 -0
  111. package/src/pretty-log-formatter.test.ts +123 -0
  112. package/src/pretty-log-formatter.ts +210 -0
  113. package/src/processors/canonical-log-line-processor.test.ts +81 -25
  114. package/src/processors/canonical-log-line-processor.ts +130 -42
  115. package/src/request-logger.test.ts +137 -0
  116. package/src/request-logger.ts +150 -0
  117. package/src/structured-error.test.ts +106 -0
  118. package/src/structured-error.ts +97 -0
  119. package/dist/chunk-2RQDNGV3.js +0 -126
  120. package/dist/chunk-2RQDNGV3.js.map +0 -1
  121. package/dist/chunk-BS757SL2.js.map +0 -1
  122. package/dist/chunk-GZFH6P5U.js.map +0 -1
  123. package/dist/chunk-ONK2Y22L.cjs +0 -128
  124. package/dist/chunk-ONK2Y22L.cjs.map +0 -1
  125. package/dist/chunk-QKUGUDXJ.cjs.map +0 -1
  126. package/dist/chunk-VQFF2WMP.cjs.map +0 -1
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: autotel-instrumentation
3
+ description: >
4
+ trace(), span(), instrument(), init(). Factory vs direct pattern, name inference. Sync init; use node-require for optional deps. Load when wrapping handlers or functions with spans.
5
+ type: core
6
+ library: autotel
7
+ library_version: '2.23.0'
8
+ sources:
9
+ - jagreehal/autotel:docs/ARCHITECTURE.md
10
+ - jagreehal/autotel:packages/autotel/src/functional.ts
11
+ - jagreehal/autotel:packages/autotel/CLAUDE.md
12
+ ---
13
+
14
+ # Autotel — Instrumentation
15
+
16
+ Wrap functions and handlers with `trace()`, `span()`, or `instrument()`. Call `init()` once at app startup. Keep init synchronous; use `safeRequire`/`requireModule` for optional dependencies.
17
+
18
+ ## Setup
19
+
20
+ ```typescript
21
+ import { init, trace } from 'autotel';
22
+
23
+ init({ service: 'my-app' });
24
+
25
+ const handler = trace(async (req: Request) => {
26
+ return processRequest(req);
27
+ });
28
+ ```
29
+
30
+ With span context (attributes, request logger):
31
+
32
+ ```typescript
33
+ const handler = trace((ctx) => async (req: Request) => {
34
+ ctx.setAttribute('http.route', '/api/checkout');
35
+ const log = getRequestLogger(ctx);
36
+ log.set({ path: req.url });
37
+ return processRequest(req);
38
+ });
39
+ ```
40
+
41
+ ## Core Patterns
42
+
43
+ **Explicit span name:**
44
+
45
+ ```typescript
46
+ const checkout = trace('checkout', async (body) => {
47
+ return processCheckout(body);
48
+ });
49
+ ```
50
+
51
+ **instrument() with key:**
52
+
53
+ ```typescript
54
+ import { instrument } from 'autotel';
55
+ const fn = instrument({
56
+ key: 'processOrder',
57
+ fn: async (id) => db.orders.get(id),
58
+ });
59
+ ```
60
+
61
+ **span() for a child span:**
62
+
63
+ ```typescript
64
+ import { span } from 'autotel';
65
+ const result = await span('db.query', async () => db.query(sql));
66
+ ```
67
+
68
+ **Init with optional config:**
69
+
70
+ ```typescript
71
+ init({
72
+ service: 'my-api',
73
+ // see docs/CONFIGURATION.md for full options
74
+ });
75
+ ```
76
+
77
+ ## Common Mistakes
78
+
79
+ ### HIGH Forget to call init() before using trace/span
80
+
81
+ Wrong:
82
+
83
+ ```typescript
84
+ import { trace } from 'autotel';
85
+ export const fn = trace(async () => {});
86
+ ```
87
+
88
+ Correct:
89
+
90
+ ```typescript
91
+ import { init, trace } from 'autotel';
92
+ init({ service: 'my-app' });
93
+ export const fn = trace(async () => {});
94
+ ```
95
+
96
+ SDK must be initialized; trace() and span() rely on the global tracer.
97
+
98
+ Source: packages/autotel/CLAUDE.md
99
+
100
+ ### MEDIUM Wrong export path for submodules
101
+
102
+ Wrong:
103
+
104
+ ```typescript
105
+ import { Event } from 'autotel'; // Event is on autotel/event
106
+ import { createTraceCollector } from 'autotel'; // testing subpath
107
+ ```
108
+
109
+ Correct:
110
+
111
+ ```typescript
112
+ import { trace, init, getRequestLogger } from 'autotel';
113
+ import { Event } from 'autotel/event';
114
+ import { createTraceCollector } from 'autotel/testing';
115
+ ```
116
+
117
+ Use the exact export paths from package.json (autotel/event, autotel/testing, autotel/attributes, etc.).
118
+
119
+ Source: packages/autotel/package.json exports
120
+
121
+ ## Version
122
+
123
+ Targets autotel v2.23.x.
124
+
125
+ See also: autotel-core/SKILL.md — when to use what. autotel-request-logging/SKILL.md — getRequestLogger requires active span.
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: autotel-request-logging
3
+ description: >
4
+ getRequestLogger(), set(), info/warn/error, emitNow(). One snapshot per request; requires active span. Use when adding request-scoped context or replacing scattered console.log.
5
+ type: core
6
+ library: autotel
7
+ library_version: '2.23.0'
8
+ requires:
9
+ - autotel-instrumentation
10
+ sources:
11
+ - jagreehal/autotel:packages/autotel/src/request-logger.ts
12
+ - jagreehal/autotel:docs/AGENT-GUIDE.md
13
+ ---
14
+
15
+ # Autotel — Request Logging
16
+
17
+ This skill builds on autotel-instrumentation. Read it first for init and span creation.
18
+
19
+ Accumulate context with `getRequestLogger(ctx)`, `.set()`, and `.info()`/`.warn()`/`.error()`. Call `.emitNow()` (or rely on middleware) to emit one snapshot per request. Request logger requires an active span — use inside `trace()` or framework middleware.
20
+
21
+ ## Setup
22
+
23
+ ```typescript
24
+ import { init, trace, getRequestLogger } from 'autotel';
25
+
26
+ init({ service: 'my-app' });
27
+
28
+ const handler = trace((ctx) => async (req: Request, res: Response) => {
29
+ const log = getRequestLogger(ctx);
30
+ log.set({ method: req.method, path: req.url });
31
+
32
+ const user = await getAuth(req);
33
+ log.set({ user: { id: user.id } });
34
+
35
+ const result = await doWork(req);
36
+ log.set({ result: { id: result.id } });
37
+ log.emitNow();
38
+ return res.json(result);
39
+ });
40
+ ```
41
+
42
+ When the framework creates the span (e.g. Hono middleware), call `getRequestLogger()` with no args:
43
+
44
+ ```typescript
45
+ app.use(autotelMiddleware());
46
+ app.get('/api/users', (c) => {
47
+ const log = getRequestLogger();
48
+ log.set({ route: 'users' });
49
+ return c.json({ users: [] });
50
+ });
51
+ ```
52
+
53
+ ## Core Patterns
54
+
55
+ **Accumulate then emit:**
56
+
57
+ ```typescript
58
+ const log = getRequestLogger(ctx);
59
+ log.set({ cart: { items: body.items.length } });
60
+ log.set({ payment: { method: body.method } });
61
+ log.error(err, { step: 'payment' });
62
+ log.emitNow();
63
+ ```
64
+
65
+ **RequestLogSnapshot:** `emitNow()` returns `{ timestamp, traceId, spanId, correlationId, context }`. You can pass `onEmit` in options to forward it.
66
+
67
+ **Options:** `getRequestLogger(ctx?, { onEmit?: (snapshot) => void })` for custom fan-out on emit.
68
+
69
+ ## Common Mistakes
70
+
71
+ ### HIGH Call getRequestLogger() outside a span
72
+
73
+ Wrong:
74
+
75
+ ```typescript
76
+ function helper() {
77
+ const log = getRequestLogger();
78
+ log.set({ step: 'helper' });
79
+ }
80
+ ```
81
+
82
+ Correct:
83
+
84
+ ```typescript
85
+ // Ensure helper runs inside trace() or middleware-created span
86
+ function helper(ctx?: TraceContext) {
87
+ const log = getRequestLogger(ctx);
88
+ log.set({ step: 'helper' });
89
+ }
90
+ ```
91
+
92
+ Request logger attaches to the active span. If there is no active span, it throws. Wrap the call path with `trace()` or register middleware that creates a span per request.
93
+
94
+ Source: packages/autotel/src/request-logger.ts
95
+
96
+ ## Version
97
+
98
+ Targets autotel v2.23.x.
99
+
100
+ See also: autotel-instrumentation/SKILL.md — creating the span. autotel-structured-errors/SKILL.md — use .error() to record errors in the snapshot.
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: autotel-structured-errors
3
+ description: >
4
+ createStructuredError, parseError, recordStructuredError. API errors with message, why, fix, link; client parsing for UI. Use in API routes and client catch blocks.
5
+ type: core
6
+ library: autotel
7
+ library_version: '2.23.0'
8
+ sources:
9
+ - jagreehal/autotel:packages/autotel/src/structured-error.ts
10
+ - jagreehal/autotel:packages/autotel/src/parse-error.ts
11
+ - jagreehal/autotel:docs/AGENT-GUIDE.md
12
+ ---
13
+
14
+ # Autotel — Structured Errors
15
+
16
+ Throw errors with `createStructuredError({ message, why?, fix?, link?, status?, cause? })` in API routes and services. On the client, use `parseError(caught)` to get `message`, `status`, `why`, `fix`, `link` for toasts and UI.
17
+
18
+ ## Setup
19
+
20
+ **Server (API route or service):**
21
+
22
+ ```typescript
23
+ import { createStructuredError } from 'autotel';
24
+
25
+ if (!user) {
26
+ throw createStructuredError({
27
+ message: 'User not found',
28
+ status: 404,
29
+ why: `No user with ID "${userId}"`,
30
+ fix: 'Check the user ID and try again',
31
+ link: 'https://docs.example.com/errors/user-not-found',
32
+ });
33
+ }
34
+ ```
35
+
36
+ **Client:**
37
+
38
+ ```typescript
39
+ import { parseError } from 'autotel';
40
+
41
+ try {
42
+ await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(data) });
43
+ } catch (err) {
44
+ const e = parseError(err);
45
+ toast.error(e.message, { description: e.why });
46
+ if (e.fix) setHelp(e.fix);
47
+ if (e.link) setDocLink(e.link);
48
+ }
49
+ ```
50
+
51
+ ## Core Patterns
52
+
53
+ **Wrap a caught error (preserve cause):**
54
+
55
+ ```typescript
56
+ try {
57
+ await stripe.charges.create(data);
58
+ } catch (err) {
59
+ throw createStructuredError({
60
+ message: 'Payment failed',
61
+ status: 402,
62
+ why: err instanceof Error ? err.message : 'Unknown error',
63
+ fix: 'Try a different payment method or contact support',
64
+ link: 'https://docs.stripe.com/declines',
65
+ cause: err,
66
+ });
67
+ }
68
+ ```
69
+
70
+ **Record on current span:** Use `recordStructuredError(ctx, error)` or the request logger's `.error(error, fields)` so the span gets error attributes and status.
71
+
72
+ **parseError** handles FetchError (ofetch), nested `data.data`, and plain Error. Returns `{ message, status, why?, fix?, link?, raw }`.
73
+
74
+ ## Common Mistakes
75
+
76
+ ### HIGH Throw new Error() in API routes instead of createStructuredError
77
+
78
+ Wrong:
79
+
80
+ ```typescript
81
+ throw new Error('Payment failed');
82
+ ```
83
+
84
+ Correct:
85
+
86
+ ```typescript
87
+ throw createStructuredError({
88
+ message: 'Payment failed',
89
+ status: 402,
90
+ why: 'Card declined by issuer',
91
+ fix: 'Try a different payment method',
92
+ link: 'https://docs.example.com/payments',
93
+ });
94
+ ```
95
+
96
+ Clients and agents need structured fields (why, fix, link) for actionable errors. parseError() reads these from API responses.
97
+
98
+ Source: docs/AGENT-GUIDE.md, AGENTS.md
99
+
100
+ ### MEDIUM Client only shows error.message and ignores why/fix/link
101
+
102
+ Wrong:
103
+
104
+ ```typescript
105
+ catch (err) {
106
+ toast.error(err.message);
107
+ }
108
+ ```
109
+
110
+ Correct:
111
+
112
+ ```typescript
113
+ import { parseError } from 'autotel';
114
+ catch (err) {
115
+ const e = parseError(err);
116
+ toast.error(e.message, { description: e.why });
117
+ if (e.fix) showFix(e.fix);
118
+ if (e.link) setDocLink(e.link);
119
+ }
120
+ ```
121
+
122
+ parseError() extracts status, why, fix, and link from API error responses and FetchError so the UI can show them.
123
+
124
+ Source: docs/AGENT-GUIDE.md
125
+
126
+ ## Version
127
+
128
+ Targets autotel v2.23.x.
129
+
130
+ See also: autotel-request-logging/SKILL.md — use .error() to record errors in the request snapshot.
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createDrainPipeline } from './drain-pipeline';
3
+
4
+ describe('createDrainPipeline', () => {
5
+ it('batches by size and sends to drain', async () => {
6
+ const batchDrain = vi.fn(async () => {});
7
+ const pipeline = createDrainPipeline<number>({
8
+ batch: { size: 2, intervalMs: 1000 },
9
+ });
10
+ const drain = pipeline(batchDrain);
11
+
12
+ drain(1);
13
+ drain(2);
14
+ await new Promise((resolve) => setImmediate(resolve));
15
+
16
+ expect(batchDrain).toHaveBeenCalledTimes(1);
17
+ expect(batchDrain).toHaveBeenCalledWith([1, 2]);
18
+ expect(drain.pending).toBe(0);
19
+ });
20
+
21
+ it('retries failed batches and eventually succeeds', async () => {
22
+ let attempts = 0;
23
+ const batchDrain = vi.fn(async () => {
24
+ attempts++;
25
+ if (attempts < 2) throw new Error('temporary');
26
+ });
27
+
28
+ const pipeline = createDrainPipeline<number>({
29
+ batch: { size: 1, intervalMs: 1000 },
30
+ retry: {
31
+ maxAttempts: 3,
32
+ initialDelayMs: 1,
33
+ maxDelayMs: 2,
34
+ backoff: 'fixed',
35
+ jitter: false,
36
+ },
37
+ });
38
+ const drain = pipeline(batchDrain);
39
+
40
+ drain(42);
41
+ await drain.flush();
42
+
43
+ expect(batchDrain).toHaveBeenCalledTimes(2);
44
+ expect(drain.pending).toBe(0);
45
+ });
46
+
47
+ it('drops overflowed events based on policy', async () => {
48
+ const dropped: number[] = [];
49
+ const batchDrain = vi.fn(async () => {});
50
+ const pipeline = createDrainPipeline<number>({
51
+ batch: { size: 10, intervalMs: 1000 },
52
+ maxBufferSize: 2,
53
+ dropPolicy: 'oldest',
54
+ onDropped: (events) => dropped.push(...events),
55
+ });
56
+ const drain = pipeline(batchDrain);
57
+
58
+ drain(1);
59
+ drain(2);
60
+ drain(3); // drops 1
61
+
62
+ expect(dropped).toEqual([1]);
63
+ expect(drain.pending).toBe(2);
64
+
65
+ await drain.flush();
66
+ expect(batchDrain).toHaveBeenCalledWith([2, 3]);
67
+ });
68
+ });
@@ -0,0 +1,199 @@
1
+ export interface DrainPipelineOptions<T = unknown> {
2
+ batch?: {
3
+ /** Maximum events per batch. @default 50 */
4
+ size?: number;
5
+ /** Max time an event can stay buffered before flush. @default 5000 */
6
+ intervalMs?: number;
7
+ };
8
+ retry?: {
9
+ /** Total attempts including first try. @default 3 */
10
+ maxAttempts?: number;
11
+ /** Delay strategy between attempts. @default 'exponential' */
12
+ backoff?: 'exponential' | 'linear' | 'fixed';
13
+ /** Base delay for first retry. @default 1000 */
14
+ initialDelayMs?: number;
15
+ /** Max delay cap. @default 30000 */
16
+ maxDelayMs?: number;
17
+ /** Add random jitter to delays. @default true */
18
+ jitter?: boolean;
19
+ };
20
+ /** Max buffered events before dropping. @default 1000 */
21
+ maxBufferSize?: number;
22
+ /** Overflow policy. @default 'oldest' */
23
+ dropPolicy?: 'oldest' | 'newest';
24
+ /** Called when events are dropped from overflow or exhausted retries. */
25
+ onDropped?: (events: T[], error?: Error) => void;
26
+ }
27
+
28
+ export interface PipelineDrainFn<T> {
29
+ (ctx: T): void;
30
+ /** Flush all buffered events. */
31
+ flush: () => Promise<void>;
32
+ /** Flush and stop scheduling future timer work. */
33
+ shutdown: () => Promise<void>;
34
+ readonly pending: number;
35
+ }
36
+
37
+ function wait(ms: number): Promise<void> {
38
+ return new Promise((resolve) => {
39
+ const timer = setTimeout(resolve, ms);
40
+ timer.unref?.();
41
+ });
42
+ }
43
+
44
+ export function createDrainPipeline<T = unknown>(
45
+ options?: DrainPipelineOptions<T>,
46
+ ): (drain: (batch: T[]) => void | Promise<void>) => PipelineDrainFn<T> {
47
+ const batchSize = options?.batch?.size ?? 50;
48
+ const intervalMs = options?.batch?.intervalMs ?? 5000;
49
+ const maxBufferSize = options?.maxBufferSize ?? 1000;
50
+ const maxAttempts = options?.retry?.maxAttempts ?? 3;
51
+ const backoff = options?.retry?.backoff ?? 'exponential';
52
+ const initialDelayMs = options?.retry?.initialDelayMs ?? 1000;
53
+ const maxDelayMs = options?.retry?.maxDelayMs ?? 30_000;
54
+ const jitter = options?.retry?.jitter ?? true;
55
+ const dropPolicy = options?.dropPolicy ?? 'oldest';
56
+ const onDropped = options?.onDropped;
57
+
58
+ if (!Number.isFinite(batchSize) || batchSize <= 0) {
59
+ throw new Error(
60
+ `[autotel/drain-pipeline] batch.size must be a positive finite number, got: ${batchSize}`,
61
+ );
62
+ }
63
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
64
+ throw new Error(
65
+ `[autotel/drain-pipeline] batch.intervalMs must be a positive finite number, got: ${intervalMs}`,
66
+ );
67
+ }
68
+ if (!Number.isFinite(maxBufferSize) || maxBufferSize <= 0) {
69
+ throw new Error(
70
+ `[autotel/drain-pipeline] maxBufferSize must be a positive finite number, got: ${maxBufferSize}`,
71
+ );
72
+ }
73
+ if (!Number.isFinite(maxAttempts) || maxAttempts <= 0) {
74
+ throw new Error(
75
+ `[autotel/drain-pipeline] retry.maxAttempts must be a positive finite number, got: ${maxAttempts}`,
76
+ );
77
+ }
78
+
79
+ return (drain: (batch: T[]) => void | Promise<void>): PipelineDrainFn<T> => {
80
+ const buffer: T[] = [];
81
+ let timer: ReturnType<typeof setTimeout> | null = null;
82
+ let activeFlush: Promise<void> | null = null;
83
+ let isShutdown = false;
84
+
85
+ const clearTimer = () => {
86
+ if (timer) {
87
+ clearTimeout(timer);
88
+ timer = null;
89
+ }
90
+ };
91
+
92
+ const computeDelay = (attempt: number): number => {
93
+ const base =
94
+ backoff === 'fixed'
95
+ ? initialDelayMs
96
+ : backoff === 'linear'
97
+ ? initialDelayMs * attempt
98
+ : initialDelayMs * 2 ** (attempt - 1);
99
+
100
+ const bounded = Math.min(base, maxDelayMs);
101
+ if (!jitter || bounded <= 0) return bounded;
102
+ const factor = 0.5 + Math.random(); // [0.5, 1.5)
103
+ return Math.max(0, Math.round(bounded * factor));
104
+ };
105
+
106
+ const sendWithRetry = async (batch: T[]): Promise<void> => {
107
+ let lastError: Error | undefined;
108
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
109
+ try {
110
+ await drain(batch);
111
+ return;
112
+ } catch (error) {
113
+ lastError = error instanceof Error ? error : new Error(String(error));
114
+ if (attempt < maxAttempts) {
115
+ await wait(computeDelay(attempt));
116
+ }
117
+ }
118
+ }
119
+ onDropped?.(batch, lastError);
120
+ };
121
+
122
+ const drainBuffer = async (): Promise<void> => {
123
+ while (buffer.length > 0) {
124
+ const batch = buffer.splice(0, batchSize);
125
+ await sendWithRetry(batch);
126
+ }
127
+ };
128
+
129
+ const scheduleFlush = () => {
130
+ if (isShutdown || timer || activeFlush) return;
131
+ timer = setTimeout(() => {
132
+ timer = null;
133
+ startFlush();
134
+ }, intervalMs);
135
+ timer.unref?.();
136
+ };
137
+
138
+ const startFlush = () => {
139
+ if (activeFlush || isShutdown) return;
140
+ activeFlush = drainBuffer().finally(() => {
141
+ activeFlush = null;
142
+ if (isShutdown) return;
143
+ if (buffer.length >= batchSize) {
144
+ startFlush();
145
+ } else if (buffer.length > 0) {
146
+ scheduleFlush();
147
+ }
148
+ });
149
+ };
150
+
151
+ const push = (ctx: T) => {
152
+ if (isShutdown) return;
153
+
154
+ if (buffer.length >= maxBufferSize) {
155
+ if (dropPolicy === 'newest') {
156
+ onDropped?.([ctx]);
157
+ return;
158
+ }
159
+ const dropped = buffer.splice(0, 1);
160
+ onDropped?.(dropped);
161
+ }
162
+
163
+ buffer.push(ctx);
164
+ if (buffer.length >= batchSize) {
165
+ clearTimer();
166
+ startFlush();
167
+ } else {
168
+ scheduleFlush();
169
+ }
170
+ };
171
+
172
+ const flush = async (): Promise<void> => {
173
+ clearTimer();
174
+ if (activeFlush) await activeFlush;
175
+
176
+ const snapshot = buffer.length;
177
+ if (snapshot <= 0) return;
178
+ const toFlush = buffer.splice(0, snapshot);
179
+ while (toFlush.length > 0) {
180
+ const batch = toFlush.splice(0, batchSize);
181
+ await sendWithRetry(batch);
182
+ }
183
+ };
184
+
185
+ const shutdown = async (): Promise<void> => {
186
+ isShutdown = true;
187
+ await flush();
188
+ };
189
+
190
+ const fn = push as PipelineDrainFn<T>;
191
+ fn.flush = flush;
192
+ fn.shutdown = shutdown;
193
+ Object.defineProperty(fn, 'pending', {
194
+ enumerable: true,
195
+ get: () => buffer.length,
196
+ });
197
+ return fn;
198
+ };
199
+ }