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
package/src/slack.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Subscriber for autotel
|
|
3
|
+
*
|
|
4
|
+
* Send events events as notifications to Slack channels via webhooks.
|
|
5
|
+
*
|
|
6
|
+
* Perfect for:
|
|
7
|
+
* - Critical business events (orders, payments, signups)
|
|
8
|
+
* - Real-time alerts for failures
|
|
9
|
+
* - Team notifications for important milestones
|
|
10
|
+
* - Monitoring funnel completions
|
|
11
|
+
*
|
|
12
|
+
* @example Basic usage
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { Events } from 'autotel/events';
|
|
15
|
+
* import { SlackSubscriber } from 'autotel-subscribers/slack';
|
|
16
|
+
*
|
|
17
|
+
* const events = new Events('app', {
|
|
18
|
+
* subscribers: [
|
|
19
|
+
* new SlackSubscriber({
|
|
20
|
+
* webhookUrl: process.env.SLACK_WEBHOOK_URL!,
|
|
21
|
+
* channel: '#order-events'
|
|
22
|
+
* })
|
|
23
|
+
* ]
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Sends to Slack
|
|
27
|
+
* events.trackEvent('order.completed', {
|
|
28
|
+
* orderId: 'ord_123',
|
|
29
|
+
* userId: 'user_456',
|
|
30
|
+
* amount: 99.99
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @example Filter critical events only
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const events = new Events('app', {
|
|
37
|
+
* subscribers: [
|
|
38
|
+
* new SlackSubscriber({
|
|
39
|
+
* webhookUrl: process.env.SLACK_WEBHOOK_URL!,
|
|
40
|
+
* channel: '#alerts',
|
|
41
|
+
* filter: (payload) => {
|
|
42
|
+
* // Only send failures and high-value orders
|
|
43
|
+
* if (payload.type === 'outcome' && payload.outcome === 'failure') {
|
|
44
|
+
* return true;
|
|
45
|
+
* }
|
|
46
|
+
* if (payload.name === 'order.completed' && payload.attributes?.amount > 1000) {
|
|
47
|
+
* return true;
|
|
48
|
+
* }
|
|
49
|
+
* return false;
|
|
50
|
+
* }
|
|
51
|
+
* })
|
|
52
|
+
* ]
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Setup:
|
|
57
|
+
* 1. Create Slack App: https://api.slack.com/apps
|
|
58
|
+
* 2. Enable Incoming Webhooks
|
|
59
|
+
* 3. Add webhook to workspace
|
|
60
|
+
* 4. Copy webhook URL (https://hooks.slack.com/services/...)
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import {
|
|
64
|
+
EventSubscriber,
|
|
65
|
+
type EventPayload,
|
|
66
|
+
} from './event-subscriber-base';
|
|
67
|
+
|
|
68
|
+
export interface SlackSubscriberConfig {
|
|
69
|
+
/** Slack webhook URL (https://hooks.slack.com/services/...) */
|
|
70
|
+
webhookUrl: string;
|
|
71
|
+
|
|
72
|
+
/** Default channel to post to (optional, overrides webhook default) */
|
|
73
|
+
channel?: string;
|
|
74
|
+
|
|
75
|
+
/** Custom username for bot (default: 'Events Bot') */
|
|
76
|
+
username?: string;
|
|
77
|
+
|
|
78
|
+
/** Custom emoji icon (default: ':chart_with_upwards_trend:') */
|
|
79
|
+
iconEmoji?: string;
|
|
80
|
+
|
|
81
|
+
/** Include timestamp in messages (default: true) */
|
|
82
|
+
includeTimestamp?: boolean;
|
|
83
|
+
|
|
84
|
+
/** Include event attributes as fields (default: true) */
|
|
85
|
+
includeAttributes?: boolean;
|
|
86
|
+
|
|
87
|
+
/** Maximum attributes to show (default: 10) */
|
|
88
|
+
maxAttributeFields?: number;
|
|
89
|
+
|
|
90
|
+
/** Filter function - return true to send, false to skip */
|
|
91
|
+
filter?: (payload: EventPayload) => boolean;
|
|
92
|
+
|
|
93
|
+
/** Enable/disable subscriber */
|
|
94
|
+
enabled?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface SlackMessage {
|
|
98
|
+
channel?: string;
|
|
99
|
+
username?: string;
|
|
100
|
+
icon_emoji?: string;
|
|
101
|
+
text?: string;
|
|
102
|
+
attachments: SlackAttachment[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface SlackAttachment {
|
|
106
|
+
color?: string;
|
|
107
|
+
title?: string;
|
|
108
|
+
text?: string;
|
|
109
|
+
fields?: SlackField[];
|
|
110
|
+
footer?: string;
|
|
111
|
+
footer_icon?: string;
|
|
112
|
+
ts?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface SlackField {
|
|
116
|
+
title: string;
|
|
117
|
+
value: string;
|
|
118
|
+
short: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class SlackSubscriber extends EventSubscriber {
|
|
122
|
+
readonly name = 'SlackSubscriber';
|
|
123
|
+
readonly version = '1.0.0';
|
|
124
|
+
|
|
125
|
+
private config: Required<Omit<SlackSubscriberConfig, 'channel' | 'filter'>> & {
|
|
126
|
+
channel?: string;
|
|
127
|
+
filter?: (payload: EventPayload) => boolean;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
constructor(config: SlackSubscriberConfig) {
|
|
131
|
+
super();
|
|
132
|
+
|
|
133
|
+
this.config = {
|
|
134
|
+
webhookUrl: config.webhookUrl,
|
|
135
|
+
channel: config.channel,
|
|
136
|
+
username: config.username ?? 'Events Bot',
|
|
137
|
+
iconEmoji: config.iconEmoji ?? ':chart_with_upwards_trend:',
|
|
138
|
+
includeTimestamp: config.includeTimestamp ?? true,
|
|
139
|
+
includeAttributes: config.includeAttributes ?? true,
|
|
140
|
+
maxAttributeFields: config.maxAttributeFields ?? 10,
|
|
141
|
+
filter: config.filter,
|
|
142
|
+
enabled: config.enabled ?? true,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
this.enabled = this.config.enabled;
|
|
146
|
+
|
|
147
|
+
if (!this.config.webhookUrl) {
|
|
148
|
+
console.error(
|
|
149
|
+
'[SlackSubscriber] No webhook URL provided - subscriber disabled'
|
|
150
|
+
);
|
|
151
|
+
this.enabled = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
protected async sendToDestination(payload: EventPayload): Promise<void> {
|
|
156
|
+
// Apply filter if provided
|
|
157
|
+
if (this.config.filter) {
|
|
158
|
+
const filterFn = this.config.filter;
|
|
159
|
+
const shouldInclude = filterFn(payload);
|
|
160
|
+
if (!shouldInclude) {
|
|
161
|
+
return; // Skip this event
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const message = this.formatSlackMessage(payload);
|
|
166
|
+
|
|
167
|
+
const response = await fetch(this.config.webhookUrl, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(message),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const errorText = await response.text();
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Slack webhook failed (${response.status}): ${errorText}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format events payload as Slack message
|
|
183
|
+
*/
|
|
184
|
+
private formatSlackMessage(payload: EventPayload): SlackMessage {
|
|
185
|
+
const emoji = this.getEventEmoji(payload);
|
|
186
|
+
const color = this.getEventColor(payload);
|
|
187
|
+
const title = `${emoji} ${payload.name}`;
|
|
188
|
+
|
|
189
|
+
// Add event type
|
|
190
|
+
const fields: SlackField[] = [
|
|
191
|
+
{
|
|
192
|
+
title: 'Event Type',
|
|
193
|
+
value: this.formatEventType(payload),
|
|
194
|
+
short: true,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
// Add timestamp if enabled
|
|
199
|
+
if (this.config.includeTimestamp) {
|
|
200
|
+
fields.push({
|
|
201
|
+
title: 'Timestamp',
|
|
202
|
+
value: new Date(payload.timestamp).toLocaleString('en-US', {
|
|
203
|
+
month: 'short',
|
|
204
|
+
day: 'numeric',
|
|
205
|
+
hour: '2-digit',
|
|
206
|
+
minute: '2-digit',
|
|
207
|
+
second: '2-digit',
|
|
208
|
+
}),
|
|
209
|
+
short: true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Add attributes as fields
|
|
214
|
+
if (this.config.includeAttributes && payload.attributes) {
|
|
215
|
+
const attributeFields = this.formatAttributes(payload.attributes);
|
|
216
|
+
fields.push(...attributeFields);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const attachment: SlackAttachment = {
|
|
220
|
+
color,
|
|
221
|
+
title,
|
|
222
|
+
fields,
|
|
223
|
+
footer: 'Events Events',
|
|
224
|
+
footer_icon: 'https://i.imgur.com/QpCKbNL.png',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Add Unix timestamp for Slack
|
|
228
|
+
if (this.config.includeTimestamp) {
|
|
229
|
+
attachment.ts = Math.floor(
|
|
230
|
+
new Date(payload.timestamp).getTime() / 1000
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
channel: this.config.channel,
|
|
236
|
+
username: this.config.username,
|
|
237
|
+
icon_emoji: this.config.iconEmoji,
|
|
238
|
+
attachments: [attachment],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get emoji for event type
|
|
244
|
+
*/
|
|
245
|
+
private getEventEmoji(payload: EventPayload): string {
|
|
246
|
+
switch (payload.type) {
|
|
247
|
+
case 'outcome': {
|
|
248
|
+
return payload.outcome === 'success' ? '✅' : '❌';
|
|
249
|
+
}
|
|
250
|
+
case 'funnel': {
|
|
251
|
+
return '🔄';
|
|
252
|
+
}
|
|
253
|
+
case 'value': {
|
|
254
|
+
return '📊';
|
|
255
|
+
}
|
|
256
|
+
default: {
|
|
257
|
+
// Use custom emoji for common event patterns
|
|
258
|
+
if (payload.name.includes('order') || payload.name.includes('payment'))
|
|
259
|
+
return '💰';
|
|
260
|
+
if (payload.name.includes('signup') || payload.name.includes('user'))
|
|
261
|
+
return '👤';
|
|
262
|
+
if (payload.name.includes('error') || payload.name.includes('fail'))
|
|
263
|
+
return '⚠️';
|
|
264
|
+
return '📌';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get Slack attachment color for event type
|
|
271
|
+
*/
|
|
272
|
+
private getEventColor(payload: EventPayload): string {
|
|
273
|
+
switch (payload.type) {
|
|
274
|
+
case 'outcome': {
|
|
275
|
+
return payload.outcome === 'success' ? 'good' : 'danger';
|
|
276
|
+
} // Green or red
|
|
277
|
+
case 'funnel': {
|
|
278
|
+
return '#3AA3E3';
|
|
279
|
+
} // Blue
|
|
280
|
+
case 'value': {
|
|
281
|
+
return '#764FA5';
|
|
282
|
+
} // Purple
|
|
283
|
+
default: {
|
|
284
|
+
// Custom colors for patterns
|
|
285
|
+
if (payload.name.includes('error') || payload.name.includes('fail'))
|
|
286
|
+
return 'danger';
|
|
287
|
+
if (payload.name.includes('warning')) return 'warning';
|
|
288
|
+
return 'good';
|
|
289
|
+
} // Default green
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Format event type for display
|
|
295
|
+
*/
|
|
296
|
+
private formatEventType(payload: EventPayload): string {
|
|
297
|
+
switch (payload.type) {
|
|
298
|
+
case 'event': {
|
|
299
|
+
return 'Event';
|
|
300
|
+
}
|
|
301
|
+
case 'funnel': {
|
|
302
|
+
return `Funnel: ${payload.step || 'unknown'}`;
|
|
303
|
+
}
|
|
304
|
+
case 'outcome': {
|
|
305
|
+
return `Outcome: ${payload.outcome || 'unknown'}`;
|
|
306
|
+
}
|
|
307
|
+
case 'value': {
|
|
308
|
+
return `Value: ${payload.value ?? 'N/A'}`;
|
|
309
|
+
}
|
|
310
|
+
default: {
|
|
311
|
+
return payload.type;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Format attributes as Slack fields
|
|
318
|
+
*/
|
|
319
|
+
private formatAttributes(attributes: Record<string, any>): SlackField[] {
|
|
320
|
+
const fields: SlackField[] = [];
|
|
321
|
+
const entries = Object.entries(attributes);
|
|
322
|
+
|
|
323
|
+
// Limit number of fields
|
|
324
|
+
const limit = Math.min(entries.length, this.config.maxAttributeFields);
|
|
325
|
+
|
|
326
|
+
for (let i = 0; i < limit; i++) {
|
|
327
|
+
const [key, value] = entries[i];
|
|
328
|
+
|
|
329
|
+
// Skip internal/system fields
|
|
330
|
+
if (key.startsWith('_') || key === 'timestamp') continue;
|
|
331
|
+
|
|
332
|
+
fields.push({
|
|
333
|
+
title: this.formatFieldName(key),
|
|
334
|
+
value: this.formatFieldValue(value),
|
|
335
|
+
short: true,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Add truncation notice if needed
|
|
340
|
+
if (entries.length > this.config.maxAttributeFields) {
|
|
341
|
+
fields.push({
|
|
342
|
+
title: 'Note',
|
|
343
|
+
value: `... and ${entries.length - this.config.maxAttributeFields} more fields`,
|
|
344
|
+
short: false,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return fields;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Format field name (convert camelCase to Title Case)
|
|
353
|
+
*/
|
|
354
|
+
private formatFieldName(name: string): string {
|
|
355
|
+
return name
|
|
356
|
+
.replaceAll(/([A-Z])/g, ' $1') // Add space before capitals
|
|
357
|
+
.replace(/^./, (str) => str.toUpperCase()) // Capitalize first letter
|
|
358
|
+
.trim();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Format field value
|
|
363
|
+
*/
|
|
364
|
+
private formatFieldValue(value: any): string {
|
|
365
|
+
if (value === null || value === undefined) return 'N/A';
|
|
366
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
367
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
368
|
+
if (typeof value === 'number' && !Number.isInteger(value)) {
|
|
369
|
+
return value.toFixed(2);
|
|
370
|
+
}
|
|
371
|
+
return String(value);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Handle errors (override from EventSubscriber)
|
|
376
|
+
*/
|
|
377
|
+
protected handleError(error: Error, payload: EventPayload): void {
|
|
378
|
+
console.error(
|
|
379
|
+
`[SlackSubscriber] Failed to send ${payload.type} event "${payload.name}":`,
|
|
380
|
+
error
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming Events Subscriber Base Class
|
|
3
|
+
*
|
|
4
|
+
* Specialized base class for high-throughput streaming platforms like
|
|
5
|
+
* Kafka, Kinesis, Pub/Sub, etc.
|
|
6
|
+
*
|
|
7
|
+
* Extends EventSubscriber with streaming-specific features:
|
|
8
|
+
* - Partitioning strategy for ordered delivery
|
|
9
|
+
* - Buffer overflow handling (drop/block/disk)
|
|
10
|
+
* - High-throughput optimizations
|
|
11
|
+
* - Backpressure signaling
|
|
12
|
+
*
|
|
13
|
+
* @example Kafka Streaming Subscriber
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { StreamingEventSubscriber } from 'autotel-subscribers/streaming-event-subscriber';
|
|
16
|
+
*
|
|
17
|
+
* class KafkaSubscriber extends StreamingEventSubscriber {
|
|
18
|
+
* name = 'KafkaSubscriber';
|
|
19
|
+
* version = '1.0.0';
|
|
20
|
+
*
|
|
21
|
+
* constructor(config: KafkaConfig) {
|
|
22
|
+
* super({
|
|
23
|
+
* maxBufferSize: 10000,
|
|
24
|
+
* bufferOverflowStrategy: 'block',
|
|
25
|
+
* maxBatchSize: 500
|
|
26
|
+
* });
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* protected getPartitionKey(payload: EventPayload): string {
|
|
30
|
+
* // Partition by userId for ordered events per user
|
|
31
|
+
* return payload.attributes?.userId || 'default';
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* protected async sendBatch(events: EventPayload[]): Promise<void> {
|
|
35
|
+
* await this.producer.send({
|
|
36
|
+
* topic: this.topic,
|
|
37
|
+
* messages: events.map(e => ({
|
|
38
|
+
* key: this.getPartitionKey(e),
|
|
39
|
+
* value: JSON.stringify(e)
|
|
40
|
+
* }))
|
|
41
|
+
* });
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
EventSubscriber,
|
|
49
|
+
type EventPayload,
|
|
50
|
+
} from './event-subscriber-base';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Buffer overflow strategy
|
|
54
|
+
*
|
|
55
|
+
* - 'drop': Drop new events when buffer is full (prevents blocking, but loses data)
|
|
56
|
+
* - 'block': Wait for space in buffer (backpressure, may slow application)
|
|
57
|
+
* - 'disk': Spill to disk when memory buffer full (reliable, but complex - not implemented yet)
|
|
58
|
+
*/
|
|
59
|
+
export type BufferOverflowStrategy = 'drop' | 'block' | 'disk';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Streaming subscriber configuration
|
|
63
|
+
*/
|
|
64
|
+
export interface StreamingSubscriberConfig {
|
|
65
|
+
/** Maximum buffer size before triggering overflow strategy (default: 10000) */
|
|
66
|
+
maxBufferSize?: number;
|
|
67
|
+
|
|
68
|
+
/** Strategy when buffer is full (default: 'block') */
|
|
69
|
+
bufferOverflowStrategy?: BufferOverflowStrategy;
|
|
70
|
+
|
|
71
|
+
/** Maximum batch size for sending (default: 500) */
|
|
72
|
+
maxBatchSize?: number;
|
|
73
|
+
|
|
74
|
+
/** Flush interval in milliseconds (default: 1000) */
|
|
75
|
+
flushIntervalMs?: number;
|
|
76
|
+
|
|
77
|
+
/** Enable compression (default: false) */
|
|
78
|
+
compressionEnabled?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Buffer status for monitoring
|
|
83
|
+
*/
|
|
84
|
+
export interface BufferStatus {
|
|
85
|
+
/** Current number of events in buffer */
|
|
86
|
+
size: number;
|
|
87
|
+
|
|
88
|
+
/** Maximum capacity */
|
|
89
|
+
capacity: number;
|
|
90
|
+
|
|
91
|
+
/** Utilization percentage (0-100) */
|
|
92
|
+
utilization: number;
|
|
93
|
+
|
|
94
|
+
/** Is buffer near full (>80%) */
|
|
95
|
+
isNearFull: boolean;
|
|
96
|
+
|
|
97
|
+
/** Is buffer full (100%) */
|
|
98
|
+
isFull: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Streaming Events Subscriber Base Class
|
|
103
|
+
*
|
|
104
|
+
* Provides streaming-specific patterns on top of EventSubscriber.
|
|
105
|
+
*/
|
|
106
|
+
export abstract class StreamingEventSubscriber extends EventSubscriber {
|
|
107
|
+
protected config: Required<StreamingSubscriberConfig>;
|
|
108
|
+
protected buffer: EventPayload[] = [];
|
|
109
|
+
protected flushIntervalHandle: NodeJS.Timeout | null = null;
|
|
110
|
+
private isShuttingDown = false;
|
|
111
|
+
|
|
112
|
+
constructor(config: StreamingSubscriberConfig = {}) {
|
|
113
|
+
super();
|
|
114
|
+
|
|
115
|
+
// Set defaults
|
|
116
|
+
this.config = {
|
|
117
|
+
maxBufferSize: config.maxBufferSize ?? 10_000,
|
|
118
|
+
bufferOverflowStrategy: config.bufferOverflowStrategy ?? 'block',
|
|
119
|
+
maxBatchSize: config.maxBatchSize ?? 500,
|
|
120
|
+
flushIntervalMs: config.flushIntervalMs ?? 1000,
|
|
121
|
+
compressionEnabled: config.compressionEnabled ?? false,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Start periodic flushing
|
|
125
|
+
this.startFlushInterval();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get partition key for event
|
|
130
|
+
*
|
|
131
|
+
* Override this to implement your partitioning strategy.
|
|
132
|
+
* Events with the same partition key go to the same partition/shard.
|
|
133
|
+
*
|
|
134
|
+
* Common strategies:
|
|
135
|
+
* - By userId: Ordered events per user
|
|
136
|
+
* - By tenantId: Isolate tenants
|
|
137
|
+
* - By eventType: Group similar events
|
|
138
|
+
* - Round-robin: Load balancing
|
|
139
|
+
*
|
|
140
|
+
* @param payload - Event payload
|
|
141
|
+
* @returns Partition key (string)
|
|
142
|
+
*
|
|
143
|
+
* @example Partition by userId
|
|
144
|
+
* ```typescript
|
|
145
|
+
* protected getPartitionKey(payload: EventPayload): string {
|
|
146
|
+
* return payload.attributes?.userId || 'default';
|
|
147
|
+
* }
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
protected abstract getPartitionKey(payload: EventPayload): string;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Send batch of events to streaming platform
|
|
154
|
+
*
|
|
155
|
+
* Override this to implement platform-specific batch sending.
|
|
156
|
+
* Called when buffer reaches maxBatchSize or flush interval triggers.
|
|
157
|
+
*
|
|
158
|
+
* @param events - Batch of events to send
|
|
159
|
+
*/
|
|
160
|
+
protected abstract sendBatch(events: EventPayload[]): Promise<void>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Send single event to destination (from EventSubscriber)
|
|
164
|
+
*
|
|
165
|
+
* This buffers events and sends in batches for performance.
|
|
166
|
+
* Override sendBatch() instead of this method.
|
|
167
|
+
*/
|
|
168
|
+
protected async sendToDestination(payload: EventPayload): Promise<void> {
|
|
169
|
+
// Check buffer capacity before adding
|
|
170
|
+
await this.ensureBufferCapacity();
|
|
171
|
+
|
|
172
|
+
// Add to buffer
|
|
173
|
+
this.buffer.push(payload);
|
|
174
|
+
|
|
175
|
+
// Auto-flush if batch size reached
|
|
176
|
+
if (this.buffer.length >= this.config.maxBatchSize) {
|
|
177
|
+
await this.flushBuffer();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Ensure buffer has capacity for new event
|
|
183
|
+
*
|
|
184
|
+
* Implements buffer overflow strategy:
|
|
185
|
+
* - 'drop': Returns immediately (event will be added, oldest may be dropped)
|
|
186
|
+
* - 'block': Waits until space available (backpressure)
|
|
187
|
+
* - 'disk': Not implemented yet (would spill to disk)
|
|
188
|
+
*/
|
|
189
|
+
private async ensureBufferCapacity(): Promise<void> {
|
|
190
|
+
if (this.buffer.length < this.config.maxBufferSize) {
|
|
191
|
+
return; // Has space
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Buffer is full - apply overflow strategy
|
|
195
|
+
switch (this.config.bufferOverflowStrategy) {
|
|
196
|
+
case 'drop': {
|
|
197
|
+
// Drop oldest event to make space
|
|
198
|
+
this.buffer.shift();
|
|
199
|
+
console.warn(
|
|
200
|
+
`[${this.name}] Buffer full (${this.config.maxBufferSize}), dropped oldest event`
|
|
201
|
+
);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'block': {
|
|
206
|
+
// Wait for flush to complete (backpressure)
|
|
207
|
+
console.warn(
|
|
208
|
+
`[${this.name}] Buffer full (${this.config.maxBufferSize}), blocking until space available`
|
|
209
|
+
);
|
|
210
|
+
await this.flushBuffer();
|
|
211
|
+
|
|
212
|
+
// If still full after flush, wait a bit and retry
|
|
213
|
+
if (this.buffer.length >= this.config.maxBufferSize) {
|
|
214
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
215
|
+
await this.ensureBufferCapacity(); // Recursive retry
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'disk': {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[${this.name}] Disk overflow strategy not implemented yet`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Flush buffer to destination
|
|
230
|
+
*/
|
|
231
|
+
private async flushBuffer(): Promise<void> {
|
|
232
|
+
if (this.buffer.length === 0) return;
|
|
233
|
+
|
|
234
|
+
const batch = [...this.buffer];
|
|
235
|
+
this.buffer = [];
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await this.sendBatch(batch);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error(
|
|
241
|
+
`[${this.name}] Failed to send batch of ${batch.length} events:`,
|
|
242
|
+
error
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// On failure, put events back in buffer (at front)
|
|
246
|
+
this.buffer.unshift(...batch);
|
|
247
|
+
|
|
248
|
+
// If we're near capacity, we need to make a decision
|
|
249
|
+
if (this.buffer.length >= this.config.maxBufferSize * 0.9 && this.config.bufferOverflowStrategy === 'drop') {
|
|
250
|
+
// Drop oldest to prevent runaway growth
|
|
251
|
+
const toDrop = Math.floor(this.config.maxBufferSize * 0.1);
|
|
252
|
+
this.buffer.splice(0, toDrop);
|
|
253
|
+
console.warn(
|
|
254
|
+
`[${this.name}] After failed flush, dropped ${toDrop} oldest events to prevent overflow`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Start periodic flushing
|
|
262
|
+
*/
|
|
263
|
+
private startFlushInterval(): void {
|
|
264
|
+
this.flushIntervalHandle = setInterval(() => {
|
|
265
|
+
if (!this.isShuttingDown) {
|
|
266
|
+
void this.flushBuffer();
|
|
267
|
+
}
|
|
268
|
+
}, this.config.flushIntervalMs);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get current buffer status (for monitoring/observability)
|
|
273
|
+
*/
|
|
274
|
+
public getBufferStatus(): BufferStatus {
|
|
275
|
+
const size = this.buffer.length;
|
|
276
|
+
const capacity = this.config.maxBufferSize;
|
|
277
|
+
const utilization = Math.round((size / capacity) * 100);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
size,
|
|
281
|
+
capacity,
|
|
282
|
+
utilization,
|
|
283
|
+
isNearFull: utilization > 80,
|
|
284
|
+
isFull: utilization >= 100,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Shutdown with proper buffer draining
|
|
290
|
+
*/
|
|
291
|
+
async shutdown(): Promise<void> {
|
|
292
|
+
this.isShuttingDown = true;
|
|
293
|
+
|
|
294
|
+
// Stop flush interval (no more automatic flushes)
|
|
295
|
+
if (this.flushIntervalHandle) {
|
|
296
|
+
clearInterval(this.flushIntervalHandle);
|
|
297
|
+
this.flushIntervalHandle = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Call parent shutdown FIRST to:
|
|
301
|
+
// 1. Set enabled = false (stop accepting new events)
|
|
302
|
+
// 2. Drain any pending sendToDestination() calls
|
|
303
|
+
await super.shutdown();
|
|
304
|
+
|
|
305
|
+
// THEN flush remaining buffer
|
|
306
|
+
// (no new events can arrive after super.shutdown() disabled the subscriber)
|
|
307
|
+
await this.flushBuffer();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Optional: Compress payload before sending
|
|
312
|
+
*
|
|
313
|
+
* Override this if your streaming platform supports compression.
|
|
314
|
+
* Only called if compressionEnabled = true.
|
|
315
|
+
*/
|
|
316
|
+
protected async compressPayload(
|
|
317
|
+
payload: string
|
|
318
|
+
): Promise<Buffer | string> {
|
|
319
|
+
// Default: no compression
|
|
320
|
+
// Override with gzip, snappy, lz4, etc.
|
|
321
|
+
return payload;
|
|
322
|
+
}
|
|
323
|
+
}
|