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.
- package/LICENSE +21 -0
- package/README.md +669 -0
- package/dist/amplitude.cjs +2486 -0
- package/dist/amplitude.cjs.map +1 -0
- package/dist/amplitude.d.cts +49 -0
- package/dist/amplitude.d.ts +49 -0
- package/dist/amplitude.js +2463 -0
- package/dist/amplitude.js.map +1 -0
- package/dist/event-subscriber-base-CnF3V56W.d.cts +182 -0
- package/dist/event-subscriber-base-CnF3V56W.d.ts +182 -0
- package/dist/factories.cjs +16660 -0
- package/dist/factories.cjs.map +1 -0
- package/dist/factories.d.cts +304 -0
- package/dist/factories.d.ts +304 -0
- package/dist/factories.js +16624 -0
- package/dist/factories.js.map +1 -0
- package/dist/index.cjs +16575 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +179 -0
- package/dist/index.d.ts +179 -0
- package/dist/index.js +16539 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.cjs +220 -0
- package/dist/middleware.cjs.map +1 -0
- package/dist/middleware.d.cts +227 -0
- package/dist/middleware.d.ts +227 -0
- package/dist/middleware.js +208 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mixpanel.cjs +2940 -0
- package/dist/mixpanel.cjs.map +1 -0
- package/dist/mixpanel.d.cts +47 -0
- package/dist/mixpanel.d.ts +47 -0
- package/dist/mixpanel.js +2932 -0
- package/dist/mixpanel.js.map +1 -0
- package/dist/posthog.cjs +4115 -0
- package/dist/posthog.cjs.map +1 -0
- package/dist/posthog.d.cts +299 -0
- package/dist/posthog.d.ts +299 -0
- package/dist/posthog.js +4113 -0
- package/dist/posthog.js.map +1 -0
- package/dist/segment.cjs +6822 -0
- package/dist/segment.cjs.map +1 -0
- package/dist/segment.d.cts +49 -0
- package/dist/segment.d.ts +49 -0
- package/dist/segment.js +6794 -0
- package/dist/segment.js.map +1 -0
- package/dist/slack.cjs +368 -0
- package/dist/slack.cjs.map +1 -0
- package/dist/slack.d.cts +126 -0
- package/dist/slack.d.ts +126 -0
- package/dist/slack.js +366 -0
- package/dist/slack.js.map +1 -0
- package/dist/webhook.cjs +100 -0
- package/dist/webhook.cjs.map +1 -0
- package/dist/webhook.d.cts +53 -0
- package/dist/webhook.d.ts +53 -0
- package/dist/webhook.js +98 -0
- package/dist/webhook.js.map +1 -0
- package/examples/quickstart-custom-subscriber.ts +144 -0
- package/examples/subscriber-bigquery.ts +219 -0
- package/examples/subscriber-databricks.ts +280 -0
- package/examples/subscriber-kafka.ts +326 -0
- package/examples/subscriber-kinesis.ts +307 -0
- package/examples/subscriber-posthog.ts +421 -0
- package/examples/subscriber-pubsub.ts +336 -0
- package/examples/subscriber-snowflake.ts +232 -0
- package/package.json +141 -0
- package/src/amplitude.test.ts +231 -0
- package/src/amplitude.ts +148 -0
- package/src/event-subscriber-base.ts +325 -0
- package/src/factories.ts +197 -0
- package/src/index.ts +50 -0
- package/src/middleware.ts +489 -0
- package/src/mixpanel.test.ts +194 -0
- package/src/mixpanel.ts +134 -0
- package/src/mock-event-subscriber.ts +333 -0
- package/src/posthog.test.ts +629 -0
- package/src/posthog.ts +530 -0
- package/src/segment.test.ts +228 -0
- package/src/segment.ts +148 -0
- package/src/slack.ts +383 -0
- package/src/streaming-event-subscriber.ts +323 -0
- package/src/testing/index.ts +37 -0
- package/src/testing/mock-webhook-server.ts +242 -0
- package/src/testing/subscriber-test-harness.ts +365 -0
- package/src/webhook.test.ts +264 -0
- package/src/webhook.ts +158 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware System for Events Subscribers
|
|
3
|
+
*
|
|
4
|
+
* Compose subscriber behaviors using middleware (like Redux/Express).
|
|
5
|
+
* Add retry logic, sampling, enrichment, logging, and more without modifying subscriber code.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { applyMiddleware, retryMiddleware, loggingMiddleware } from 'autotel-subscribers/middleware';
|
|
10
|
+
*
|
|
11
|
+
* const subscriber = applyMiddleware(
|
|
12
|
+
* new PostHogSubscriber({ apiKey: '...' }),
|
|
13
|
+
* [
|
|
14
|
+
* retryMiddleware({ maxRetries: 3 }),
|
|
15
|
+
* loggingMiddleware()
|
|
16
|
+
* ]
|
|
17
|
+
* );
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { EventSubscriber, EventAttributes, FunnelStatus, OutcomeStatus } from 'autotel/event-subscriber';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Unified event type for middleware
|
|
25
|
+
*/
|
|
26
|
+
export type EventsEvent =
|
|
27
|
+
| {
|
|
28
|
+
type: 'event';
|
|
29
|
+
name: string;
|
|
30
|
+
attributes?: EventAttributes;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
type: 'funnel';
|
|
34
|
+
funnel: string;
|
|
35
|
+
step: FunnelStatus;
|
|
36
|
+
attributes?: EventAttributes;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: 'outcome';
|
|
40
|
+
operation: string;
|
|
41
|
+
outcome: OutcomeStatus;
|
|
42
|
+
attributes?: EventAttributes;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
type: 'value';
|
|
46
|
+
name: string;
|
|
47
|
+
value: number;
|
|
48
|
+
attributes?: EventAttributes;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Middleware function signature.
|
|
53
|
+
*
|
|
54
|
+
* Like Express middleware: `(event, next) => Promise<void>`
|
|
55
|
+
*/
|
|
56
|
+
export type SubscriberMiddleware = (
|
|
57
|
+
event: EventsEvent,
|
|
58
|
+
next: (event: EventsEvent) => Promise<void>
|
|
59
|
+
) => Promise<void>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply middleware to an subscriber.
|
|
63
|
+
*
|
|
64
|
+
* Middleware is executed in order. Each middleware can:
|
|
65
|
+
* - Transform the event before passing to next()
|
|
66
|
+
* - Add side effects (logging, metrics)
|
|
67
|
+
* - Skip calling next() (filtering)
|
|
68
|
+
* - Handle errors
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const subscriber = applyMiddleware(
|
|
73
|
+
* new WebhookSubscriber({ url: '...' }),
|
|
74
|
+
* [
|
|
75
|
+
* loggingMiddleware(),
|
|
76
|
+
* retryMiddleware({ maxRetries: 3 }),
|
|
77
|
+
* samplingMiddleware(0.1) // Only 10% of events
|
|
78
|
+
* ]
|
|
79
|
+
* );
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function applyMiddleware(
|
|
83
|
+
subscriber: EventSubscriber,
|
|
84
|
+
middlewares: SubscriberMiddleware[]
|
|
85
|
+
): EventSubscriber {
|
|
86
|
+
// Convert subscriber methods to event format
|
|
87
|
+
const trackEvent = async (event: EventsEvent): Promise<void> => {
|
|
88
|
+
switch (event.type) {
|
|
89
|
+
case 'event': {
|
|
90
|
+
await subscriber.trackEvent(event.name, event.attributes);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'funnel': {
|
|
94
|
+
await subscriber.trackFunnelStep(event.funnel, event.step, event.attributes);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'outcome': {
|
|
98
|
+
await subscriber.trackOutcome(event.operation, event.outcome, event.attributes);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'value': {
|
|
102
|
+
await subscriber.trackValue(event.name, event.value, event.attributes);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Build middleware chain
|
|
109
|
+
type ChainFunction = (event: EventsEvent) => Promise<void>;
|
|
110
|
+
const reversedMiddlewares = middlewares.toReversed();
|
|
111
|
+
let chain: ChainFunction = trackEvent;
|
|
112
|
+
for (const middleware of reversedMiddlewares) {
|
|
113
|
+
const next = chain;
|
|
114
|
+
chain = (event: EventsEvent) => middleware(event, next);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Wrap subscriber with middleware chain
|
|
118
|
+
return {
|
|
119
|
+
name: `${subscriber.name}(middleware)`,
|
|
120
|
+
version: subscriber.version,
|
|
121
|
+
|
|
122
|
+
async trackEvent(name: string, attributes?: EventAttributes): Promise<void> {
|
|
123
|
+
await chain({ type: 'event', name, attributes });
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async trackFunnelStep(funnel: string, step: FunnelStatus, attributes?: EventAttributes): Promise<void> {
|
|
127
|
+
await chain({ type: 'funnel', funnel, step, attributes });
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async trackOutcome(operation: string, outcome: OutcomeStatus, attributes?: EventAttributes): Promise<void> {
|
|
131
|
+
await chain({ type: 'outcome', operation, outcome, attributes });
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async trackValue(name: string, value: number, attributes?: EventAttributes): Promise<void> {
|
|
135
|
+
await chain({ type: 'value', name, value, attributes });
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async shutdown(): Promise<void> {
|
|
139
|
+
await subscriber.shutdown?.();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Built-in Middleware
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Retry failed requests with exponential backoff.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
154
|
+
* retryMiddleware({ maxRetries: 3, delayMs: 1000 })
|
|
155
|
+
* ]);
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export function retryMiddleware(options: {
|
|
159
|
+
maxRetries?: number;
|
|
160
|
+
delayMs?: number;
|
|
161
|
+
onRetry?: (attempt: number, error: Error) => void;
|
|
162
|
+
}): SubscriberMiddleware {
|
|
163
|
+
const { maxRetries = 3, delayMs = 1000, onRetry } = options;
|
|
164
|
+
|
|
165
|
+
return async (event, next) => {
|
|
166
|
+
let lastError: Error | undefined;
|
|
167
|
+
|
|
168
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
await next(event);
|
|
171
|
+
return; // Success!
|
|
172
|
+
} catch (error) {
|
|
173
|
+
lastError = error as Error;
|
|
174
|
+
|
|
175
|
+
if (attempt < maxRetries) {
|
|
176
|
+
onRetry?.(attempt, lastError);
|
|
177
|
+
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * Math.pow(2, attempt - 1)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw lastError;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sample events (only send a percentage).
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* // Only send 10% of events (reduce costs)
|
|
193
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
194
|
+
* samplingMiddleware(0.1)
|
|
195
|
+
* ]);
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export function samplingMiddleware(rate: number): SubscriberMiddleware {
|
|
199
|
+
if (rate < 0 || rate > 1) {
|
|
200
|
+
throw new Error('Sample rate must be between 0 and 1');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return async (event, next) => {
|
|
204
|
+
if (Math.random() < rate) {
|
|
205
|
+
await next(event);
|
|
206
|
+
}
|
|
207
|
+
// Else: skip this event
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Enrich events with additional attributes.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```typescript
|
|
216
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
217
|
+
* enrichmentMiddleware((event) => ({
|
|
218
|
+
* ...event,
|
|
219
|
+
* attributes: {
|
|
220
|
+
* ...event.attributes,
|
|
221
|
+
* environment: process.env.NODE_ENV,
|
|
222
|
+
* timestamp: Date.now()
|
|
223
|
+
* }
|
|
224
|
+
* }))
|
|
225
|
+
* ]);
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function enrichmentMiddleware(
|
|
229
|
+
enricher: (event: EventsEvent) => EventsEvent
|
|
230
|
+
): SubscriberMiddleware {
|
|
231
|
+
return async (event, next) => {
|
|
232
|
+
const enriched = enricher(event);
|
|
233
|
+
await next(enriched);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Log events to console.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
243
|
+
* loggingMiddleware({ prefix: '[Events]', logAttributes: true })
|
|
244
|
+
* ]);
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function loggingMiddleware(options: {
|
|
248
|
+
prefix?: string;
|
|
249
|
+
logAttributes?: boolean;
|
|
250
|
+
} = {}): SubscriberMiddleware {
|
|
251
|
+
const { prefix = '[Events]', logAttributes = false } = options;
|
|
252
|
+
|
|
253
|
+
return async (event, next) => {
|
|
254
|
+
if (logAttributes) {
|
|
255
|
+
console.log(prefix, event.type, event);
|
|
256
|
+
} else {
|
|
257
|
+
// Just log event type and name
|
|
258
|
+
const eventName = 'name' in event ? event.name : `${(event as any).funnel || (event as any).operation}`;
|
|
259
|
+
console.log(prefix, event.type, eventName);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await next(event);
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Filter events based on a predicate.
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```typescript
|
|
271
|
+
* // Only send 'order' events
|
|
272
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
273
|
+
* filterMiddleware((event) =>
|
|
274
|
+
* event.type === 'event' && event.name.startsWith('order.')
|
|
275
|
+
* )
|
|
276
|
+
* ]);
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export function filterMiddleware(
|
|
280
|
+
predicate: (event: EventsEvent) => boolean
|
|
281
|
+
): SubscriberMiddleware {
|
|
282
|
+
return async (event, next) => {
|
|
283
|
+
if (predicate(event)) {
|
|
284
|
+
await next(event);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Transform events.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* // Lowercase all event names
|
|
295
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
296
|
+
* transformMiddleware((event) => {
|
|
297
|
+
* if (event.type === 'event') {
|
|
298
|
+
* return { ...event, name: event.name.toLowerCase() };
|
|
299
|
+
* }
|
|
300
|
+
* return event;
|
|
301
|
+
* })
|
|
302
|
+
* ]);
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export function transformMiddleware(
|
|
306
|
+
transformer: (event: EventsEvent) => EventsEvent
|
|
307
|
+
): SubscriberMiddleware {
|
|
308
|
+
return async (event, next) => {
|
|
309
|
+
const transformed = transformer(event);
|
|
310
|
+
await next(transformed);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Batch events and flush periodically.
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```typescript
|
|
319
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
320
|
+
* batchingMiddleware({ batchSize: 100, flushInterval: 5000 })
|
|
321
|
+
* ]);
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export function batchingMiddleware(options: {
|
|
325
|
+
batchSize?: number;
|
|
326
|
+
flushInterval?: number;
|
|
327
|
+
}): SubscriberMiddleware {
|
|
328
|
+
const { batchSize = 100, flushInterval = 5000 } = options;
|
|
329
|
+
const buffer: Array<{ event: EventsEvent; next: (event: EventsEvent) => Promise<void> }> = [];
|
|
330
|
+
let flushTimer: NodeJS.Timeout | null = null;
|
|
331
|
+
|
|
332
|
+
const flush = async () => {
|
|
333
|
+
const batch = [...buffer];
|
|
334
|
+
buffer.length = 0;
|
|
335
|
+
|
|
336
|
+
await Promise.all(batch.map(({ event, next }) => next(event)));
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const scheduleFlush = () => {
|
|
340
|
+
if (flushTimer) return;
|
|
341
|
+
flushTimer = setTimeout(() => {
|
|
342
|
+
flush().catch(console.error);
|
|
343
|
+
flushTimer = null;
|
|
344
|
+
}, flushInterval);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return async (event, next) => {
|
|
348
|
+
buffer.push({ event, next });
|
|
349
|
+
|
|
350
|
+
if (buffer.length >= batchSize) {
|
|
351
|
+
await flush();
|
|
352
|
+
} else {
|
|
353
|
+
scheduleFlush();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Rate limit events (throttle).
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```typescript
|
|
363
|
+
* // Max 100 events per second
|
|
364
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
365
|
+
* rateLimitMiddleware({ requestsPerSecond: 100 })
|
|
366
|
+
* ]);
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
export function rateLimitMiddleware(options: {
|
|
370
|
+
requestsPerSecond: number;
|
|
371
|
+
}): SubscriberMiddleware {
|
|
372
|
+
const { requestsPerSecond } = options;
|
|
373
|
+
const intervalMs = 1000 / requestsPerSecond;
|
|
374
|
+
let lastCallTime = 0;
|
|
375
|
+
const queue: Array<() => void> = [];
|
|
376
|
+
let processing = false;
|
|
377
|
+
|
|
378
|
+
const processQueue = async () => {
|
|
379
|
+
if (processing) return;
|
|
380
|
+
processing = true;
|
|
381
|
+
|
|
382
|
+
while (queue.length > 0) {
|
|
383
|
+
const now = Date.now();
|
|
384
|
+
const timeSinceLastCall = now - lastCallTime;
|
|
385
|
+
|
|
386
|
+
if (timeSinceLastCall < intervalMs) {
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs - timeSinceLastCall));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const fn = queue.shift();
|
|
391
|
+
if (fn) {
|
|
392
|
+
lastCallTime = Date.now();
|
|
393
|
+
fn();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
processing = false;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return async (event, next) => {
|
|
401
|
+
return new Promise<void>((resolve, reject) => {
|
|
402
|
+
queue.push(() => {
|
|
403
|
+
next(event).then(resolve).catch(reject);
|
|
404
|
+
});
|
|
405
|
+
processQueue().catch(reject);
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Circuit breaker pattern.
|
|
412
|
+
*
|
|
413
|
+
* Opens circuit after N failures, prevents further requests for a timeout period.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```typescript
|
|
417
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
418
|
+
* circuitBreakerMiddleware({
|
|
419
|
+
* failureThreshold: 5,
|
|
420
|
+
* timeout: 60000 // 1 minute
|
|
421
|
+
* })
|
|
422
|
+
* ]);
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
export function circuitBreakerMiddleware(options: {
|
|
426
|
+
failureThreshold?: number;
|
|
427
|
+
timeout?: number;
|
|
428
|
+
onOpen?: () => void;
|
|
429
|
+
onClose?: () => void;
|
|
430
|
+
}): SubscriberMiddleware {
|
|
431
|
+
const { failureThreshold = 5, timeout = 60_000, onOpen, onClose } = options;
|
|
432
|
+
let failureCount = 0;
|
|
433
|
+
let lastFailureTime = 0;
|
|
434
|
+
let circuitOpen = false;
|
|
435
|
+
|
|
436
|
+
return async (event, next) => {
|
|
437
|
+
// Check if circuit should close
|
|
438
|
+
if (circuitOpen) {
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
if (now - lastFailureTime > timeout) {
|
|
441
|
+
circuitOpen = false;
|
|
442
|
+
failureCount = 0;
|
|
443
|
+
onClose?.();
|
|
444
|
+
} else {
|
|
445
|
+
throw new Error('Circuit breaker is open');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
await next(event);
|
|
451
|
+
// Success resets failure count
|
|
452
|
+
failureCount = 0;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
failureCount++;
|
|
455
|
+
lastFailureTime = Date.now();
|
|
456
|
+
|
|
457
|
+
if (failureCount >= failureThreshold) {
|
|
458
|
+
circuitOpen = true;
|
|
459
|
+
onOpen?.();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Add timeout to events.
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* ```typescript
|
|
472
|
+
* const subscriber = applyMiddleware(adapter, [
|
|
473
|
+
* timeoutMiddleware({ timeoutMs: 5000 })
|
|
474
|
+
* ]);
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
export function timeoutMiddleware(options: {
|
|
478
|
+
timeoutMs: number;
|
|
479
|
+
}): SubscriberMiddleware {
|
|
480
|
+
const { timeoutMs } = options;
|
|
481
|
+
|
|
482
|
+
return async (event, next) => {
|
|
483
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
484
|
+
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await Promise.race([next(event), timeoutPromise]);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { MixpanelSubscriber } from './mixpanel';
|
|
3
|
+
|
|
4
|
+
// Mock the mixpanel module
|
|
5
|
+
vi.mock('mixpanel', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
init: vi.fn().mockReturnValue({
|
|
8
|
+
track: vi.fn(),
|
|
9
|
+
}),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe('MixpanelSubscriber', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('initialization', () => {
|
|
19
|
+
it('should initialize with valid config', async () => {
|
|
20
|
+
const adapter = new MixpanelSubscriber({
|
|
21
|
+
token: 'test_token',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
25
|
+
|
|
26
|
+
expect(adapter).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should not initialize when disabled', () => {
|
|
30
|
+
const adapter = new MixpanelSubscriber({
|
|
31
|
+
token: 'test_token',
|
|
32
|
+
enabled: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(adapter).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('trackEvent', () => {
|
|
40
|
+
it('should track event with attributes', async () => {
|
|
41
|
+
const Mixpanel = await import('mixpanel');
|
|
42
|
+
const adapter = new MixpanelSubscriber({
|
|
43
|
+
token: 'test_token',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
47
|
+
|
|
48
|
+
adapter.trackEvent('order.completed', {
|
|
49
|
+
userId: 'user-123',
|
|
50
|
+
amount: 99.99,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
54
|
+
|
|
55
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
56
|
+
expect(mockInstance.track).toHaveBeenCalledWith('order.completed', {
|
|
57
|
+
distinct_id: 'user-123',
|
|
58
|
+
userId: 'user-123',
|
|
59
|
+
amount: 99.99,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should use user_id if userId is not present', async () => {
|
|
64
|
+
const Mixpanel = await import('mixpanel');
|
|
65
|
+
const adapter = new MixpanelSubscriber({
|
|
66
|
+
token: 'test_token',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
70
|
+
|
|
71
|
+
adapter.trackEvent('order.completed', {
|
|
72
|
+
user_id: 'user-456',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
76
|
+
|
|
77
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
78
|
+
expect(mockInstance.track).toHaveBeenCalledWith('order.completed', {
|
|
79
|
+
distinct_id: 'user-456',
|
|
80
|
+
user_id: 'user-456',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should use anonymous if no userId is present', async () => {
|
|
85
|
+
const Mixpanel = await import('mixpanel');
|
|
86
|
+
const adapter = new MixpanelSubscriber({
|
|
87
|
+
token: 'test_token',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
91
|
+
|
|
92
|
+
adapter.trackEvent('page.viewed');
|
|
93
|
+
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
95
|
+
|
|
96
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
97
|
+
expect(mockInstance.track).toHaveBeenCalledWith('page.viewed', {
|
|
98
|
+
distinct_id: 'anonymous',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should not track when disabled', () => {
|
|
103
|
+
const adapter = new MixpanelSubscriber({
|
|
104
|
+
token: 'test_token',
|
|
105
|
+
enabled: false,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
adapter.trackEvent('order.completed', { userId: 'user-123' });
|
|
109
|
+
|
|
110
|
+
// Should not throw
|
|
111
|
+
expect(true).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('trackFunnelStep', () => {
|
|
116
|
+
it('should track funnel step', async () => {
|
|
117
|
+
const Mixpanel = await import('mixpanel');
|
|
118
|
+
const adapter = new MixpanelSubscriber({
|
|
119
|
+
token: 'test_token',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
123
|
+
|
|
124
|
+
adapter.trackFunnelStep('checkout', 'started', {
|
|
125
|
+
userId: 'user-123',
|
|
126
|
+
cartValue: 150,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
130
|
+
|
|
131
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
132
|
+
expect(mockInstance.track).toHaveBeenCalledWith('checkout.started', {
|
|
133
|
+
distinct_id: 'user-123',
|
|
134
|
+
funnel: 'checkout',
|
|
135
|
+
step: 'started',
|
|
136
|
+
userId: 'user-123',
|
|
137
|
+
cartValue: 150,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('trackOutcome', () => {
|
|
143
|
+
it('should track outcome', async () => {
|
|
144
|
+
const Mixpanel = await import('mixpanel');
|
|
145
|
+
const adapter = new MixpanelSubscriber({
|
|
146
|
+
token: 'test_token',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
150
|
+
|
|
151
|
+
adapter.trackOutcome('payment.processing', 'success', {
|
|
152
|
+
userId: 'user-123',
|
|
153
|
+
transactionId: 'txn-789',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
157
|
+
|
|
158
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
159
|
+
expect(mockInstance.track).toHaveBeenCalledWith('payment.processing.success', {
|
|
160
|
+
distinct_id: 'user-123',
|
|
161
|
+
operation: 'payment.processing',
|
|
162
|
+
outcome: 'success',
|
|
163
|
+
userId: 'user-123',
|
|
164
|
+
transactionId: 'txn-789',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('trackValue', () => {
|
|
170
|
+
it('should track value', async () => {
|
|
171
|
+
const Mixpanel = await import('mixpanel');
|
|
172
|
+
const adapter = new MixpanelSubscriber({
|
|
173
|
+
token: 'test_token',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
177
|
+
|
|
178
|
+
adapter.trackValue('revenue', 99.99, {
|
|
179
|
+
userId: 'user-123',
|
|
180
|
+
currency: 'USD',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
184
|
+
|
|
185
|
+
const mockInstance = (Mixpanel.default.init as any).mock.results[0].value;
|
|
186
|
+
expect(mockInstance.track).toHaveBeenCalledWith('revenue', {
|
|
187
|
+
distinct_id: 'user-123',
|
|
188
|
+
value: 99.99,
|
|
189
|
+
userId: 'user-123',
|
|
190
|
+
currency: 'USD',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|