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
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
+ }