@od-oneapp/analytics 2026.1.1301

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 (184) hide show
  1. package/README.md +509 -0
  2. package/dist/ai-YMnynb-t.mjs +3347 -0
  3. package/dist/ai-YMnynb-t.mjs.map +1 -0
  4. package/dist/chunk-DQk6qfdC.mjs +18 -0
  5. package/dist/client-CTzJVFU5.mjs +9 -0
  6. package/dist/client-CTzJVFU5.mjs.map +1 -0
  7. package/dist/client-CcFTauAh.mjs +54 -0
  8. package/dist/client-CcFTauAh.mjs.map +1 -0
  9. package/dist/client-CeOLjbac.mjs +281 -0
  10. package/dist/client-CeOLjbac.mjs.map +1 -0
  11. package/dist/client-D339NFJS.mjs +267 -0
  12. package/dist/client-D339NFJS.mjs.map +1 -0
  13. package/dist/client-next.d.mts +62 -0
  14. package/dist/client-next.d.mts.map +1 -0
  15. package/dist/client-next.mjs +525 -0
  16. package/dist/client-next.mjs.map +1 -0
  17. package/dist/client.d.mts +30 -0
  18. package/dist/client.d.mts.map +1 -0
  19. package/dist/client.mjs +186 -0
  20. package/dist/client.mjs.map +1 -0
  21. package/dist/config-DPS6bSYo.d.mts +34 -0
  22. package/dist/config-DPS6bSYo.d.mts.map +1 -0
  23. package/dist/config-P6P5adJg.mjs +287 -0
  24. package/dist/config-P6P5adJg.mjs.map +1 -0
  25. package/dist/console-8bND3mMU.mjs +128 -0
  26. package/dist/console-8bND3mMU.mjs.map +1 -0
  27. package/dist/ecommerce-Cgu4wlux.mjs +993 -0
  28. package/dist/ecommerce-Cgu4wlux.mjs.map +1 -0
  29. package/dist/emitters-6-nKo8i-.mjs +208 -0
  30. package/dist/emitters-6-nKo8i-.mjs.map +1 -0
  31. package/dist/emitters-DldkVSPp.d.mts +12 -0
  32. package/dist/emitters-DldkVSPp.d.mts.map +1 -0
  33. package/dist/index-BfNWgfa5.d.mts +1494 -0
  34. package/dist/index-BfNWgfa5.d.mts.map +1 -0
  35. package/dist/index-BkIWe--N.d.mts +953 -0
  36. package/dist/index-BkIWe--N.d.mts.map +1 -0
  37. package/dist/index-jPzXRn52.d.mts +184 -0
  38. package/dist/index-jPzXRn52.d.mts.map +1 -0
  39. package/dist/manager-DvRRjza6.d.mts +76 -0
  40. package/dist/manager-DvRRjza6.d.mts.map +1 -0
  41. package/dist/posthog-bootstrap-CYfIy_WS.mjs +1769 -0
  42. package/dist/posthog-bootstrap-CYfIy_WS.mjs.map +1 -0
  43. package/dist/posthog-bootstrap-DWxFrxlt.d.mts +81 -0
  44. package/dist/posthog-bootstrap-DWxFrxlt.d.mts.map +1 -0
  45. package/dist/providers-http-client.d.mts +37 -0
  46. package/dist/providers-http-client.d.mts.map +1 -0
  47. package/dist/providers-http-client.mjs +320 -0
  48. package/dist/providers-http-client.mjs.map +1 -0
  49. package/dist/providers-http-server.d.mts +31 -0
  50. package/dist/providers-http-server.d.mts.map +1 -0
  51. package/dist/providers-http-server.mjs +297 -0
  52. package/dist/providers-http-server.mjs.map +1 -0
  53. package/dist/providers-http.d.mts +46 -0
  54. package/dist/providers-http.d.mts.map +1 -0
  55. package/dist/providers-http.mjs +4 -0
  56. package/dist/server-edge.d.mts +9 -0
  57. package/dist/server-edge.d.mts.map +1 -0
  58. package/dist/server-edge.mjs +373 -0
  59. package/dist/server-edge.mjs.map +1 -0
  60. package/dist/server-next.d.mts +67 -0
  61. package/dist/server-next.d.mts.map +1 -0
  62. package/dist/server-next.mjs +193 -0
  63. package/dist/server-next.mjs.map +1 -0
  64. package/dist/server.d.mts +10 -0
  65. package/dist/server.mjs +7 -0
  66. package/dist/service-cYtBBL8x.mjs +945 -0
  67. package/dist/service-cYtBBL8x.mjs.map +1 -0
  68. package/dist/shared.d.mts +16 -0
  69. package/dist/shared.d.mts.map +1 -0
  70. package/dist/shared.mjs +93 -0
  71. package/dist/shared.mjs.map +1 -0
  72. package/dist/types-BxBnNQ0V.d.mts +354 -0
  73. package/dist/types-BxBnNQ0V.d.mts.map +1 -0
  74. package/dist/types-CBvxUEaF.d.mts +216 -0
  75. package/dist/types-CBvxUEaF.d.mts.map +1 -0
  76. package/dist/types.d.mts +4 -0
  77. package/dist/types.mjs +0 -0
  78. package/dist/vercel-types-lwakUfoI.d.mts +102 -0
  79. package/dist/vercel-types-lwakUfoI.d.mts.map +1 -0
  80. package/package.json +129 -0
  81. package/src/client/index.ts +164 -0
  82. package/src/client/manager.ts +71 -0
  83. package/src/client/next/components.tsx +270 -0
  84. package/src/client/next/hooks.ts +217 -0
  85. package/src/client/next/manager.ts +141 -0
  86. package/src/client/next.ts +144 -0
  87. package/src/client-next.ts +101 -0
  88. package/src/client.ts +89 -0
  89. package/src/examples/ai-sdk-patterns.ts +583 -0
  90. package/src/examples/emitter-patterns.ts +476 -0
  91. package/src/examples/nextjs-emitter-patterns.tsx +403 -0
  92. package/src/next/app-router.tsx +564 -0
  93. package/src/next/client.ts +419 -0
  94. package/src/next/index.ts +84 -0
  95. package/src/next/middleware.ts +429 -0
  96. package/src/next/rsc.tsx +300 -0
  97. package/src/next/server.ts +253 -0
  98. package/src/next/types.d.ts +220 -0
  99. package/src/providers/base-provider.ts +419 -0
  100. package/src/providers/console/client.ts +10 -0
  101. package/src/providers/console/index.ts +152 -0
  102. package/src/providers/console/server.ts +6 -0
  103. package/src/providers/console/types.ts +15 -0
  104. package/src/providers/http/client.ts +464 -0
  105. package/src/providers/http/index.ts +30 -0
  106. package/src/providers/http/server.ts +396 -0
  107. package/src/providers/http/types.ts +135 -0
  108. package/src/providers/posthog/client.ts +518 -0
  109. package/src/providers/posthog/index.ts +11 -0
  110. package/src/providers/posthog/server.ts +329 -0
  111. package/src/providers/posthog/types.ts +104 -0
  112. package/src/providers/segment/client.ts +113 -0
  113. package/src/providers/segment/index.ts +11 -0
  114. package/src/providers/segment/server.ts +115 -0
  115. package/src/providers/segment/types.ts +51 -0
  116. package/src/providers/vercel/client.ts +102 -0
  117. package/src/providers/vercel/index.ts +11 -0
  118. package/src/providers/vercel/server.ts +89 -0
  119. package/src/providers/vercel/types.ts +27 -0
  120. package/src/server/index.ts +103 -0
  121. package/src/server/manager.ts +62 -0
  122. package/src/server/next.ts +210 -0
  123. package/src/server-edge.ts +442 -0
  124. package/src/server-next.ts +39 -0
  125. package/src/server.ts +106 -0
  126. package/src/shared/emitters/ai/README.md +981 -0
  127. package/src/shared/emitters/ai/events/agent.ts +130 -0
  128. package/src/shared/emitters/ai/events/artifacts.ts +167 -0
  129. package/src/shared/emitters/ai/events/chat.ts +126 -0
  130. package/src/shared/emitters/ai/events/chatbot-ecommerce.ts +133 -0
  131. package/src/shared/emitters/ai/events/completion.ts +103 -0
  132. package/src/shared/emitters/ai/events/content-generation.ts +347 -0
  133. package/src/shared/emitters/ai/events/conversation.ts +332 -0
  134. package/src/shared/emitters/ai/events/product-features.ts +1402 -0
  135. package/src/shared/emitters/ai/events/streaming.ts +114 -0
  136. package/src/shared/emitters/ai/events/tool.ts +93 -0
  137. package/src/shared/emitters/ai/index.ts +69 -0
  138. package/src/shared/emitters/ai/track-ai-sdk.ts +74 -0
  139. package/src/shared/emitters/ai/track-ai.ts +50 -0
  140. package/src/shared/emitters/ai/types.ts +1041 -0
  141. package/src/shared/emitters/ai/utils.ts +468 -0
  142. package/src/shared/emitters/ecommerce/events/cart-checkout.ts +106 -0
  143. package/src/shared/emitters/ecommerce/events/coupon.ts +49 -0
  144. package/src/shared/emitters/ecommerce/events/engagement.ts +61 -0
  145. package/src/shared/emitters/ecommerce/events/marketplace.ts +119 -0
  146. package/src/shared/emitters/ecommerce/events/order.ts +199 -0
  147. package/src/shared/emitters/ecommerce/events/product.ts +205 -0
  148. package/src/shared/emitters/ecommerce/events/registry.ts +123 -0
  149. package/src/shared/emitters/ecommerce/events/wishlist-sharing.ts +140 -0
  150. package/src/shared/emitters/ecommerce/index.ts +46 -0
  151. package/src/shared/emitters/ecommerce/track-ecommerce.ts +53 -0
  152. package/src/shared/emitters/ecommerce/types.ts +314 -0
  153. package/src/shared/emitters/ecommerce/utils.ts +216 -0
  154. package/src/shared/emitters/emitter-types.ts +974 -0
  155. package/src/shared/emitters/emitters.ts +292 -0
  156. package/src/shared/emitters/helpers.ts +419 -0
  157. package/src/shared/emitters/index.ts +66 -0
  158. package/src/shared/index.ts +142 -0
  159. package/src/shared/ingestion/index.ts +66 -0
  160. package/src/shared/ingestion/schemas.ts +386 -0
  161. package/src/shared/ingestion/service.ts +628 -0
  162. package/src/shared/node22-features.ts +848 -0
  163. package/src/shared/providers/console-provider.ts +160 -0
  164. package/src/shared/types/base-types.ts +54 -0
  165. package/src/shared/types/console-types.ts +19 -0
  166. package/src/shared/types/posthog-types.ts +131 -0
  167. package/src/shared/types/segment-types.ts +15 -0
  168. package/src/shared/types/types.ts +397 -0
  169. package/src/shared/types/vercel-types.ts +19 -0
  170. package/src/shared/utils/config-client.ts +19 -0
  171. package/src/shared/utils/config.ts +250 -0
  172. package/src/shared/utils/emitter-adapter.ts +212 -0
  173. package/src/shared/utils/manager.test.ts +36 -0
  174. package/src/shared/utils/manager.ts +1322 -0
  175. package/src/shared/utils/posthog-bootstrap.ts +136 -0
  176. package/src/shared/utils/posthog-client-utils.ts +48 -0
  177. package/src/shared/utils/posthog-next-utils.ts +282 -0
  178. package/src/shared/utils/posthog-server-utils.ts +210 -0
  179. package/src/shared/utils/rate-limit.ts +289 -0
  180. package/src/shared/utils/security.ts +545 -0
  181. package/src/shared/utils/validation-client.ts +161 -0
  182. package/src/shared/utils/validation.ts +399 -0
  183. package/src/shared.ts +155 -0
  184. package/src/types/index.ts +62 -0
