autotel-subscribers 4.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +669 -0
  3. package/dist/amplitude.cjs +2486 -0
  4. package/dist/amplitude.cjs.map +1 -0
  5. package/dist/amplitude.d.cts +49 -0
  6. package/dist/amplitude.d.ts +49 -0
  7. package/dist/amplitude.js +2463 -0
  8. package/dist/amplitude.js.map +1 -0
  9. package/dist/event-subscriber-base-CnF3V56W.d.cts +182 -0
  10. package/dist/event-subscriber-base-CnF3V56W.d.ts +182 -0
  11. package/dist/factories.cjs +16660 -0
  12. package/dist/factories.cjs.map +1 -0
  13. package/dist/factories.d.cts +304 -0
  14. package/dist/factories.d.ts +304 -0
  15. package/dist/factories.js +16624 -0
  16. package/dist/factories.js.map +1 -0
  17. package/dist/index.cjs +16575 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.cts +179 -0
  20. package/dist/index.d.ts +179 -0
  21. package/dist/index.js +16539 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/middleware.cjs +220 -0
  24. package/dist/middleware.cjs.map +1 -0
  25. package/dist/middleware.d.cts +227 -0
  26. package/dist/middleware.d.ts +227 -0
  27. package/dist/middleware.js +208 -0
  28. package/dist/middleware.js.map +1 -0
  29. package/dist/mixpanel.cjs +2940 -0
  30. package/dist/mixpanel.cjs.map +1 -0
  31. package/dist/mixpanel.d.cts +47 -0
  32. package/dist/mixpanel.d.ts +47 -0
  33. package/dist/mixpanel.js +2932 -0
  34. package/dist/mixpanel.js.map +1 -0
  35. package/dist/posthog.cjs +4115 -0
  36. package/dist/posthog.cjs.map +1 -0
  37. package/dist/posthog.d.cts +299 -0
  38. package/dist/posthog.d.ts +299 -0
  39. package/dist/posthog.js +4113 -0
  40. package/dist/posthog.js.map +1 -0
  41. package/dist/segment.cjs +6822 -0
  42. package/dist/segment.cjs.map +1 -0
  43. package/dist/segment.d.cts +49 -0
  44. package/dist/segment.d.ts +49 -0
  45. package/dist/segment.js +6794 -0
  46. package/dist/segment.js.map +1 -0
  47. package/dist/slack.cjs +368 -0
  48. package/dist/slack.cjs.map +1 -0
  49. package/dist/slack.d.cts +126 -0
  50. package/dist/slack.d.ts +126 -0
  51. package/dist/slack.js +366 -0
  52. package/dist/slack.js.map +1 -0
  53. package/dist/webhook.cjs +100 -0
  54. package/dist/webhook.cjs.map +1 -0
  55. package/dist/webhook.d.cts +53 -0
  56. package/dist/webhook.d.ts +53 -0
  57. package/dist/webhook.js +98 -0
  58. package/dist/webhook.js.map +1 -0
  59. package/examples/quickstart-custom-subscriber.ts +144 -0
  60. package/examples/subscriber-bigquery.ts +219 -0
  61. package/examples/subscriber-databricks.ts +280 -0
  62. package/examples/subscriber-kafka.ts +326 -0
  63. package/examples/subscriber-kinesis.ts +307 -0
  64. package/examples/subscriber-posthog.ts +421 -0
  65. package/examples/subscriber-pubsub.ts +336 -0
  66. package/examples/subscriber-snowflake.ts +232 -0
  67. package/package.json +141 -0
  68. package/src/amplitude.test.ts +231 -0
  69. package/src/amplitude.ts +148 -0
  70. package/src/event-subscriber-base.ts +325 -0
  71. package/src/factories.ts +197 -0
  72. package/src/index.ts +50 -0
  73. package/src/middleware.ts +489 -0
  74. package/src/mixpanel.test.ts +194 -0
  75. package/src/mixpanel.ts +134 -0
  76. package/src/mock-event-subscriber.ts +333 -0
  77. package/src/posthog.test.ts +629 -0
  78. package/src/posthog.ts +530 -0
  79. package/src/segment.test.ts +228 -0
  80. package/src/segment.ts +148 -0
  81. package/src/slack.ts +383 -0
  82. package/src/streaming-event-subscriber.ts +323 -0
  83. package/src/testing/index.ts +37 -0
  84. package/src/testing/mock-webhook-server.ts +242 -0
  85. package/src/testing/subscriber-test-harness.ts +365 -0
  86. package/src/webhook.test.ts +264 -0
  87. package/src/webhook.ts +158 -0
