@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.
- package/README.md +509 -0
- package/dist/ai-YMnynb-t.mjs +3347 -0
- package/dist/ai-YMnynb-t.mjs.map +1 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/client-CTzJVFU5.mjs +9 -0
- package/dist/client-CTzJVFU5.mjs.map +1 -0
- package/dist/client-CcFTauAh.mjs +54 -0
- package/dist/client-CcFTauAh.mjs.map +1 -0
- package/dist/client-CeOLjbac.mjs +281 -0
- package/dist/client-CeOLjbac.mjs.map +1 -0
- package/dist/client-D339NFJS.mjs +267 -0
- package/dist/client-D339NFJS.mjs.map +1 -0
- package/dist/client-next.d.mts +62 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +525 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client.d.mts +30 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +186 -0
- package/dist/client.mjs.map +1 -0
- package/dist/config-DPS6bSYo.d.mts +34 -0
- package/dist/config-DPS6bSYo.d.mts.map +1 -0
- package/dist/config-P6P5adJg.mjs +287 -0
- package/dist/config-P6P5adJg.mjs.map +1 -0
- package/dist/console-8bND3mMU.mjs +128 -0
- package/dist/console-8bND3mMU.mjs.map +1 -0
- package/dist/ecommerce-Cgu4wlux.mjs +993 -0
- package/dist/ecommerce-Cgu4wlux.mjs.map +1 -0
- package/dist/emitters-6-nKo8i-.mjs +208 -0
- package/dist/emitters-6-nKo8i-.mjs.map +1 -0
- package/dist/emitters-DldkVSPp.d.mts +12 -0
- package/dist/emitters-DldkVSPp.d.mts.map +1 -0
- package/dist/index-BfNWgfa5.d.mts +1494 -0
- package/dist/index-BfNWgfa5.d.mts.map +1 -0
- package/dist/index-BkIWe--N.d.mts +953 -0
- package/dist/index-BkIWe--N.d.mts.map +1 -0
- package/dist/index-jPzXRn52.d.mts +184 -0
- package/dist/index-jPzXRn52.d.mts.map +1 -0
- package/dist/manager-DvRRjza6.d.mts +76 -0
- package/dist/manager-DvRRjza6.d.mts.map +1 -0
- package/dist/posthog-bootstrap-CYfIy_WS.mjs +1769 -0
- package/dist/posthog-bootstrap-CYfIy_WS.mjs.map +1 -0
- package/dist/posthog-bootstrap-DWxFrxlt.d.mts +81 -0
- package/dist/posthog-bootstrap-DWxFrxlt.d.mts.map +1 -0
- package/dist/providers-http-client.d.mts +37 -0
- package/dist/providers-http-client.d.mts.map +1 -0
- package/dist/providers-http-client.mjs +320 -0
- package/dist/providers-http-client.mjs.map +1 -0
- package/dist/providers-http-server.d.mts +31 -0
- package/dist/providers-http-server.d.mts.map +1 -0
- package/dist/providers-http-server.mjs +297 -0
- package/dist/providers-http-server.mjs.map +1 -0
- package/dist/providers-http.d.mts +46 -0
- package/dist/providers-http.d.mts.map +1 -0
- package/dist/providers-http.mjs +4 -0
- package/dist/server-edge.d.mts +9 -0
- package/dist/server-edge.d.mts.map +1 -0
- package/dist/server-edge.mjs +373 -0
- package/dist/server-edge.mjs.map +1 -0
- package/dist/server-next.d.mts +67 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +193 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +10 -0
- package/dist/server.mjs +7 -0
- package/dist/service-cYtBBL8x.mjs +945 -0
- package/dist/service-cYtBBL8x.mjs.map +1 -0
- package/dist/shared.d.mts +16 -0
- package/dist/shared.d.mts.map +1 -0
- package/dist/shared.mjs +93 -0
- package/dist/shared.mjs.map +1 -0
- package/dist/types-BxBnNQ0V.d.mts +354 -0
- package/dist/types-BxBnNQ0V.d.mts.map +1 -0
- package/dist/types-CBvxUEaF.d.mts +216 -0
- package/dist/types-CBvxUEaF.d.mts.map +1 -0
- package/dist/types.d.mts +4 -0
- package/dist/types.mjs +0 -0
- package/dist/vercel-types-lwakUfoI.d.mts +102 -0
- package/dist/vercel-types-lwakUfoI.d.mts.map +1 -0
- package/package.json +129 -0
- package/src/client/index.ts +164 -0
- package/src/client/manager.ts +71 -0
- package/src/client/next/components.tsx +270 -0
- package/src/client/next/hooks.ts +217 -0
- package/src/client/next/manager.ts +141 -0
- package/src/client/next.ts +144 -0
- package/src/client-next.ts +101 -0
- package/src/client.ts +89 -0
- package/src/examples/ai-sdk-patterns.ts +583 -0
- package/src/examples/emitter-patterns.ts +476 -0
- package/src/examples/nextjs-emitter-patterns.tsx +403 -0
- package/src/next/app-router.tsx +564 -0
- package/src/next/client.ts +419 -0
- package/src/next/index.ts +84 -0
- package/src/next/middleware.ts +429 -0
- package/src/next/rsc.tsx +300 -0
- package/src/next/server.ts +253 -0
- package/src/next/types.d.ts +220 -0
- package/src/providers/base-provider.ts +419 -0
- package/src/providers/console/client.ts +10 -0
- package/src/providers/console/index.ts +152 -0
- package/src/providers/console/server.ts +6 -0
- package/src/providers/console/types.ts +15 -0
- package/src/providers/http/client.ts +464 -0
- package/src/providers/http/index.ts +30 -0
- package/src/providers/http/server.ts +396 -0
- package/src/providers/http/types.ts +135 -0
- package/src/providers/posthog/client.ts +518 -0
- package/src/providers/posthog/index.ts +11 -0
- package/src/providers/posthog/server.ts +329 -0
- package/src/providers/posthog/types.ts +104 -0
- package/src/providers/segment/client.ts +113 -0
- package/src/providers/segment/index.ts +11 -0
- package/src/providers/segment/server.ts +115 -0
- package/src/providers/segment/types.ts +51 -0
- package/src/providers/vercel/client.ts +102 -0
- package/src/providers/vercel/index.ts +11 -0
- package/src/providers/vercel/server.ts +89 -0
- package/src/providers/vercel/types.ts +27 -0
- package/src/server/index.ts +103 -0
- package/src/server/manager.ts +62 -0
- package/src/server/next.ts +210 -0
- package/src/server-edge.ts +442 -0
- package/src/server-next.ts +39 -0
- package/src/server.ts +106 -0
- package/src/shared/emitters/ai/README.md +981 -0
- package/src/shared/emitters/ai/events/agent.ts +130 -0
- package/src/shared/emitters/ai/events/artifacts.ts +167 -0
- package/src/shared/emitters/ai/events/chat.ts +126 -0
- package/src/shared/emitters/ai/events/chatbot-ecommerce.ts +133 -0
- package/src/shared/emitters/ai/events/completion.ts +103 -0
- package/src/shared/emitters/ai/events/content-generation.ts +347 -0
- package/src/shared/emitters/ai/events/conversation.ts +332 -0
- package/src/shared/emitters/ai/events/product-features.ts +1402 -0
- package/src/shared/emitters/ai/events/streaming.ts +114 -0
- package/src/shared/emitters/ai/events/tool.ts +93 -0
- package/src/shared/emitters/ai/index.ts +69 -0
- package/src/shared/emitters/ai/track-ai-sdk.ts +74 -0
- package/src/shared/emitters/ai/track-ai.ts +50 -0
- package/src/shared/emitters/ai/types.ts +1041 -0
- package/src/shared/emitters/ai/utils.ts +468 -0
- package/src/shared/emitters/ecommerce/events/cart-checkout.ts +106 -0
- package/src/shared/emitters/ecommerce/events/coupon.ts +49 -0
- package/src/shared/emitters/ecommerce/events/engagement.ts +61 -0
- package/src/shared/emitters/ecommerce/events/marketplace.ts +119 -0
- package/src/shared/emitters/ecommerce/events/order.ts +199 -0
- package/src/shared/emitters/ecommerce/events/product.ts +205 -0
- package/src/shared/emitters/ecommerce/events/registry.ts +123 -0
- package/src/shared/emitters/ecommerce/events/wishlist-sharing.ts +140 -0
- package/src/shared/emitters/ecommerce/index.ts +46 -0
- package/src/shared/emitters/ecommerce/track-ecommerce.ts +53 -0
- package/src/shared/emitters/ecommerce/types.ts +314 -0
- package/src/shared/emitters/ecommerce/utils.ts +216 -0
- package/src/shared/emitters/emitter-types.ts +974 -0
- package/src/shared/emitters/emitters.ts +292 -0
- package/src/shared/emitters/helpers.ts +419 -0
- package/src/shared/emitters/index.ts +66 -0
- package/src/shared/index.ts +142 -0
- package/src/shared/ingestion/index.ts +66 -0
- package/src/shared/ingestion/schemas.ts +386 -0
- package/src/shared/ingestion/service.ts +628 -0
- package/src/shared/node22-features.ts +848 -0
- package/src/shared/providers/console-provider.ts +160 -0
- package/src/shared/types/base-types.ts +54 -0
- package/src/shared/types/console-types.ts +19 -0
- package/src/shared/types/posthog-types.ts +131 -0
- package/src/shared/types/segment-types.ts +15 -0
- package/src/shared/types/types.ts +397 -0
- package/src/shared/types/vercel-types.ts +19 -0
- package/src/shared/utils/config-client.ts +19 -0
- package/src/shared/utils/config.ts +250 -0
- package/src/shared/utils/emitter-adapter.ts +212 -0
- package/src/shared/utils/manager.test.ts +36 -0
- package/src/shared/utils/manager.ts +1322 -0
- package/src/shared/utils/posthog-bootstrap.ts +136 -0
- package/src/shared/utils/posthog-client-utils.ts +48 -0
- package/src/shared/utils/posthog-next-utils.ts +282 -0
- package/src/shared/utils/posthog-server-utils.ts +210 -0
- package/src/shared/utils/rate-limit.ts +289 -0
- package/src/shared/utils/security.ts +545 -0
- package/src/shared/utils/validation-client.ts +161 -0
- package/src/shared/utils/validation.ts +399 -0
- package/src/shared.ts +155 -0
- 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
|
+
}
|