autotel-subscribers 40.0.0 → 41.0.0

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.
@@ -1,433 +0,0 @@
1
- /**
2
- * EventSubscriber - Standard base class for building custom subscribers
3
- *
4
- * This is the recommended base class for creating custom events subscribers.
5
- * It provides production-ready features out of the box:
6
- *
7
- * **Built-in Features:**
8
- * - **Error Handling**: Automatic error catching with customizable handlers
9
- * - **Pending Request Tracking**: Ensures all requests complete during shutdown
10
- * - **Graceful Shutdown**: Drains pending requests before closing
11
- * - **Enable/Disable**: Runtime control to turn subscriber on/off
12
- * - **Normalized Payload**: Consistent event structure across all event types
13
- *
14
- * **When to use:**
15
- * - Building custom subscribers for any platform
16
- * - Production deployments requiring reliability
17
- * - Need graceful shutdown and error handling
18
- *
19
- * @example Basic usage
20
- * ```typescript
21
- * import { EventSubscriber, EventPayload } from 'autotel-subscribers';
22
- *
23
- * class SnowflakeSubscriber extends EventSubscriber {
24
- * name = 'SnowflakeSubscriber';
25
- * version = '1.0.0';
26
- *
27
- * protected async sendToDestination(payload: EventPayload): Promise<void> {
28
- * await snowflakeClient.execute(
29
- * `INSERT INTO events VALUES (?, ?, ?)`,
30
- * [payload.type, payload.name, JSON.stringify(payload.attributes)]
31
- * );
32
- * }
33
- * }
34
- * ```
35
- *
36
- * @example With buffering
37
- * ```typescript
38
- * class BufferedSubscriber extends EventSubscriber {
39
- * name = 'BufferedSubscriber';
40
- * private buffer: EventPayload[] = [];
41
- *
42
- * protected async sendToDestination(payload: EventPayload): Promise<void> {
43
- * this.buffer.push(payload);
44
- *
45
- * if (this.buffer.length >= 100) {
46
- * await this.flush();
47
- * }
48
- * }
49
- *
50
- * async shutdown(): Promise<void> {
51
- * await super.shutdown(); // Drain pending requests first
52
- * await this.flush(); // Then flush buffer
53
- * }
54
- *
55
- * private async flush(): Promise<void> {
56
- * if (this.buffer.length === 0) return;
57
- *
58
- * const batch = [...this.buffer];
59
- * this.buffer = [];
60
- *
61
- * await apiClient.sendBatch(batch);
62
- * }
63
- * }
64
- * ```
65
- */
66
-
67
- import type {
68
- EventSubscriber as IEventSubscriber,
69
- EventAttributes,
70
- EventAttributesInput,
71
- FunnelStatus,
72
- OutcomeStatus,
73
- AutotelEventContext,
74
- EventTrackingOptions,
75
- } from 'autotel/event-subscriber';
76
-
77
- // Re-export types for convenience
78
- export type { AutotelEventContext, EventTrackingOptions } from 'autotel/event-subscriber';
79
-
80
- /**
81
- * Payload sent to destination
82
- */
83
- export interface EventPayload {
84
- /** Event type: 'event', 'funnel', 'outcome', or 'value' */
85
- type: 'event' | 'funnel' | 'outcome' | 'value';
86
-
87
- /** Event name or metric name */
88
- name: string;
89
-
90
- /** Optional attributes */
91
- attributes?: EventAttributes;
92
-
93
- /** For funnel events: funnel name */
94
- funnel?: string;
95
-
96
- /** For funnel events: step status (from FunnelStatus enum) */
97
- step?: FunnelStatus | string;
98
-
99
- /** For funnel events: custom step name (from trackFunnelProgression) */
100
- stepName?: string;
101
-
102
- /** For funnel events: numeric position in funnel */
103
- stepNumber?: number;
104
-
105
- /** For outcome events: operation name */
106
- operation?: string;
107
-
108
- /** For outcome events: outcome status */
109
- outcome?: OutcomeStatus;
110
-
111
- /** For value events: numeric value */
112
- value?: number;
113
-
114
- /** Timestamp (ISO 8601) */
115
- timestamp: string;
116
-
117
- /**
118
- * Autotel trace context (present when events.includeTraceContext is enabled)
119
- *
120
- * Subscribers should map these to platform-specific field names:
121
- * - PostHog: autotel.trace_id → $trace_id
122
- * - Mixpanel: autotel.trace_id → trace_id
123
- */
124
- autotel?: AutotelEventContext;
125
- /** Optional schema metadata for contract-aware subscribers. */
126
- schema?: EventTrackingOptions['schema'];
127
- }
128
-
129
- /**
130
- * Standard base class for building custom events subscribers
131
- *
132
- * **What it provides:**
133
- * - Consistent payload structure (normalized across all event types)
134
- * - Enable/disable flag (runtime control)
135
- * - Automatic error handling (with customizable error handlers)
136
- * - Pending requests tracking (ensures no lost events during shutdown)
137
- * - Graceful shutdown (drains pending requests before closing)
138
- *
139
- * **Usage:**
140
- * Extend this class and implement `sendToDestination()`. All other methods
141
- * (trackEvent, trackFunnelStep, trackOutcome, trackValue, shutdown) are handled automatically.
142
- *
143
- * For high-throughput streaming platforms (Kafka, Kinesis, Pub/Sub), use `StreamingEventSubscriber` instead.
144
- */
145
- export abstract class EventSubscriber implements IEventSubscriber {
146
- /**
147
- * Subscriber name (required for debugging)
148
- */
149
- abstract readonly name: string;
150
-
151
- /**
152
- * Subscriber version (optional)
153
- */
154
- readonly version?: string;
155
-
156
- /**
157
- * Enable/disable the subscriber (default: true)
158
- */
159
- protected enabled: boolean = true;
160
-
161
- /**
162
- * Track pending requests for graceful shutdown
163
- */
164
- private pendingRequests: Set<Promise<void>> = new Set();
165
-
166
- /**
167
- * Send payload to destination
168
- *
169
- * Override this method to implement your destination-specific logic.
170
- * This is called for all event types (event, funnel, outcome, value).
171
- *
172
- * @param payload - Normalized event payload
173
- */
174
- protected abstract sendToDestination(payload: EventPayload): Promise<void>;
175
-
176
- /**
177
- * Optional: Handle errors
178
- *
179
- * Override this to customize error handling (logging, retries, etc.).
180
- * Default behavior: log to console.error
181
- *
182
- * @param error - Error that occurred
183
- * @param payload - Event payload that failed
184
- */
185
- protected handleError(error: Error, payload: EventPayload): void {
186
- console.error(
187
- `[${this.name}] Failed to send ${payload.type}:`,
188
- error,
189
- payload,
190
- );
191
- }
192
-
193
- /**
194
- * Filter out undefined and null values from attributes
195
- *
196
- * This improves DX by allowing callers to pass objects with optional properties
197
- * without having to manually filter them first.
198
- *
199
- * @param attributes - Input attributes (may contain undefined/null)
200
- * @returns Filtered attributes with only defined values, or undefined if empty
201
- *
202
- * @example
203
- * ```typescript
204
- * const filtered = this.filterAttributes({
205
- * userId: user.id,
206
- * email: user.email, // might be undefined
207
- * plan: null, // will be filtered out
208
- * });
209
- * // Result: { userId: 'abc', email: 'test@example.com' } or { userId: 'abc' }
210
- * ```
211
- */
212
- protected filterAttributes(
213
- attributes?: EventAttributesInput,
214
- ): EventAttributes | undefined {
215
- if (!attributes) return undefined;
216
-
217
- const filtered: EventAttributes = {};
218
- for (const [key, value] of Object.entries(attributes)) {
219
- if (value !== undefined && value !== null) {
220
- filtered[key] = value;
221
- }
222
- }
223
-
224
- // Return undefined if no attributes remain after filtering
225
- return Object.keys(filtered).length > 0 ? filtered : undefined;
226
- }
227
-
228
- /**
229
- * Track an event
230
- */
231
- async trackEvent(
232
- name: string,
233
- attributes?: EventAttributes,
234
- options?: EventTrackingOptions,
235
- ): Promise<void> {
236
- if (!this.enabled) return;
237
-
238
- const payload: EventPayload = {
239
- type: 'event',
240
- name,
241
- attributes,
242
- timestamp: new Date().toISOString(),
243
- autotel: options?.autotel,
244
- schema: options?.schema,
245
- };
246
-
247
- await this.send(payload);
248
- }
249
-
250
- /**
251
- * Track a funnel step
252
- */
253
- async trackFunnelStep(
254
- funnelName: string,
255
- step: FunnelStatus,
256
- attributes?: EventAttributes,
257
- options?: EventTrackingOptions,
258
- ): Promise<void> {
259
- if (!this.enabled) return;
260
-
261
- const payload: EventPayload = {
262
- type: 'funnel',
263
- name: `${funnelName}.${step}`,
264
- funnel: funnelName,
265
- step,
266
- attributes,
267
- timestamp: new Date().toISOString(),
268
- autotel: options?.autotel,
269
- };
270
-
271
- await this.send(payload);
272
- }
273
-
274
- /**
275
- * Track an outcome
276
- */
277
- async trackOutcome(
278
- operationName: string,
279
- outcome: OutcomeStatus,
280
- attributes?: EventAttributes,
281
- options?: EventTrackingOptions,
282
- ): Promise<void> {
283
- if (!this.enabled) return;
284
-
285
- const payload: EventPayload = {
286
- type: 'outcome',
287
- name: `${operationName}.${outcome}`,
288
- operation: operationName,
289
- outcome,
290
- attributes,
291
- timestamp: new Date().toISOString(),
292
- autotel: options?.autotel,
293
- };
294
-
295
- await this.send(payload);
296
- }
297
-
298
- /**
299
- * Track a value/metric
300
- */
301
- async trackValue(
302
- name: string,
303
- value: number,
304
- attributes?: EventAttributes,
305
- options?: EventTrackingOptions,
306
- ): Promise<void> {
307
- if (!this.enabled) return;
308
-
309
- const payload: EventPayload = {
310
- type: 'value',
311
- name,
312
- value,
313
- attributes,
314
- timestamp: new Date().toISOString(),
315
- autotel: options?.autotel,
316
- };
317
-
318
- await this.send(payload);
319
- }
320
-
321
- /**
322
- * Track funnel progression with custom step names
323
- *
324
- * Unlike trackFunnelStep which uses FunnelStatus enum values,
325
- * this method allows any string as the step name for flexible funnel tracking.
326
- *
327
- * @param funnelName - Name of the funnel (e.g., "checkout", "onboarding")
328
- * @param stepName - Custom step name (e.g., "cart_viewed", "payment_entered")
329
- * @param stepNumber - Optional numeric position in the funnel
330
- * @param attributes - Optional event attributes
331
- * @param options - Optional tracking options including autotel context
332
- */
333
- async trackFunnelProgression(
334
- funnelName: string,
335
- stepName: string,
336
- stepNumber?: number,
337
- attributes?: EventAttributes,
338
- options?: EventTrackingOptions,
339
- ): Promise<void> {
340
- if (!this.enabled) return;
341
-
342
- const payload: EventPayload = {
343
- type: 'funnel',
344
- name: `${funnelName}.${stepName}`,
345
- funnel: funnelName,
346
- step: stepName,
347
- stepName,
348
- stepNumber,
349
- attributes,
350
- timestamp: new Date().toISOString(),
351
- autotel: options?.autotel,
352
- };
353
-
354
- await this.send(payload);
355
- }
356
-
357
- /**
358
- * Flush pending requests and clean up
359
- *
360
- * CRITICAL: Prevents race condition during shutdown
361
- * 1. Disables subscriber to stop new events
362
- * 2. Drains all pending requests (with retry logic)
363
- * 3. Ensures flush guarantee
364
- *
365
- * Override this if you need custom cleanup logic (close connections, flush buffers, etc.),
366
- * but ALWAYS call super.shutdown() first to drain pending requests.
367
- */
368
- async shutdown(): Promise<void> {
369
- // 1. Stop accepting new events (prevents race condition)
370
- this.enabled = false;
371
-
372
- // 2. Drain pending requests with retry logic
373
- // Loop until empty to handle race where new requests added during Promise.allSettled
374
- const maxDrainAttempts = 10;
375
- const drainIntervalMs = 50;
376
-
377
- for (let attempt = 0; attempt < maxDrainAttempts; attempt++) {
378
- if (this.pendingRequests.size === 0) {
379
- break;
380
- }
381
-
382
- // Wait for current batch
383
- await Promise.allSettled(this.pendingRequests);
384
-
385
- // Small delay to catch any stragglers added during allSettled
386
- if (this.pendingRequests.size > 0 && attempt < maxDrainAttempts - 1) {
387
- await new Promise((resolve) => setTimeout(resolve, drainIntervalMs));
388
- }
389
- }
390
-
391
- // 3. Warn if we still have pending requests (shouldn't happen, but be defensive)
392
- if (this.pendingRequests.size > 0) {
393
- console.warn(
394
- `[${this.name}] Shutdown completed with ${this.pendingRequests.size} pending requests still in-flight. ` +
395
- `This may indicate a bug in the subscriber or extremely slow destination.`
396
- );
397
- }
398
- }
399
-
400
- /**
401
- * Internal: Send payload and track request
402
- */
403
- private async send(payload: EventPayload): Promise<void> {
404
- const request = this.sendWithErrorHandling(payload);
405
- this.pendingRequests.add(request);
406
-
407
- void request.finally(() => {
408
- this.pendingRequests.delete(request);
409
- });
410
-
411
- return request;
412
- }
413
-
414
- /**
415
- * Internal: Send with error handling
416
- */
417
- private async sendWithErrorHandling(
418
- payload: EventPayload,
419
- ): Promise<void> {
420
- try {
421
- await this.sendToDestination(payload);
422
- } catch (error) {
423
- this.handleError(error as Error, payload);
424
- }
425
- }
426
- }
427
-
428
- export {
429
- type EventAttributes,
430
- type EventAttributesInput,
431
- type FunnelStatus,
432
- type OutcomeStatus,
433
- } from 'autotel/event-subscriber';
@@ -1,107 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { composeSubscribers } from './factories';
3
- import type {
4
- EventSubscriber,
5
- EventAttributes,
6
- FunnelStatus,
7
- OutcomeStatus,
8
- EventTrackingOptions,
9
- } from 'autotel/event-subscriber';
10
-
11
- class StubSubscriber implements EventSubscriber {
12
- readonly name: string;
13
- private readonly failUntil: number;
14
- public calls = 0;
15
-
16
- constructor(name: string, failUntil = 0) {
17
- this.name = name;
18
- this.failUntil = failUntil;
19
- }
20
-
21
- private async run(): Promise<void> {
22
- this.calls += 1;
23
- if (this.calls <= this.failUntil) {
24
- throw new Error(`${this.name} failed`);
25
- }
26
- }
27
-
28
- async trackEvent(_name: string, _attributes?: EventAttributes, _options?: EventTrackingOptions): Promise<void> {
29
- await this.run();
30
- }
31
- async trackFunnelStep(_funnel: string, _step: FunnelStatus, _attributes?: EventAttributes, _options?: EventTrackingOptions): Promise<void> {
32
- await this.run();
33
- }
34
- async trackOutcome(_operation: string, _outcome: OutcomeStatus, _attributes?: EventAttributes, _options?: EventTrackingOptions): Promise<void> {
35
- await this.run();
36
- }
37
- async trackValue(_name: string, _value: number, _attributes?: EventAttributes, _options?: EventTrackingOptions): Promise<void> {
38
- await this.run();
39
- }
40
- }
41
-
42
- describe('composeSubscribers strategies', () => {
43
- it('parallel requires all subscribers to succeed', async () => {
44
- const a = new StubSubscriber('a');
45
- const b = new StubSubscriber('b', 1);
46
-
47
- const composed = composeSubscribers([a, b], { strategy: 'parallel' });
48
- await expect(composed.trackEvent('x')).rejects.toThrow();
49
- });
50
-
51
- it('failover succeeds on next healthy subscriber', async () => {
52
- const a = new StubSubscriber('a', 1);
53
- const b = new StubSubscriber('b');
54
-
55
- const composed = composeSubscribers([a, b], { strategy: 'failover' });
56
- await composed.trackEvent('x');
57
-
58
- expect(a.calls).toBe(1);
59
- expect(b.calls).toBe(1);
60
- });
61
-
62
- it('race succeeds if any subscriber succeeds', async () => {
63
- const a = new StubSubscriber('a', 1);
64
- const b = new StubSubscriber('b');
65
-
66
- const composed = composeSubscribers([a, b], { strategy: 'race' });
67
- await expect(composed.trackEvent('x')).resolves.toBeUndefined();
68
- });
69
-
70
- it('mirrored returns based on primary subscriber only', async () => {
71
- const a = new StubSubscriber('a');
72
- const b = new StubSubscriber('b', 10);
73
-
74
- const composed = composeSubscribers([a, b], { strategy: 'mirrored' });
75
- await expect(composed.trackEvent('x')).resolves.toBeUndefined();
76
- expect(a.calls).toBe(1);
77
- });
78
-
79
- it('round-robin rotates starting subscriber', async () => {
80
- const a = new StubSubscriber('a');
81
- const b = new StubSubscriber('b');
82
-
83
- const composed = composeSubscribers([a, b], {
84
- strategy: 'round-robin',
85
- maxAttemptsPerSubscriber: 1,
86
- });
87
-
88
- await composed.trackEvent('x');
89
- await composed.trackEvent('x');
90
-
91
- expect(a.calls).toBe(1);
92
- expect(b.calls).toBe(1);
93
- });
94
-
95
- it('retries subscriber when retriable', async () => {
96
- const a = new StubSubscriber('a', 1);
97
- const composed = composeSubscribers([a], {
98
- strategy: 'failover',
99
- maxAttemptsPerSubscriber: 2,
100
- initialRetryDelayMs: 1,
101
- maxRetryDelayMs: 1,
102
- });
103
-
104
- await composed.trackEvent('x');
105
- expect(a.calls).toBe(2);
106
- });
107
- });