@@ -0,0 +1,1322 @@
1
+ /**
2
+ * @fileoverview Analytics Manager - Core orchestration for multi-provider analytics
3
+ *
4
+ * This module provides the core AnalyticsManager class that orchestrates multiple
5
+ * analytics providers. It includes:
6
+ *
7
+ * - **Multi-Provider Support**: Manages multiple providers simultaneously
8
+ * - **Event Deduplication**: LRU cache prevents duplicate events
9
+ * - **Rate Limiting**: Per-provider rate limiting (100 calls/second default)
10
+ * - **Batch Processing**: Concurrent batch processing with configurable concurrency
11
+ * - **Performance Metrics**: Tracks provider performance and reliability
12
+ * - **Error Handling**: Comprehensive error handling with graceful degradation
13
+ *
14
+ * **Node.js 22+ Features Used**:
15
+ * - `Promise.withResolvers()`: External promise control for complex workflows
16
+ * - `AbortSignal.timeout()`: Context-aware timeouts for provider operations
17
+ * - High-resolution timing: Precise performance measurement (`process.hrtime.bigint()`)
18
+ * - `Object.hasOwn()`: Safer property existence checks
19
+ *
20
+ * **Optimizations**:
21
+ * - Spread operator instead of `structuredClone()` for better performance
22
+ * - LRU cache for event deduplication (prevents memory leaks)
23
+ * - Concurrent batch processing with configurable concurrency
24
+ * - Comprehensive input sanitization and validation
25
+ * - Per-provider rate limiting
26
+ *
27
+ * @module @od-oneapp/analytics/shared/utils/manager
28
+ */
29
+
30
+ import { RateLimiter } from './rate-limit';
31
+ import { sanitizeProperties, validateEventName } from './security';
32
+
33
+ import type {
34
+ EmitterAliasPayload,
35
+ EmitterGroupPayload,
36
+ EmitterIdentifyPayload,
37
+ EmitterPagePayload,
38
+ EmitterPayload,
39
+ EmitterScreenPayload,
40
+ EmitterTrackPayload,
41
+ } from '../emitters/emitter-types';
42
+ import type {
43
+ AnalyticsConfig,
44
+ AnalyticsContext,
45
+ AnalyticsProvider,
46
+ EcommerceEventSpec,
47
+ GroupTraits,
48
+ PageProperties,
49
+ Properties,
50
+ PropertyObject,
51
+ ProviderRegistry,
52
+ TrackingOptions,
53
+ UserTraits,
54
+ } from '../types/types';
55
+
56
+ /**
57
+ * LRU Cache for event deduplication.
58
+ *
59
+ * Prevents memory leaks by limiting cache size. Uses least-recently-used eviction
60
+ * policy to maintain bounded memory usage.
61
+ *
62
+ * @template K - Cache key type
63
+ * @template V - Cache value type
64
+ *
65
+ * @internal
66
+ */
67
+ class LRUCache<K, V> {
68
+ private cache = new Map<K, V>();
69
+ private readonly maxSize: number;
70
+
71
+ constructor(maxSize: number = 1000) {
72
+ this.maxSize = maxSize;
73
+ }
74
+
75
+ get(key: K): V | undefined {
76
+ const value = this.cache.get(key);
77
+ if (value !== undefined) {
78
+ // Move to end (most recently used)
79
+ this.cache.delete(key);
80
+ this.cache.set(key, value);
81
+ }
82
+ return value;
83
+ }
84
+
85
+ set(key: K, value: V): void {
86
+ // Remove if exists (to re-add at end)
87
+ if (this.cache.has(key)) {
88
+ this.cache.delete(key);
89
+ }
90
+
91
+ // Add to end
92
+ this.cache.set(key, value);
93
+
94
+ // Evict oldest if over capacity
95
+ if (this.cache.size > this.maxSize) {
96
+ const firstKey = this.cache.keys().next().value;
97
+ if (firstKey !== undefined) {
98
+ this.cache.delete(firstKey);
99
+ }
100
+ }
101
+ }
102
+
103
+ has(key: K): boolean {
104
+ return this.cache.get(key) !== undefined;
105
+ }
106
+
107
+ clear(): void {
108
+ this.cache.clear();
109
+ }
110
+
111
+ get size(): number {
112
+ return this.cache.size;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Provider metrics for tracking performance and reliability.
118
+ *
119
+ * Tracks initialization time, call counts, error counts, and last usage time
120
+ * for each provider to monitor performance and reliability.
121
+ *
122
+ * @internal
123
+ */
124
+ interface ProviderMetrics {
125
+ initTime: bigint;
126
+ callCount: number;
127
+ errorCount: number;
128
+ lastUsed: bigint;
129
+ }
130
+
131
+ /**
132
+ * Event metadata for deduplication and tracking.
133
+ *
134
+ * Stores metadata about events to prevent duplicate processing and track
135
+ * event processing status.
136
+ *
137
+ * @internal
138
+ */
139
+ interface EventMetadata {
140
+ timestamp: bigint;
141
+ processed: boolean;
142
+ }
143
+
144
+ /**
145
+ * Analytics Manager - Main entry point for analytics tracking.
146
+ *
147
+ * Orchestrates multiple analytics providers, handles event deduplication,
148
+ * rate limiting, batch processing, and error handling.
149
+ *
150
+ * **Key Features**:
151
+ * - Multi-provider support (PostHog, Segment, Vercel, Console)
152
+ * - Event deduplication via LRU cache
153
+ * - Rate limiting (100 calls/second per provider)
154
+ * - Batch processing with concurrency control
155
+ * - Performance metrics tracking
156
+ * - Graceful error handling
157
+ *
158
+ * **Usage**:
159
+ * ```typescript
160
+ * const manager = new AnalyticsManager(config, providerRegistry);
161
+ * await manager.initialize();
162
+ * await manager.emit(track('Event Name', { property: 'value' }));
163
+ * ```
164
+ */
165
+ export class AnalyticsManager {
166
+ private providers = new Map<string, AnalyticsProvider>();
167
+ private context: AnalyticsContext = {};
168
+ private isInitialized = false;
169
+
170
+ // Provider metrics tracking (provider name -> metrics)
171
+ private readonly providerMetrics = new Map<string, ProviderMetrics>();
172
+
173
+ // Event deduplication with LRU cache (prevents memory leaks)
174
+ private readonly eventCache = new LRUCache<string, EventMetadata>(1000);
175
+
176
+ // Rate limiting per provider (100 calls per second default)
177
+ private readonly rateLimiter = new RateLimiter({
178
+ maxCalls: 100,
179
+ windowMs: 1000,
180
+ maxConcurrent: 10,
181
+ queueExcess: false,
182
+ });
183
+
184
+ constructor(
185
+ private config: AnalyticsConfig,
186
+ private availableProviders: ProviderRegistry,
187
+ ) {}
188
+
189
+ /**
190
+ * Initialize all configured providers
191
+ * Throws error if all providers fail to initialize
192
+ */
193
+ async initialize(): Promise<void> {
194
+ if (this.isInitialized) return;
195
+
196
+ const initPromises: Promise<void>[] = [];
197
+ const initStartTime = process.hrtime.bigint();
198
+
199
+ for (const [providerName, providerConfig] of Object.entries(this.config.providers)) {
200
+ const providerFactory = this.availableProviders[providerName];
201
+
202
+ if (providerFactory) {
203
+ try {
204
+ const provider = providerFactory(providerConfig);
205
+ this.providers.set(providerName, provider);
206
+
207
+ // Initialize provider with enhanced error handling and timeout (Node 22+)
208
+ initPromises.push(
209
+ (async () => {
210
+ const providerInitStart: bigint = typeof process.hrtime?.bigint === 'function'
211
+ ? process.hrtime.bigint()
212
+ : BigInt(Date.now() * 1000000); // Fallback for non-Node environments
213
+
214
+ try {
215
+ // Use AbortSignal.timeout() for initialization timeout (Node 22+)
216
+ const timeoutSignal = AbortSignal.timeout(10000); // 10 second timeout
217
+
218
+ const initPromise = provider.initialize(providerConfig);
219
+
220
+ // Race against timeout
221
+ await Promise.race([
222
+ initPromise,
223
+ new Promise<void>((_resolve, reject) => {
224
+ timeoutSignal.addEventListener('abort', () =>
225
+ reject(new Error(`Provider ${providerName} initialization timed out`)),
226
+ );
227
+ }),
228
+ ]);
229
+
230
+ // Track successful initialization metrics
231
+ const now: bigint = typeof process.hrtime?.bigint === 'function'
232
+ ? process.hrtime.bigint()
233
+ : BigInt(Date.now() * 1000000); // Fallback for non-Node environments
234
+ this.providerMetrics.set(providerName, {
235
+ initTime: now - providerInitStart,
236
+ callCount: 0,
237
+ errorCount: 0,
238
+ lastUsed: now,
239
+ });
240
+ } catch (error) {
241
+ if (this.config.onError) {
242
+ this.config.onError(error, {
243
+ provider: providerName,
244
+ method: 'initialize',
245
+ });
246
+ }
247
+ // Remove failed provider to ensure it doesn't affect others
248
+ this.providers.delete(providerName);
249
+ throw error; // Re-throw to track failure
250
+ }
251
+ })(),
252
+ );
253
+ } catch (error) {
254
+ if (this.config.onError) {
255
+ this.config.onError(error, {
256
+ provider: providerName,
257
+ method: 'create',
258
+ });
259
+ }
260
+ }
261
+ } else if (this.config.debug && this.config.onInfo) {
262
+ this.config.onInfo(`Provider ${providerName} not available in this environment`);
263
+ }
264
+ }
265
+
266
+ // Wait for all providers to initialize with enhanced monitoring
267
+ const results = await Promise.allSettled(initPromises);
268
+ const initEndTime = process.hrtime.bigint();
269
+
270
+ const successCount = results.filter(r => r.status === 'fulfilled').length;
271
+ const failureCount = results.filter(r => r.status === 'rejected').length;
272
+
273
+ // FIX #10: Throw error if all providers fail
274
+ if (successCount === 0 && initPromises.length > 0) {
275
+ throw new Error(
276
+ `Analytics initialization failed: All ${initPromises.length} providers failed to initialize`,
277
+ );
278
+ }
279
+
280
+ this.isInitialized = true;
281
+
282
+ if (this.config.debug && this.config.onInfo) {
283
+ const initTimeMs = Number(initEndTime - initStartTime) / 1_000_000;
284
+ this.config.onInfo(
285
+ `Analytics initialized in ${initTimeMs.toFixed(2)}ms with ${successCount} providers ` +
286
+ `(${failureCount} failed): ${[...this.providers.keys()].join(', ')}`,
287
+ );
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Set global analytics context
293
+ * FIX #13: Use spread operator instead of structuredClone for better performance
294
+ */
295
+ setContext(context: AnalyticsContext): void {
296
+ // Use spread operator for better performance
297
+ this.context = { ...this.context, ...context };
298
+
299
+ // Update context on providers that support it with enhanced error handling
300
+ for (const [providerName, provider] of this.providers) {
301
+ if (provider.setContext) {
302
+ try {
303
+ // Use spread operator instead of structuredClone
304
+ provider.setContext({ ...this.context });
305
+
306
+ // Update metrics for successful context update
307
+ const metrics = this.providerMetrics.get(providerName);
308
+ if (metrics) {
309
+ metrics.lastUsed = process.hrtime.bigint();
310
+ }
311
+ } catch (error) {
312
+ if (this.config.onError) {
313
+ this.config.onError(error, {
314
+ provider: providerName,
315
+ method: 'setContext',
316
+ context: Object.keys(context).join(', '),
317
+ });
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Get current analytics context
326
+ * FIX #13: Use spread operator instead of structuredClone
327
+ */
328
+ getContext(): AnalyticsContext {
329
+ return { ...this.context };
330
+ }
331
+
332
+ /**
333
+ * Track an event
334
+ * FIX #3: Replace any types with proper TypeScript types
335
+ * FIX #14/#15: Integrate security utilities and rate limiting
336
+ */
337
+ async track(payload: EmitterTrackPayload): Promise<void>;
338
+ async track(event: string, properties?: Properties, options?: TrackingOptions): Promise<void>;
339
+ async track(
340
+ eventOrPayload: string | EmitterTrackPayload,
341
+ properties?: Properties,
342
+ options?: TrackingOptions,
343
+ ): Promise<void> {
344
+ // If first argument is an emitter payload, use it
345
+ if (typeof eventOrPayload === 'object') {
346
+ const payload = eventOrPayload;
347
+ return this.track(payload.event, payload.properties, {
348
+ ...options,
349
+ // Merge context from payload
350
+ context: { ...this.context, ...payload.context },
351
+ });
352
+ }
353
+
354
+ // Traditional track call
355
+ const event = eventOrPayload;
356
+
357
+ if (!this.isInitialized) {
358
+ if (this.config.onError) {
359
+ this.config.onError(new Error('Analytics not initialized'), {
360
+ provider: 'analytics',
361
+ event,
362
+ method: 'track',
363
+ });
364
+ }
365
+ return;
366
+ }
367
+
368
+ // FIX #14: Validate event name
369
+ const eventValidation = validateEventName(event);
370
+ if (!eventValidation.valid) {
371
+ if (this.config.onError) {
372
+ this.config.onError(new Error(`Invalid event name: ${eventValidation.reason}`), {
373
+ provider: 'analytics',
374
+ event,
375
+ method: 'track',
376
+ });
377
+ }
378
+ return;
379
+ }
380
+
381
+ // FIX #14: Sanitize properties
382
+ const sanitized = sanitizeProperties(properties ?? {}, {
383
+ stripPII: process.env.NODE_ENV === 'production',
384
+ stripHTML: true,
385
+ allowDangerousKeys: false,
386
+ });
387
+
388
+ const targetProviders = this.getTargetProviders(options);
389
+ const enhancedProperties = { ...this.context, ...sanitized.data };
390
+
391
+ const promises = [...targetProviders.entries()].map(async ([name, provider]) => {
392
+ // FIX #15: Apply rate limiting
393
+ const allowed = await this.rateLimiter.tryAcquire();
394
+ if (!allowed) {
395
+ if (this.config.debug && this.config.onInfo) {
396
+ this.config.onInfo(`Rate limit exceeded for provider ${name}`);
397
+ }
398
+ // Report rate limit rejection to onError handler to prevent silent event loss
399
+ if (this.config.onError) {
400
+ this.config.onError(new Error(`Rate limit exceeded for provider ${name}`), {
401
+ provider: name,
402
+ event,
403
+ method: 'track',
404
+ });
405
+ }
406
+ return;
407
+ }
408
+
409
+ try {
410
+ await provider.track(event, enhancedProperties as PropertyObject, this.context);
411
+
412
+ // Update metrics
413
+ const metrics = this.providerMetrics.get(name);
414
+ if (metrics) {
415
+ metrics.callCount++;
416
+ metrics.lastUsed = process.hrtime.bigint();
417
+ }
418
+ } catch (error) {
419
+ // Update error metrics
420
+ const metrics = this.providerMetrics.get(name);
421
+ if (metrics) {
422
+ metrics.errorCount++;
423
+ }
424
+
425
+ // Error boundary: report error but don't let it affect other providers
426
+ if (this.config.onError) {
427
+ this.config.onError(error, {
428
+ provider: name,
429
+ event,
430
+ method: 'track',
431
+ });
432
+ }
433
+ } finally {
434
+ this.rateLimiter.release();
435
+ }
436
+ });
437
+
438
+ // Use allSettled to ensure all providers are called even if some fail
439
+ await Promise.allSettled(promises);
440
+ }
441
+
442
+ /**
443
+ * Identify a user
444
+ * FIX #3: Replace any types with proper TypeScript types
445
+ */
446
+ async identify(payload: EmitterIdentifyPayload): Promise<void>;
447
+ async identify(userId: string, traits?: UserTraits, options?: TrackingOptions): Promise<void>;
448
+ async identify(
449
+ userIdOrPayload: string | EmitterIdentifyPayload,
450
+ traits?: UserTraits,
451
+ options?: TrackingOptions,
452
+ ): Promise<void> {
453
+ // If first argument is an emitter payload, use it
454
+ if (typeof userIdOrPayload === 'object') {
455
+ const payload = userIdOrPayload;
456
+ return this.identify(payload.userId, payload.traits, {
457
+ ...options,
458
+ // Merge context from payload
459
+ context: { ...this.context, ...payload.context },
460
+ });
461
+ }
462
+
463
+ // Traditional identify call
464
+ const userId = userIdOrPayload;
465
+
466
+ if (!this.isInitialized) {
467
+ if (this.config.onError) {
468
+ this.config.onError(new Error('Analytics not initialized'), {
469
+ provider: 'analytics',
470
+ method: 'identify',
471
+ userId,
472
+ });
473
+ }
474
+ return;
475
+ }
476
+
477
+ // Sanitize traits
478
+ const sanitized = sanitizeProperties(traits ?? {}, {
479
+ stripPII: process.env.NODE_ENV === 'production',
480
+ stripHTML: true,
481
+ allowDangerousKeys: false,
482
+ });
483
+
484
+ // Update context with user info
485
+ this.setContext({ userId, ...sanitized.data });
486
+
487
+ const targetProviders = this.getTargetProviders(options);
488
+ const enhancedTraits = { ...sanitized.data } as UserTraits;
489
+
490
+ const promises = [...targetProviders.entries()].map(async ([name, provider]) => {
491
+ if (provider.identify) {
492
+ const allowed = await this.rateLimiter.tryAcquire();
493
+ if (!allowed) {
494
+ if (this.config.debug && this.config.onInfo) {
495
+ this.config.onInfo(`Rate limit exceeded for provider ${name}`);
496
+ }
497
+ // Report rate limit rejection to onError handler to prevent silent event loss
498
+ if (this.config.onError) {
499
+ this.config.onError(new Error(`Rate limit exceeded for provider ${name}`), {
500
+ provider: name,
501
+ userId,
502
+ method: 'identify',
503
+ });
504
+ }
505
+ return;
506
+ }
507
+
508
+ try {
509
+ // Pass context as TrackingOptions (provider.identify expects TrackingOptions as third parameter)
510
+ await provider.identify(userId, enhancedTraits, {
511
+ ...options,
512
+ context: this.context,
513
+ });
514
+
515
+ const metrics = this.providerMetrics.get(name);
516
+ if (metrics) {
517
+ metrics.callCount++;
518
+ metrics.lastUsed = process.hrtime.bigint();
519
+ }
520
+ } catch (error) {
521
+ const metrics = this.providerMetrics.get(name);
522
+ if (metrics) {
523
+ metrics.errorCount++;
524
+ }
525
+
526
+ if (this.config.onError) {
527
+ this.config.onError(error, {
528
+ provider: name,
529
+ method: 'identify',
530
+ userId,
531
+ });
532
+ }
533
+ } finally {
534
+ this.rateLimiter.release();
535
+ }
536
+ }
537
+ });
538
+
539
+ await Promise.allSettled(promises);
540
+ }
541
+
542
+ /**
543
+ * Track a page view
544
+ * FIX #3: Replace any types with proper TypeScript types
545
+ */
546
+ async page(payload: EmitterPagePayload): Promise<void>;
547
+ async page(name?: string, properties?: PageProperties, options?: TrackingOptions): Promise<void>;
548
+ async page(
549
+ nameOrPayload?: string | EmitterPagePayload,
550
+ properties?: PageProperties,
551
+ options?: TrackingOptions,
552
+ ): Promise<void> {
553
+ // If first argument is an emitter payload, use it
554
+ if (typeof nameOrPayload === 'object') {
555
+ const payload = nameOrPayload;
556
+ return this.page(payload.name, payload.properties, {
557
+ ...options,
558
+ // Merge context from payload
559
+ context: { ...this.context, ...payload.context },
560
+ });
561
+ }
562
+
563
+ // Traditional page call
564
+ const name = nameOrPayload;
565
+
566
+ if (!this.isInitialized) {
567
+ if (this.config.onError) {
568
+ this.config.onError(new Error('Analytics not initialized'), {
569
+ provider: 'analytics',
570
+ name,
571
+ method: 'page',
572
+ });
573
+ }
574
+ return;
575
+ }
576
+
577
+ // Sanitize properties
578
+ const sanitized = sanitizeProperties(properties ?? {}, {
579
+ stripPII: process.env.NODE_ENV === 'production',
580
+ stripHTML: true,
581
+ allowDangerousKeys: false,
582
+ });
583
+
584
+ const targetProviders = this.getTargetProviders(options);
585
+ const enhancedProperties = { ...sanitized.data } as PageProperties;
586
+
587
+ const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
588
+ if (provider.page) {
589
+ const allowed = await this.rateLimiter.tryAcquire();
590
+ if (!allowed) {
591
+ if (this.config.debug && this.config.onInfo) {
592
+ this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
593
+ }
594
+ // Report rate limit rejection to onError handler to prevent silent event loss
595
+ if (this.config.onError) {
596
+ this.config.onError(new Error(`Rate limit exceeded for provider ${providerName}`), {
597
+ provider: providerName,
598
+ name,
599
+ method: 'page',
600
+ });
601
+ }
602
+ return;
603
+ }
604
+
605
+ try {
606
+ // Pass context as TrackingOptions (provider.page expects TrackingOptions as third parameter)
607
+ await provider.page(name ?? '', enhancedProperties, {
608
+ ...options,
609
+ context: this.context,
610
+ });
611
+
612
+ const metrics = this.providerMetrics.get(providerName);
613
+ if (metrics) {
614
+ metrics.callCount++;
615
+ metrics.lastUsed = process.hrtime.bigint();
616
+ }
617
+ } catch (error) {
618
+ const metrics = this.providerMetrics.get(providerName);
619
+ if (metrics) {
620
+ metrics.errorCount++;
621
+ }
622
+
623
+ if (this.config.onError) {
624
+ this.config.onError(error, {
625
+ provider: providerName,
626
+ name: name ?? '',
627
+ method: 'page',
628
+ });
629
+ }
630
+ } finally {
631
+ this.rateLimiter.release();
632
+ }
633
+ }
634
+ });
635
+
636
+ await Promise.allSettled(promises);
637
+ }
638
+
639
+ /**
640
+ * Track a screen view (mobile equivalent of page)
641
+ */
642
+ async screen(payload: EmitterScreenPayload): Promise<void>;
643
+ async screen(
644
+ name?: string,
645
+ properties?: PageProperties,
646
+ options?: TrackingOptions,
647
+ ): Promise<void>;
648
+ async screen(
649
+ nameOrPayload?: string | EmitterScreenPayload,
650
+ properties?: PageProperties,
651
+ options?: TrackingOptions,
652
+ ): Promise<void> {
653
+ // If first argument is an emitter payload, use it
654
+ if (typeof nameOrPayload === 'object') {
655
+ const payload = nameOrPayload;
656
+ return this.screen(payload.name, payload.properties, {
657
+ ...options,
658
+ context: { ...this.context, ...payload.context },
659
+ });
660
+ }
661
+
662
+ const name = nameOrPayload;
663
+
664
+ if (!this.isInitialized) {
665
+ if (this.config.onError) {
666
+ this.config.onError(new Error('Analytics not initialized'), {
667
+ provider: 'analytics',
668
+ name,
669
+ method: 'screen',
670
+ });
671
+ }
672
+ return;
673
+ }
674
+
675
+ const sanitized = sanitizeProperties(properties ?? {}, {
676
+ stripPII: process.env.NODE_ENV === 'production',
677
+ stripHTML: true,
678
+ allowDangerousKeys: false,
679
+ });
680
+
681
+ const targetProviders = this.getTargetProviders(options);
682
+ const enhancedProperties = { ...sanitized.data } as PageProperties;
683
+
684
+ const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
685
+ if (provider.screen) {
686
+ const allowed = await this.rateLimiter.tryAcquire();
687
+ if (!allowed) {
688
+ if (this.config.debug && this.config.onInfo) {
689
+ this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
690
+ }
691
+ if (this.config.onError) {
692
+ this.config.onError(new Error(`Rate limit exceeded for provider ${providerName}`), {
693
+ provider: providerName,
694
+ name,
695
+ method: 'screen',
696
+ });
697
+ }
698
+ return;
699
+ }
700
+
701
+ try {
702
+ await provider.screen(name ?? '', enhancedProperties, {
703
+ ...options,
704
+ context: this.context,
705
+ });
706
+
707
+ const metrics = this.providerMetrics.get(providerName);
708
+ if (metrics) {
709
+ metrics.callCount++;
710
+ metrics.lastUsed = process.hrtime.bigint();
711
+ }
712
+ } catch (error) {
713
+ const metrics = this.providerMetrics.get(providerName);
714
+ if (metrics) {
715
+ metrics.errorCount++;
716
+ }
717
+
718
+ if (this.config.onError) {
719
+ this.config.onError(error, {
720
+ provider: providerName,
721
+ name: name ?? '',
722
+ method: 'screen',
723
+ });
724
+ }
725
+ } finally {
726
+ this.rateLimiter.release();
727
+ }
728
+ }
729
+ });
730
+
731
+ await Promise.allSettled(promises);
732
+ }
733
+
734
+ /**
735
+ * Associate user with a group
736
+ * FIX #3: Replace any types with proper TypeScript types
737
+ */
738
+ async group(payload: EmitterGroupPayload): Promise<void>;
739
+ async group(groupId: string, traits?: GroupTraits, options?: TrackingOptions): Promise<void>;
740
+ async group(
741
+ groupIdOrPayload: string | EmitterGroupPayload,
742
+ traits?: GroupTraits,
743
+ options?: TrackingOptions,
744
+ ): Promise<void> {
745
+ // If first argument is an emitter payload, use it
746
+ if (typeof groupIdOrPayload === 'object') {
747
+ const payload = groupIdOrPayload;
748
+ return this.group(payload.groupId, payload.traits, {
749
+ ...options,
750
+ // Merge context from payload
751
+ context: { ...this.context, ...payload.context },
752
+ });
753
+ }
754
+
755
+ // Traditional group call
756
+ const groupId = groupIdOrPayload;
757
+
758
+ if (!this.isInitialized) {
759
+ if (this.config.onError) {
760
+ this.config.onError(new Error('Analytics not initialized'), {
761
+ provider: 'analytics',
762
+ groupId,
763
+ method: 'group',
764
+ });
765
+ }
766
+ return;
767
+ }
768
+
769
+ // Sanitize traits
770
+ const sanitized = sanitizeProperties(traits ?? {}, {
771
+ stripPII: process.env.NODE_ENV === 'production',
772
+ stripHTML: true,
773
+ allowDangerousKeys: false,
774
+ });
775
+
776
+ // Update context with group info
777
+ this.setContext({ organizationId: groupId, ...sanitized.data });
778
+
779
+ const targetProviders = this.getTargetProviders(options);
780
+ const enhancedTraits = { ...sanitized.data } as GroupTraits;
781
+
782
+ const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
783
+ if (provider.group) {
784
+ const allowed = await this.rateLimiter.tryAcquire();
785
+ if (!allowed) {
786
+ if (this.config.debug && this.config.onInfo) {
787
+ this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
788
+ }
789
+ // Report rate limit rejection to onError handler to prevent silent event loss
790
+ if (this.config.onError) {
791
+ this.config.onError(new Error(`Rate limit exceeded for provider ${providerName}`), {
792
+ provider: providerName,
793
+ groupId,
794
+ method: 'group',
795
+ });
796
+ }
797
+ return;
798
+ }
799
+
800
+ try {
801
+ // Pass context as TrackingOptions (provider.group expects TrackingOptions as third parameter)
802
+ await provider.group(groupId, enhancedTraits, {
803
+ ...options,
804
+ context: this.context,
805
+ });
806
+
807
+ const metrics = this.providerMetrics.get(providerName);
808
+ if (metrics) {
809
+ metrics.callCount++;
810
+ metrics.lastUsed = process.hrtime.bigint();
811
+ }
812
+ } catch (error) {
813
+ const metrics = this.providerMetrics.get(providerName);
814
+ if (metrics) {
815
+ metrics.errorCount++;
816
+ }
817
+
818
+ if (this.config.onError) {
819
+ this.config.onError(error, {
820
+ provider: providerName,
821
+ groupId,
822
+ method: 'group',
823
+ });
824
+ }
825
+ } finally {
826
+ this.rateLimiter.release();
827
+ }
828
+ }
829
+ });
830
+
831
+ await Promise.allSettled(promises);
832
+ }
833
+
834
+ /**
835
+ * Alias one user ID to another
836
+ * No any types - already properly typed
837
+ */
838
+ async alias(payload: EmitterAliasPayload): Promise<void>;
839
+ async alias(userId: string, previousId: string, options?: TrackingOptions): Promise<void>;
840
+ async alias(
841
+ userIdOrPayload: string | EmitterAliasPayload,
842
+ previousId?: string,
843
+ options?: TrackingOptions,
844
+ ): Promise<void> {
845
+ // If first argument is an emitter payload, use it
846
+ if (typeof userIdOrPayload === 'object') {
847
+ const payload = userIdOrPayload;
848
+ return this.alias(payload.userId, payload.previousId, {
849
+ ...options,
850
+ // Merge context from payload
851
+ context: { ...this.context, ...payload.context },
852
+ });
853
+ }
854
+
855
+ // Traditional alias call
856
+ const userId = userIdOrPayload;
857
+ const prevId = previousId as string;
858
+
859
+ if (!this.isInitialized) {
860
+ if (this.config.onError) {
861
+ this.config.onError(new Error('Analytics not initialized'), {
862
+ provider: 'analytics',
863
+ method: 'alias',
864
+ previousId: prevId,
865
+ userId,
866
+ });
867
+ }
868
+ return;
869
+ }
870
+
871
+ const targetProviders = this.getTargetProviders(options);
872
+
873
+ const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
874
+ if (provider.alias) {
875
+ const allowed = await this.rateLimiter.tryAcquire();
876
+ if (!allowed) {
877
+ if (this.config.debug && this.config.onInfo) {
878
+ this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
879
+ }
880
+ // Report rate limit rejection to onError handler to prevent silent event loss
881
+ if (this.config.onError) {
882
+ this.config.onError(new Error(`Rate limit exceeded for provider ${providerName}`), {
883
+ provider: providerName,
884
+ userId,
885
+ previousId,
886
+ method: 'alias',
887
+ });
888
+ }
889
+ return;
890
+ }
891
+
892
+ try {
893
+ await provider.alias(userId, prevId, this.context);
894
+
895
+ const metrics = this.providerMetrics.get(providerName);
896
+ if (metrics) {
897
+ metrics.callCount++;
898
+ metrics.lastUsed = process.hrtime.bigint();
899
+ }
900
+ } catch (error) {
901
+ const metrics = this.providerMetrics.get(providerName);
902
+ if (metrics) {
903
+ metrics.errorCount++;
904
+ }
905
+
906
+ if (this.config.onError) {
907
+ this.config.onError(error, {
908
+ provider: providerName,
909
+ method: 'alias',
910
+ previousId: prevId,
911
+ userId,
912
+ });
913
+ }
914
+ } finally {
915
+ this.rateLimiter.release();
916
+ }
917
+ }
918
+ });
919
+
920
+ await Promise.allSettled(promises);
921
+ }
922
+
923
+ /**
924
+ * Get list of active provider names
925
+ */
926
+ getActiveProviders(): string[] {
927
+ return [...this.providers.keys()];
928
+ }
929
+
930
+ /**
931
+ * Get a specific provider instance
932
+ */
933
+ getProvider(name: string): AnalyticsProvider | undefined {
934
+ return this.providers.get(name);
935
+ }
936
+
937
+ /**
938
+ * Reset analytics context and identity
939
+ */
940
+ reset(): void {
941
+ this.context = {};
942
+ this.eventCache.clear();
943
+ }
944
+
945
+ /**
946
+ * Shutdown all providers and cleanup resources
947
+ */
948
+ async shutdown(): Promise<void> {
949
+ const shutdownPromises = [...this.providers.entries()].map(async ([name, provider]) => {
950
+ try {
951
+ if (typeof (provider as any).destroy === 'function') {
952
+ await (provider as any).destroy();
953
+ }
954
+ } catch (error) {
955
+ if (this.config.onError) {
956
+ this.config.onError(error, {
957
+ provider: name,
958
+ method: 'shutdown',
959
+ });
960
+ }
961
+ }
962
+ });
963
+
964
+ await Promise.allSettled(shutdownPromises);
965
+ this.providers.clear();
966
+ this.isInitialized = false;
967
+ }
968
+
969
+ /**
970
+ * Process any emitter payload with Node 22+ optimizations
971
+ * FIX #12: Use LRU cache for event deduplication (prevents memory leaks)
972
+ */
973
+ async emit(payload: EmitterPayload, options?: { timeout?: number }): Promise<void> {
974
+ const emitStartTime = process.hrtime.bigint();
975
+
976
+ // FIX #12: Use LRU cache for deduplication instead of WeakMap
977
+ // Create order-independent cache key by sorting object keys
978
+ const createCacheKey = (p: EmitterPayload): string => {
979
+ const sorted = (obj: unknown): unknown => {
980
+ if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
981
+ return obj;
982
+ }
983
+ const sortedObj: Record<string, unknown> = {};
984
+ for (const key of Object.keys(obj).sort()) {
985
+ sortedObj[key] = sorted((obj as Record<string, unknown>)[key]);
986
+ }
987
+ return sortedObj;
988
+ };
989
+ return JSON.stringify(sorted(p));
990
+ };
991
+ const cacheKey = createCacheKey(payload);
992
+ if (this.eventCache.has(cacheKey)) {
993
+ const metadata = this.eventCache.get(cacheKey);
994
+ if (metadata?.processed) {
995
+ if (this.config.debug && this.config.onInfo) {
996
+ this.config.onInfo('Skipping duplicate event processing');
997
+ }
998
+ return;
999
+ }
1000
+ }
1001
+
1002
+ // Mark as being processed and track metadata
1003
+ this.eventCache.set(cacheKey, {
1004
+ timestamp: emitStartTime,
1005
+ processed: true,
1006
+ });
1007
+
1008
+ try {
1009
+ // Apply timeout if specified using AbortSignal.timeout() (Node 22+)
1010
+ if (options?.timeout) {
1011
+ const timeoutSignal = AbortSignal.timeout(options.timeout);
1012
+ await Promise.race([
1013
+ this.processPayloadByType(payload),
1014
+ new Promise<never>((_resolve, reject) => {
1015
+ timeoutSignal.addEventListener('abort', () =>
1016
+ reject(new Error(`Event processing timed out after ${options.timeout}ms`)),
1017
+ );
1018
+ }),
1019
+ ]);
1020
+ } else {
1021
+ await this.processPayloadByType(payload);
1022
+ }
1023
+
1024
+ // Log performance metrics in debug mode
1025
+ if (this.config.debug && this.config.onInfo) {
1026
+ const processingTime = Number(process.hrtime.bigint() - emitStartTime) / 1_000_000;
1027
+ this.config.onInfo(`Event processed in ${processingTime.toFixed(2)}ms`);
1028
+ }
1029
+ } catch (error) {
1030
+ // Update metadata to mark as failed
1031
+ this.eventCache.set(cacheKey, {
1032
+ timestamp: emitStartTime,
1033
+ processed: false,
1034
+ });
1035
+ throw error;
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Route payload to appropriate method based on type
1041
+ */
1042
+ private async processPayloadByType(payload: EmitterPayload): Promise<void> {
1043
+ switch (payload.type) {
1044
+ case 'track':
1045
+ return this.track(payload);
1046
+ case 'identify':
1047
+ return this.identify(payload);
1048
+ case 'page':
1049
+ return this.page(payload);
1050
+ case 'screen':
1051
+ return this.screen(payload);
1052
+ case 'group':
1053
+ return this.group(payload);
1054
+ case 'alias':
1055
+ return this.alias(payload);
1056
+ }
1057
+ }
1058
+
1059
+ /**
1060
+ * Process an emitter payload (legacy method - use emit() instead)
1061
+ * @deprecated Use emit() for better type safety
1062
+ */
1063
+ async processEmitterPayload(payload: EmitterPayload): Promise<void> {
1064
+ return this.emit(payload);
1065
+ }
1066
+
1067
+ /**
1068
+ * Batch emit multiple payloads with Node 22+ optimizations
1069
+ * FIX #14: Process chunks concurrently instead of sequentially
1070
+ * FIX #13: Use spread operator instead of structuredClone
1071
+ */
1072
+ async emitBatch(
1073
+ payloads: EmitterPayload[],
1074
+ options?: {
1075
+ timeout?: number;
1076
+ concurrency?: number;
1077
+ failFast?: boolean;
1078
+ },
1079
+ ): Promise<void> {
1080
+ const batchStartTime = process.hrtime.bigint();
1081
+ const concurrency = options?.concurrency ?? 10; // Default concurrency limit
1082
+
1083
+ if (payloads.length === 0) return;
1084
+
1085
+ // FIX #13: Use structuredClone for deep cloning to ensure nested data safety
1086
+ const clonedPayloads = payloads.map(p => structuredClone(p));
1087
+
1088
+ // Process in chunks for better memory management
1089
+ const chunks: EmitterPayload[][] = [];
1090
+ for (let i = 0; i < clonedPayloads.length; i += concurrency) {
1091
+ chunks.push(clonedPayloads.slice(i, i + concurrency));
1092
+ }
1093
+
1094
+ const processChunk = async (chunk: EmitterPayload[]) => {
1095
+ const chunkPromises = chunk.map(payload =>
1096
+ this.emit(
1097
+ payload,
1098
+ options?.timeout !== undefined ? { timeout: options.timeout } : undefined,
1099
+ ),
1100
+ );
1101
+
1102
+ if (options?.failFast) {
1103
+ await Promise.all(chunkPromises);
1104
+ } else {
1105
+ await Promise.allSettled(chunkPromises);
1106
+ }
1107
+ };
1108
+
1109
+ // FIX #14: Process chunks concurrently instead of sequentially
1110
+ const chunkPromises = chunks.map(chunk => processChunk(chunk));
1111
+ await Promise.all(chunkPromises);
1112
+
1113
+ // Log batch processing metrics in debug mode
1114
+ if (this.config.debug && this.config.onInfo) {
1115
+ const batchTime = Number(process.hrtime.bigint() - batchStartTime) / 1_000_000;
1116
+ this.config.onInfo(
1117
+ `Batch of ${payloads.length} events processed in ${batchTime.toFixed(2)}ms ` +
1118
+ `(concurrency: ${concurrency})`,
1119
+ );
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Create a bound emitter function for convenience
1125
+ */
1126
+ createEmitter(): (payload: EmitterPayload) => Promise<void> {
1127
+ // Extract arrow function to avoid consistent-function-scoping warning
1128
+ const boundEmit = this.emit.bind(this);
1129
+ return boundEmit;
1130
+ }
1131
+
1132
+ /**
1133
+ * Track an ecommerce event specification
1134
+ * @deprecated Use emit(ecommerce.EVENT_NAME(...)) instead
1135
+ */
1136
+ async trackEcommerce(eventSpec: EcommerceEventSpec): Promise<void> {
1137
+ return this.track(eventSpec.name, eventSpec.properties);
1138
+ }
1139
+
1140
+ /**
1141
+ * Get target providers based on tracking options
1142
+ */
1143
+ private getTargetProviders(options?: TrackingOptions): Map<string, AnalyticsProvider> {
1144
+ let targetProviders = new Map(this.providers);
1145
+
1146
+ if (options) {
1147
+ // Handle runtime provider additions with enhanced error handling
1148
+ if (options.providers && Object.hasOwn(options, 'providers')) {
1149
+ for (const [name, config] of Object.entries(options.providers)) {
1150
+ const factory = this.availableProviders[name];
1151
+ if (factory) {
1152
+ try {
1153
+ const provider = factory(config);
1154
+ targetProviders.set(name, provider);
1155
+
1156
+ // Initialize runtime metrics tracking (Node 22+)
1157
+ this.providerMetrics.set(name, {
1158
+ initTime: process.hrtime.bigint(),
1159
+ callCount: 0,
1160
+ errorCount: 0,
1161
+ lastUsed: process.hrtime.bigint(),
1162
+ });
1163
+ } catch (error) {
1164
+ if (this.config.onError) {
1165
+ this.config.onError(error, {
1166
+ provider: name,
1167
+ method: 'runtime-create',
1168
+ });
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ // Handle 'only' option with safer property checking
1176
+ if (options.only && Object.hasOwn(options, 'only')) {
1177
+ const onlyProviders = new Map();
1178
+ for (const name of options.only) {
1179
+ if (targetProviders.has(name)) {
1180
+ onlyProviders.set(name, targetProviders.get(name));
1181
+ }
1182
+ }
1183
+ targetProviders = onlyProviders;
1184
+ }
1185
+
1186
+ // Handle 'exclude' option with safer property checking
1187
+ if (options.exclude && Object.hasOwn(options, 'exclude')) {
1188
+ for (const name of options.exclude) {
1189
+ targetProviders.delete(name);
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ return targetProviders;
1195
+ }
1196
+
1197
+ /**
1198
+ * Get analytics performance metrics using Node 22+ features
1199
+ */
1200
+ getAnalyticsMetrics(): {
1201
+ providers: Record<
1202
+ string,
1203
+ {
1204
+ initTimeMs: number;
1205
+ callCount: number;
1206
+ errorCount: number;
1207
+ lastUsedMs: number;
1208
+ successRate: number;
1209
+ }
1210
+ >;
1211
+ events: {
1212
+ totalProcessed: number;
1213
+ cacheSize: number;
1214
+ memoryUsage: {
1215
+ rss: number;
1216
+ heapTotal: number;
1217
+ heapUsed: number;
1218
+ external: number;
1219
+ arrayBuffers: number;
1220
+ };
1221
+ };
1222
+ } {
1223
+ const now = process.hrtime.bigint();
1224
+ const providers: Record<
1225
+ string,
1226
+ {
1227
+ initTimeMs: number;
1228
+ callCount: number;
1229
+ errorCount: number;
1230
+ lastUsedMs: number;
1231
+ successRate: number;
1232
+ }
1233
+ > = {};
1234
+
1235
+ // Collect provider metrics
1236
+ for (const [providerName, metrics] of this.providerMetrics) {
1237
+ providers[providerName] = {
1238
+ initTimeMs: Number(metrics.initTime) / 1_000_000,
1239
+ callCount: metrics.callCount,
1240
+ errorCount: metrics.errorCount,
1241
+ lastUsedMs: Number(now - metrics.lastUsed) / 1_000_000,
1242
+ successRate: metrics.callCount > 0 ? 1 - metrics.errorCount / metrics.callCount : 1,
1243
+ };
1244
+ }
1245
+
1246
+ return {
1247
+ providers,
1248
+ events: {
1249
+ totalProcessed: this.eventCache.size,
1250
+ cacheSize: this.eventCache.size,
1251
+ memoryUsage: process.memoryUsage(),
1252
+ },
1253
+ };
1254
+ }
1255
+
1256
+ /**
1257
+ * Health check for analytics system using Node 22+ timing
1258
+ * FIX #16: Send actual test events instead of just setContext
1259
+ */
1260
+ async healthCheck(timeout: number = 5000): Promise<{
1261
+ healthy: boolean;
1262
+ providers: Record<string, boolean>;
1263
+ metrics: ReturnType<AnalyticsManager['getAnalyticsMetrics']>;
1264
+ totalCheckTime: number;
1265
+ }> {
1266
+ const checkStartTime = process.hrtime.bigint();
1267
+ const providerHealth: Record<string, boolean> = {};
1268
+
1269
+ // Use AbortSignal.timeout() for health check timeout (Node 22+)
1270
+ const timeoutSignal = AbortSignal.timeout(timeout);
1271
+
1272
+ try {
1273
+ const healthPromises = [...this.providers.entries()].map(async ([name, provider]) => {
1274
+ try {
1275
+ // FIX #16: Send actual test event instead of just setContext
1276
+ await Promise.race([
1277
+ provider.track('__health_check__', { timestamp: Date.now(), provider: name }),
1278
+ new Promise<void>((_resolve, reject) => {
1279
+ timeoutSignal.addEventListener('abort', () =>
1280
+ reject(new Error('Health check timeout')),
1281
+ );
1282
+ }),
1283
+ ]);
1284
+ providerHealth[name] = true;
1285
+ } catch {
1286
+ providerHealth[name] = false;
1287
+ }
1288
+ });
1289
+
1290
+ await Promise.allSettled(healthPromises);
1291
+
1292
+ const checkEndTime = process.hrtime.bigint();
1293
+ const totalCheckTime = Number(checkEndTime - checkStartTime) / 1_000_000;
1294
+
1295
+ const healthyCount = Object.values(providerHealth).filter(Boolean).length;
1296
+
1297
+ return {
1298
+ healthy: healthyCount > 0 && this.isInitialized,
1299
+ providers: providerHealth,
1300
+ metrics: this.getAnalyticsMetrics(),
1301
+ totalCheckTime,
1302
+ };
1303
+ } catch {
1304
+ return {
1305
+ healthy: false,
1306
+ providers: providerHealth,
1307
+ metrics: this.getAnalyticsMetrics(),
1308
+ totalCheckTime: Number(process.hrtime.bigint() - checkStartTime) / 1_000_000,
1309
+ };
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * Factory function to create analytics manager
1316
+ */
1317
+ export function createAnalyticsManager(
1318
+ config: AnalyticsConfig,
1319
+ availableProviders: ProviderRegistry,
1320
+ ): AnalyticsManager {
1321
+ return new AnalyticsManager(config, availableProviders);
1322
+ }