@@ -0,0 +1,231 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { AmplitudeSubscriber } from './amplitude';
3
+
4
+ // Mock the @amplitude/analytics-node module
5
+ const mockTrack = vi.fn();
6
+ const mockTrackEvent = vi.fn();
7
+ const mockFlush = vi.fn(() => Promise.resolve());
8
+
9
+ // Create a mock init function that returns instances with mocked methods
10
+ const mockInit = vi.fn(() => ({
11
+ track: mockTrack,
12
+ trackEvent: mockTrackEvent,
13
+ flush: mockFlush,
14
+ }));
15
+
16
+ vi.mock('@amplitude/analytics-node', () => ({
17
+ init: mockInit,
18
+ }));
19
+
20
+ describe('AmplitudeSubscriber', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ mockTrack.mockClear();
24
+ mockTrackEvent.mockClear();
25
+ mockFlush.mockClear();
26
+ });
27
+
28
+ describe('initialization', () => {
29
+ it('should initialize with valid config', async () => {
30
+ const adapter = new AmplitudeSubscriber({
31
+ apiKey: 'test_api_key',
32
+ });
33
+
34
+ await new Promise((resolve) => setTimeout(resolve, 100));
35
+
36
+ expect(adapter).toBeDefined();
37
+ });
38
+
39
+ it('should not initialize when disabled', () => {
40
+ const adapter = new AmplitudeSubscriber({
41
+ apiKey: 'test_api_key',
42
+ enabled: false,
43
+ });
44
+
45
+ expect(adapter).toBeDefined();
46
+ });
47
+ });
48
+
49
+ describe('trackEvent', () => {
50
+ it('should track event with attributes', async () => {
51
+ const adapter = new AmplitudeSubscriber({
52
+ apiKey: 'test_api_key',
53
+ });
54
+
55
+ await new Promise((resolve) => setTimeout(resolve, 100));
56
+
57
+ await adapter.trackEvent('order.completed', {
58
+ userId: 'user-123',
59
+ amount: 99.99,
60
+ });
61
+
62
+ await new Promise((resolve) => setTimeout(resolve, 100));
63
+
64
+ expect(mockTrack).toHaveBeenCalledWith({
65
+ event_type: 'order.completed',
66
+ user_id: 'user-123',
67
+ event_properties: {
68
+ userId: 'user-123',
69
+ amount: 99.99,
70
+ },
71
+ });
72
+ });
73
+
74
+ it('should use user_id if userId is not present', async () => {
75
+ const adapter = new AmplitudeSubscriber({
76
+ apiKey: 'test_api_key',
77
+ });
78
+
79
+ await new Promise((resolve) => setTimeout(resolve, 100));
80
+
81
+ await adapter.trackEvent('order.completed', {
82
+ user_id: 'user-456',
83
+ });
84
+
85
+ await new Promise((resolve) => setTimeout(resolve, 100));
86
+
87
+ expect(mockTrack).toHaveBeenCalledWith({
88
+ event_type: 'order.completed',
89
+ user_id: 'user-456',
90
+ event_properties: {
91
+ user_id: 'user-456',
92
+ },
93
+ });
94
+ });
95
+
96
+ it('should use anonymous if no userId is present', async () => {
97
+ const adapter = new AmplitudeSubscriber({
98
+ apiKey: 'test_api_key',
99
+ });
100
+
101
+ await new Promise((resolve) => setTimeout(resolve, 100));
102
+
103
+ await adapter.trackEvent('page.viewed');
104
+
105
+ await new Promise((resolve) => setTimeout(resolve, 100));
106
+
107
+ expect(mockTrack).toHaveBeenCalledWith({
108
+ event_type: 'page.viewed',
109
+ user_id: 'anonymous',
110
+ event_properties: undefined,
111
+ });
112
+ });
113
+
114
+ it('should not track when disabled', () => {
115
+ const adapter = new AmplitudeSubscriber({
116
+ apiKey: 'test_api_key',
117
+ enabled: false,
118
+ });
119
+
120
+ adapter.trackEvent('order.completed', { userId: 'user-123' });
121
+
122
+ // Should not throw
123
+ expect(true).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('trackFunnelStep', () => {
128
+ it('should track funnel step', async () => {
129
+ const adapter = new AmplitudeSubscriber({
130
+ apiKey: 'test_api_key',
131
+ });
132
+
133
+ await new Promise((resolve) => setTimeout(resolve, 100));
134
+
135
+ await adapter.trackFunnelStep('checkout', 'started', {
136
+ userId: 'user-123',
137
+ cartValue: 150,
138
+ });
139
+
140
+ await new Promise((resolve) => setTimeout(resolve, 100));
141
+
142
+ expect(mockTrackEvent).toHaveBeenCalledWith({
143
+ event_type: 'checkout.started',
144
+ user_id: 'user-123',
145
+ event_properties: {
146
+ funnel: 'checkout',
147
+ step: 'started',
148
+ userId: 'user-123',
149
+ cartValue: 150,
150
+ },
151
+ });
152
+ });
153
+ });
154
+
155
+ describe('trackOutcome', () => {
156
+ it('should track outcome', async () => {
157
+ const adapter = new AmplitudeSubscriber({
158
+ apiKey: 'test_api_key',
159
+ });
160
+
161
+ await new Promise((resolve) => setTimeout(resolve, 100));
162
+
163
+ await adapter.trackOutcome('payment.processing', 'success', {
164
+ userId: 'user-123',
165
+ transactionId: 'txn-789',
166
+ });
167
+
168
+ await new Promise((resolve) => setTimeout(resolve, 100));
169
+
170
+ expect(mockTrackEvent).toHaveBeenCalledWith({
171
+ event_type: 'payment.processing.success',
172
+ user_id: 'user-123',
173
+ event_properties: {
174
+ operation: 'payment.processing',
175
+ outcome: 'success',
176
+ userId: 'user-123',
177
+ transactionId: 'txn-789',
178
+ },
179
+ });
180
+ });
181
+ });
182
+
183
+ describe('trackValue', () => {
184
+ it('should track value', async () => {
185
+ const adapter = new AmplitudeSubscriber({
186
+ apiKey: 'test_api_key',
187
+ });
188
+
189
+ await new Promise((resolve) => setTimeout(resolve, 100));
190
+
191
+ await adapter.trackValue('revenue', 99.99, {
192
+ userId: 'user-123',
193
+ currency: 'USD',
194
+ });
195
+
196
+ await new Promise((resolve) => setTimeout(resolve, 100));
197
+
198
+ expect(mockTrackEvent).toHaveBeenCalledWith({
199
+ event_type: 'revenue',
200
+ user_id: 'user-123',
201
+ event_properties: {
202
+ value: 99.99,
203
+ userId: 'user-123',
204
+ currency: 'USD',
205
+ },
206
+ });
207
+ });
208
+ });
209
+
210
+ describe('shutdown', () => {
211
+ it('should call flush on Amplitude instance', async () => {
212
+ const adapter = new AmplitudeSubscriber({
213
+ apiKey: 'test_api_key',
214
+ });
215
+
216
+ await new Promise((resolve) => setTimeout(resolve, 100));
217
+ await adapter.shutdown();
218
+
219
+ expect(mockFlush).toHaveBeenCalled();
220
+ });
221
+
222
+ it('should not throw when shutting down disabled adapter', async () => {
223
+ const adapter = new AmplitudeSubscriber({
224
+ apiKey: 'test_api_key',
225
+ enabled: false,
226
+ });
227
+
228
+ await expect(adapter.shutdown()).resolves.not.toThrow();
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Amplitude Subscriber for autotel
3
+ *
4
+ * Send events to Amplitude for product events.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { Events } from 'autotel/events';
9
+ * import { AmplitudeSubscriber } from 'autotel-subscribers/amplitude';
10
+ *
11
+ * const events = new Events('checkout', {
12
+ * subscribers: [
13
+ * new AmplitudeSubscriber({
14
+ * apiKey: process.env.AMPLITUDE_API_KEY!
15
+ * })
16
+ * ]
17
+ * });
18
+ *
19
+ * events.trackEvent('order.completed', { userId: '123', amount: 99.99 });
20
+ * ```
21
+ */
22
+
23
+ import type {
24
+ EventSubscriber,
25
+ EventAttributes,
26
+ FunnelStatus,
27
+ OutcomeStatus,
28
+ } from 'autotel/event-subscriber';
29
+
30
+ export interface AmplitudeConfig {
31
+ /** Amplitude API key */
32
+ apiKey: string;
33
+ /** Enable/disable the subscriber */
34
+ enabled?: boolean;
35
+ }
36
+
37
+ export class AmplitudeSubscriber implements EventSubscriber {
38
+ readonly name = 'AmplitudeSubscriber';
39
+ readonly version = '1.0.0';
40
+
41
+ private amplitude: any;
42
+ private enabled: boolean;
43
+ private config: AmplitudeConfig;
44
+ private initPromise: Promise<void> | null = null;
45
+
46
+ constructor(config: AmplitudeConfig) {
47
+ this.enabled = config.enabled ?? true;
48
+ this.config = config;
49
+
50
+ if (this.enabled) {
51
+ // Start initialization immediately but don't block constructor
52
+ this.initPromise = this.initialize();
53
+ }
54
+ }
55
+
56
+ private async initialize(): Promise<void> {
57
+ try {
58
+ // Dynamic import to avoid adding @amplitude/events-node as a hard dependency
59
+ const { init } = await import('@amplitude/analytics-node');
60
+ this.amplitude = init(this.config.apiKey);
61
+ } catch (error) {
62
+ console.error(
63
+ 'Amplitude subscriber failed to initialize. Install @amplitude/events-node: pnpm add @amplitude/events-node',
64
+ error,
65
+ );
66
+ this.enabled = false;
67
+ }
68
+ }
69
+
70
+ private async ensureInitialized(): Promise<void> {
71
+ if (this.initPromise) {
72
+ await this.initPromise;
73
+ this.initPromise = null;
74
+ }
75
+ }
76
+
77
+ async trackEvent(name: string, attributes?: EventAttributes): Promise<void> {
78
+ if (!this.enabled) return;
79
+
80
+ await this.ensureInitialized();
81
+ this.amplitude?.track({
82
+ event_type: name,
83
+ user_id: attributes?.userId || attributes?.user_id || 'anonymous',
84
+ event_properties: attributes,
85
+ });
86
+ }
87
+
88
+ async trackFunnelStep(
89
+ funnelName: string,
90
+ step: FunnelStatus,
91
+ attributes?: EventAttributes,
92
+ ): Promise<void> {
93
+ if (!this.enabled) return;
94
+
95
+ await this.ensureInitialized();
96
+ this.amplitude?.trackEvent({
97
+ event_type: `${funnelName}.${step}`,
98
+ user_id: attributes?.userId || attributes?.user_id || 'anonymous',
99
+ event_properties: {
100
+ funnel: funnelName,
101
+ step,
102
+ ...attributes,
103
+ },
104
+ });
105
+ }
106
+
107
+ async trackOutcome(
108
+ operationName: string,
109
+ outcome: OutcomeStatus,
110
+ attributes?: EventAttributes,
111
+ ): Promise<void> {
112
+ if (!this.enabled) return;
113
+
114
+ await this.ensureInitialized();
115
+ this.amplitude?.trackEvent({
116
+ event_type: `${operationName}.${outcome}`,
117
+ user_id: attributes?.userId || attributes?.user_id || 'anonymous',
118
+ event_properties: {
119
+ operation: operationName,
120
+ outcome,
121
+ ...attributes,
122
+ },
123
+ });
124
+ }
125
+
126
+ async trackValue(name: string, value: number, attributes?: EventAttributes): Promise<void> {
127
+ if (!this.enabled) return;
128
+
129
+ await this.ensureInitialized();
130
+ this.amplitude?.trackEvent({
131
+ event_type: name,
132
+ user_id: attributes?.userId || attributes?.user_id || 'anonymous',
133
+ event_properties: {
134
+ value,
135
+ ...attributes,
136
+ },
137
+ });
138
+ }
139
+
140
+ /** Flush pending events before shutdown */
141
+ async shutdown(): Promise<void> {
142
+ await this.ensureInitialized();
143
+ if (this.amplitude) {
144
+ await this.amplitude.flush();
145
+ }
146
+ }
147
+ }
148
+
@@ -0,0 +1,325 @@
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
+ FunnelStatus,
71
+ OutcomeStatus,
72
+ } from 'autotel/event-subscriber';
73
+
74
+ // Re-export types for convenience
75
+
76
+
77
+ /**
78
+ * Payload sent to destination
79
+ */
80
+ export interface EventPayload {
81
+ /** Event type: 'event', 'funnel', 'outcome', or 'value' */
82
+ type: 'event' | 'funnel' | 'outcome' | 'value';
83
+
84
+ /** Event name or metric name */
85
+ name: string;
86
+
87
+ /** Optional attributes */
88
+ attributes?: EventAttributes;
89
+
90
+ /** For funnel events: funnel name */
91
+ funnel?: string;
92
+
93
+ /** For funnel events: step status */
94
+ step?: FunnelStatus;
95
+
96
+ /** For outcome events: operation name */
97
+ operation?: string;
98
+
99
+ /** For outcome events: outcome status */
100
+ outcome?: OutcomeStatus;
101
+
102
+ /** For value events: numeric value */
103
+ value?: number;
104
+
105
+ /** Timestamp (ISO 8601) */
106
+ timestamp: string;
107
+ }
108
+
109
+ /**
110
+ * Standard base class for building custom events subscribers
111
+ *
112
+ * **What it provides:**
113
+ * - Consistent payload structure (normalized across all event types)
114
+ * - Enable/disable flag (runtime control)
115
+ * - Automatic error handling (with customizable error handlers)
116
+ * - Pending requests tracking (ensures no lost events during shutdown)
117
+ * - Graceful shutdown (drains pending requests before closing)
118
+ *
119
+ * **Usage:**
120
+ * Extend this class and implement `sendToDestination()`. All other methods
121
+ * (trackEvent, trackFunnelStep, trackOutcome, trackValue, shutdown) are handled automatically.
122
+ *
123
+ * For high-throughput streaming platforms (Kafka, Kinesis, Pub/Sub), use `StreamingEventSubscriber` instead.
124
+ */
125
+ export abstract class EventSubscriber implements IEventSubscriber {
126
+ /**
127
+ * Subscriber name (required for debugging)
128
+ */
129
+ abstract readonly name: string;
130
+
131
+ /**
132
+ * Subscriber version (optional)
133
+ */
134
+ readonly version?: string;
135
+
136
+ /**
137
+ * Enable/disable the subscriber (default: true)
138
+ */
139
+ protected enabled: boolean = true;
140
+
141
+ /**
142
+ * Track pending requests for graceful shutdown
143
+ */
144
+ private pendingRequests: Set<Promise<void>> = new Set();
145
+
146
+ /**
147
+ * Send payload to destination
148
+ *
149
+ * Override this method to implement your destination-specific logic.
150
+ * This is called for all event types (event, funnel, outcome, value).
151
+ *
152
+ * @param payload - Normalized event payload
153
+ */
154
+ protected abstract sendToDestination(payload: EventPayload): Promise<void>;
155
+
156
+ /**
157
+ * Optional: Handle errors
158
+ *
159
+ * Override this to customize error handling (logging, retries, etc.).
160
+ * Default behavior: log to console.error
161
+ *
162
+ * @param error - Error that occurred
163
+ * @param payload - Event payload that failed
164
+ */
165
+ protected handleError(error: Error, payload: EventPayload): void {
166
+ console.error(
167
+ `[${this.name}] Failed to send ${payload.type}:`,
168
+ error,
169
+ payload,
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Track an event
175
+ */
176
+ async trackEvent(name: string, attributes?: EventAttributes): Promise<void> {
177
+ if (!this.enabled) return;
178
+
179
+ const payload: EventPayload = {
180
+ type: 'event',
181
+ name,
182
+ attributes,
183
+ timestamp: new Date().toISOString(),
184
+ };
185
+
186
+ await this.send(payload);
187
+ }
188
+
189
+ /**
190
+ * Track a funnel step
191
+ */
192
+ async trackFunnelStep(
193
+ funnelName: string,
194
+ step: FunnelStatus,
195
+ attributes?: EventAttributes,
196
+ ): Promise<void> {
197
+ if (!this.enabled) return;
198
+
199
+ const payload: EventPayload = {
200
+ type: 'funnel',
201
+ name: `${funnelName}.${step}`,
202
+ funnel: funnelName,
203
+ step,
204
+ attributes,
205
+ timestamp: new Date().toISOString(),
206
+ };
207
+
208
+ await this.send(payload);
209
+ }
210
+
211
+ /**
212
+ * Track an outcome
213
+ */
214
+ async trackOutcome(
215
+ operationName: string,
216
+ outcome: OutcomeStatus,
217
+ attributes?: EventAttributes,
218
+ ): Promise<void> {
219
+ if (!this.enabled) return;
220
+
221
+ const payload: EventPayload = {
222
+ type: 'outcome',
223
+ name: `${operationName}.${outcome}`,
224
+ operation: operationName,
225
+ outcome,
226
+ attributes,
227
+ timestamp: new Date().toISOString(),
228
+ };
229
+
230
+ await this.send(payload);
231
+ }
232
+
233
+ /**
234
+ * Track a value/metric
235
+ */
236
+ async trackValue(
237
+ name: string,
238
+ value: number,
239
+ attributes?: EventAttributes,
240
+ ): Promise<void> {
241
+ if (!this.enabled) return;
242
+
243
+ const payload: EventPayload = {
244
+ type: 'value',
245
+ name,
246
+ value,
247
+ attributes,
248
+ timestamp: new Date().toISOString(),
249
+ };
250
+
251
+ await this.send(payload);
252
+ }
253
+
254
+ /**
255
+ * Flush pending requests and clean up
256
+ *
257
+ * CRITICAL: Prevents race condition during shutdown
258
+ * 1. Disables subscriber to stop new events
259
+ * 2. Drains all pending requests (with retry logic)
260
+ * 3. Ensures flush guarantee
261
+ *
262
+ * Override this if you need custom cleanup logic (close connections, flush buffers, etc.),
263
+ * but ALWAYS call super.shutdown() first to drain pending requests.
264
+ */
265
+ async shutdown(): Promise<void> {
266
+ // 1. Stop accepting new events (prevents race condition)
267
+ this.enabled = false;
268
+
269
+ // 2. Drain pending requests with retry logic
270
+ // Loop until empty to handle race where new requests added during Promise.allSettled
271
+ const maxDrainAttempts = 10;
272
+ const drainIntervalMs = 50;
273
+
274
+ for (let attempt = 0; attempt < maxDrainAttempts; attempt++) {
275
+ if (this.pendingRequests.size === 0) {
276
+ break;
277
+ }
278
+
279
+ // Wait for current batch
280
+ await Promise.allSettled(this.pendingRequests);
281
+
282
+ // Small delay to catch any stragglers added during allSettled
283
+ if (this.pendingRequests.size > 0 && attempt < maxDrainAttempts - 1) {
284
+ await new Promise((resolve) => setTimeout(resolve, drainIntervalMs));
285
+ }
286
+ }
287
+
288
+ // 3. Warn if we still have pending requests (shouldn't happen, but be defensive)
289
+ if (this.pendingRequests.size > 0) {
290
+ console.warn(
291
+ `[${this.name}] Shutdown completed with ${this.pendingRequests.size} pending requests still in-flight. ` +
292
+ `This may indicate a bug in the subscriber or extremely slow destination.`
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Internal: Send payload and track request
299
+ */
300
+ private async send(payload: EventPayload): Promise<void> {
301
+ const request = this.sendWithErrorHandling(payload);
302
+ this.pendingRequests.add(request);
303
+
304
+ void request.finally(() => {
305
+ this.pendingRequests.delete(request);
306
+ });
307
+
308
+ return request;
309
+ }
310
+
311
+ /**
312
+ * Internal: Send with error handling
313
+ */
314
+ private async sendWithErrorHandling(
315
+ payload: EventPayload,
316
+ ): Promise<void> {
317
+ try {
318
+ await this.sendToDestination(payload);
319
+ } catch (error) {
320
+ this.handleError(error as Error, payload);
321
+ }
322
+ }
323
+ }
324
+
325
+ export {type EventAttributes, type FunnelStatus, type OutcomeStatus} from 'autotel/event-subscriber';