@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,1769 @@
|
|
|
1
|
+
import { i as page, n as group, o as track, r as identify, t as alias } from "./emitters-6-nKo8i-.mjs";
|
|
2
|
+
import { logWarn } from "@od-oneapp/shared/logger";
|
|
3
|
+
|
|
4
|
+
//#region src/shared/utils/rate-limit.ts
|
|
5
|
+
/**
|
|
6
|
+
* @fileoverview Rate limiting utilities for analytics calls
|
|
7
|
+
* Rate limiting utilities for analytics calls
|
|
8
|
+
*
|
|
9
|
+
* Prevents overwhelming provider APIs, hitting rate limits, and excessive costs.
|
|
10
|
+
* Uses token bucket algorithm for smooth rate limiting with bursts.
|
|
11
|
+
*
|
|
12
|
+
* @module rate-limit
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Rate limiter for analytics calls
|
|
16
|
+
*
|
|
17
|
+
* Implements token bucket algorithm with optional call queuing.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const limiter = new RateLimiter({
|
|
22
|
+
* maxCalls: 100,
|
|
23
|
+
* windowMs: 1000, // 100 calls per second
|
|
24
|
+
* maxConcurrent: 10,
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* // Check if call is allowed
|
|
28
|
+
* if (await limiter.tryAcquire()) {
|
|
29
|
+
* await analytics.track('Event');
|
|
30
|
+
* limiter.release();
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* // Or use wrapper
|
|
34
|
+
* await limiter.execute(() => analytics.track('Event'));
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
var RateLimiter = class {
|
|
38
|
+
callCount = 0;
|
|
39
|
+
concurrentCount = 0;
|
|
40
|
+
windowStart = Date.now();
|
|
41
|
+
queue = [];
|
|
42
|
+
config;
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = {
|
|
45
|
+
maxCalls: config.maxCalls,
|
|
46
|
+
windowMs: config.windowMs,
|
|
47
|
+
maxConcurrent: config.maxConcurrent ?? Infinity,
|
|
48
|
+
queueExcess: config.queueExcess ?? false,
|
|
49
|
+
maxQueueSize: config.maxQueueSize ?? 1e3
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Try to acquire a rate limit token
|
|
54
|
+
*
|
|
55
|
+
* @returns Promise resolving to whether the call is allowed
|
|
56
|
+
*/
|
|
57
|
+
async tryAcquire() {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (now - this.windowStart >= this.config.windowMs) {
|
|
60
|
+
this.callCount = 0;
|
|
61
|
+
this.windowStart = now;
|
|
62
|
+
}
|
|
63
|
+
if (this.callCount >= this.config.maxCalls) {
|
|
64
|
+
if (this.config.queueExcess && this.queue.length < this.config.maxQueueSize) return new Promise((resolve) => {
|
|
65
|
+
this.queue.push(() => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - this.windowStart >= this.config.windowMs) {
|
|
68
|
+
this.callCount = 0;
|
|
69
|
+
this.windowStart = now;
|
|
70
|
+
}
|
|
71
|
+
if (this.callCount < this.config.maxCalls) {
|
|
72
|
+
this.callCount++;
|
|
73
|
+
resolve(true);
|
|
74
|
+
} else resolve(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
logWarn("Rate limit exceeded", {
|
|
78
|
+
maxCalls: this.config.maxCalls,
|
|
79
|
+
windowMs: this.config.windowMs,
|
|
80
|
+
currentCount: this.callCount
|
|
81
|
+
});
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (this.concurrentCount >= this.config.maxConcurrent) {
|
|
85
|
+
if (this.config.queueExcess && this.queue.length < this.config.maxQueueSize) return new Promise((resolve) => {
|
|
86
|
+
this.queue.push(() => resolve(true));
|
|
87
|
+
});
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
this.callCount++;
|
|
91
|
+
this.concurrentCount++;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Release a rate limit token
|
|
96
|
+
*/
|
|
97
|
+
release() {
|
|
98
|
+
this.concurrentCount = Math.max(0, this.concurrentCount - 1);
|
|
99
|
+
if (this.queue.length > 0 && this.concurrentCount < this.config.maxConcurrent) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (now - this.windowStart >= this.config.windowMs) {
|
|
102
|
+
this.callCount = 0;
|
|
103
|
+
this.windowStart = now;
|
|
104
|
+
}
|
|
105
|
+
if (this.callCount < this.config.maxCalls) {
|
|
106
|
+
const next = this.queue.shift();
|
|
107
|
+
if (next) {
|
|
108
|
+
this.concurrentCount++;
|
|
109
|
+
next();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Execute a function with rate limiting
|
|
116
|
+
*
|
|
117
|
+
* @param fn - Function to execute
|
|
118
|
+
* @returns Promise resolving to function result
|
|
119
|
+
*/
|
|
120
|
+
async execute(fn) {
|
|
121
|
+
if (!await this.tryAcquire()) return null;
|
|
122
|
+
try {
|
|
123
|
+
return await fn();
|
|
124
|
+
} finally {
|
|
125
|
+
this.release();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Get current rate limiter stats
|
|
130
|
+
*/
|
|
131
|
+
getStats() {
|
|
132
|
+
return {
|
|
133
|
+
callCount: this.callCount,
|
|
134
|
+
concurrentCount: this.concurrentCount,
|
|
135
|
+
queueSize: this.queue.length,
|
|
136
|
+
remainingCalls: Math.max(0, this.config.maxCalls - this.callCount),
|
|
137
|
+
windowStart: this.windowStart
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reset the rate limiter
|
|
142
|
+
*/
|
|
143
|
+
reset() {
|
|
144
|
+
this.callCount = 0;
|
|
145
|
+
this.concurrentCount = 0;
|
|
146
|
+
this.windowStart = Date.now();
|
|
147
|
+
this.queue = [];
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/shared/utils/security.ts
|
|
153
|
+
/**
|
|
154
|
+
* @fileoverview Security and sanitization utilities for analytics data
|
|
155
|
+
*
|
|
156
|
+
* This module provides comprehensive security utilities for analytics data:
|
|
157
|
+
*
|
|
158
|
+
* - **PII Detection**: Detects personally identifiable information (emails, phones, SSNs, credit cards, IPs)
|
|
159
|
+
* - **PII Redaction**: Redacts PII from strings before sending to analytics
|
|
160
|
+
* - **XSS Protection**: Strips HTML and script tags to prevent XSS attacks
|
|
161
|
+
* - **Property Validation**: Validates property keys to prevent prototype pollution
|
|
162
|
+
* - **Payload Sanitization**: Comprehensive sanitization with size limits and depth checks
|
|
163
|
+
* - **Size Limits**: Enforces maximum property and payload sizes
|
|
164
|
+
*
|
|
165
|
+
* **Security Features**:
|
|
166
|
+
* - Maximum property value size: 100KB
|
|
167
|
+
* - Maximum payload size: 1MB
|
|
168
|
+
* - Maximum nesting depth: 10 levels
|
|
169
|
+
* - Dangerous key filtering (prevents prototype pollution)
|
|
170
|
+
* - HTML/script tag removal
|
|
171
|
+
*
|
|
172
|
+
* @module @od-oneapp/analytics/shared/utils/security
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Maximum allowed property value size (100KB).
|
|
176
|
+
*
|
|
177
|
+
* Prevents sending excessively large property values to analytics providers.
|
|
178
|
+
*
|
|
179
|
+
* @internal
|
|
180
|
+
*/
|
|
181
|
+
const MAX_PROPERTY_VALUE_SIZE = 100 * 1024;
|
|
182
|
+
/**
|
|
183
|
+
* Maximum allowed total payload size (1MB).
|
|
184
|
+
*
|
|
185
|
+
* Prevents sending excessively large payloads to analytics providers.
|
|
186
|
+
*
|
|
187
|
+
* @internal
|
|
188
|
+
*/
|
|
189
|
+
const MAX_PAYLOAD_SIZE = 1024 * 1024;
|
|
190
|
+
/**
|
|
191
|
+
* Maximum allowed object nesting depth.
|
|
192
|
+
*
|
|
193
|
+
* Prevents deeply nested objects that could cause performance issues or stack overflows.
|
|
194
|
+
*
|
|
195
|
+
* @internal
|
|
196
|
+
*/
|
|
197
|
+
const MAX_DEPTH = 10;
|
|
198
|
+
/**
|
|
199
|
+
* PII patterns for detection and filtering
|
|
200
|
+
* Note: These regexes are intentionally complex for accurate PII detection.
|
|
201
|
+
* They are used only on bounded, sanitized input with size limits.
|
|
202
|
+
*/
|
|
203
|
+
const PII_PATTERNS = {
|
|
204
|
+
email: /\b[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z|]{2,}\b/g,
|
|
205
|
+
phone: /\b(?:\+?1[.-]?)?\(?(\d{3})\)?[.-]?(\d{3})[.-]?(\d{4})\b/g,
|
|
206
|
+
ssn: /\b\d{3}-?\d{2}-?\d{4}\b/g,
|
|
207
|
+
creditCard: /\b(?:\d{4}[ -]?){3}\d{4}\b/g,
|
|
208
|
+
ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
209
|
+
ipv6: /\b(?:[\da-f]{1,4}:){7}[\da-f]{1,4}\b/gi
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Dangerous property keys that should never be allowed
|
|
213
|
+
*/
|
|
214
|
+
const DANGEROUS_KEYS = new Set([
|
|
215
|
+
"__proto__",
|
|
216
|
+
"constructor",
|
|
217
|
+
"prototype",
|
|
218
|
+
"__defineGetter__",
|
|
219
|
+
"__defineSetter__",
|
|
220
|
+
"__lookupGetter__",
|
|
221
|
+
"__lookupSetter__"
|
|
222
|
+
]);
|
|
223
|
+
/**
|
|
224
|
+
* HTML/Script patterns for XSS protection
|
|
225
|
+
* Note: These regexes are intentionally complex for accurate XSS detection.
|
|
226
|
+
* They are used only on bounded, sanitized input with size limits.
|
|
227
|
+
*/
|
|
228
|
+
const XSS_PATTERNS = {
|
|
229
|
+
scriptTag: /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
|
230
|
+
onEvent: /\bon\w+\s*=\s*["'][^"']*["']/gi,
|
|
231
|
+
javascript: /javascript:/gi,
|
|
232
|
+
dataUri: /data:text\/html/gi
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Check if a value contains PII (Personally Identifiable Information).
|
|
236
|
+
*
|
|
237
|
+
* Detects common PII patterns including:
|
|
238
|
+
* - Email addresses
|
|
239
|
+
* - Phone numbers
|
|
240
|
+
* - Social Security Numbers
|
|
241
|
+
* - Credit card numbers
|
|
242
|
+
* - IPv4 and IPv6 addresses
|
|
243
|
+
*
|
|
244
|
+
* @param {string} value - String value to check for PII
|
|
245
|
+
* @returns {boolean} `true` if PII is detected, `false` otherwise
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* if (containsPII(userInput)) {
|
|
250
|
+
* const sanitized = redactPII(userInput);
|
|
251
|
+
* // Use sanitized value
|
|
252
|
+
* }
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
function containsPII(value) {
|
|
256
|
+
return Object.values(PII_PATTERNS).some((pattern) => pattern.test(value));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Redact PII from a string.
|
|
260
|
+
*
|
|
261
|
+
* Replaces detected PII patterns with `[REDACTED_TYPE]` placeholders.
|
|
262
|
+
* Useful for logging or analytics where PII should not be stored.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} value - String value to redact PII from
|
|
265
|
+
* @returns {string} String with PII redacted
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```typescript
|
|
269
|
+
* const sanitized = redactPII('Contact john@example.com at 555-1234');
|
|
270
|
+
* // Returns: 'Contact [REDACTED_EMAIL] at [REDACTED_PHONE]'
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
function redactPII(value) {
|
|
274
|
+
let redacted = value;
|
|
275
|
+
for (const [type, pattern] of Object.entries(PII_PATTERNS)) redacted = redacted.replace(pattern, `[REDACTED_${type.toUpperCase()}]`);
|
|
276
|
+
return redacted;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Strip HTML and script tags from a string.
|
|
280
|
+
*
|
|
281
|
+
* Removes HTML tags, script tags, event handlers, and other potentially dangerous
|
|
282
|
+
* content to prevent XSS attacks. Also removes `data:text/html` URIs.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} value - String value to strip HTML from
|
|
285
|
+
* @returns {string} String with HTML and scripts removed
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```typescript
|
|
289
|
+
* const sanitized = stripHTML('<script>alert("xss")<\/script>Hello');
|
|
290
|
+
* // Returns: 'Hello'
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
function stripHTML(value) {
|
|
294
|
+
let stripped = value;
|
|
295
|
+
for (const pattern of Object.values(XSS_PATTERNS)) stripped = stripped.replace(pattern, "");
|
|
296
|
+
stripped = stripped.replaceAll(/<[^>]*>/g, "");
|
|
297
|
+
return stripped;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Validate that a property key is safe.
|
|
301
|
+
*
|
|
302
|
+
* Checks for dangerous keys that could cause prototype pollution or other
|
|
303
|
+
* security issues. Also validates against allowed key patterns if provided.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} key - Property key to validate
|
|
306
|
+
* @param {SanitizationOptions} [options] - Sanitization options
|
|
307
|
+
* @returns {{ valid: boolean; reason?: string }} Validation result with reason if invalid
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```typescript
|
|
311
|
+
* const result = isValidPropertyKey('__proto__');
|
|
312
|
+
* if (!result.valid) {
|
|
313
|
+
* console.warn('Unsafe key:', result.reason);
|
|
314
|
+
* }
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
function isValidPropertyKey(key, options = {}) {
|
|
318
|
+
if (!options.allowDangerousKeys && DANGEROUS_KEYS.has(key)) return {
|
|
319
|
+
valid: false,
|
|
320
|
+
reason: "Dangerous key that could cause prototype pollution"
|
|
321
|
+
};
|
|
322
|
+
if (options.allowedKeyPattern && !options.allowedKeyPattern.test(key)) return {
|
|
323
|
+
valid: false,
|
|
324
|
+
reason: "Key does not match allowed pattern"
|
|
325
|
+
};
|
|
326
|
+
if (/[<>[\\\]{}]/.test(key)) return {
|
|
327
|
+
valid: false,
|
|
328
|
+
reason: "Key contains potentially dangerous characters"
|
|
329
|
+
};
|
|
330
|
+
return { valid: true };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Get the size of a value in bytes
|
|
334
|
+
*/
|
|
335
|
+
function getValueSize(value) {
|
|
336
|
+
if (typeof value === "string") return new Blob([value]).size;
|
|
337
|
+
try {
|
|
338
|
+
return new Blob([JSON.stringify(value)]).size;
|
|
339
|
+
} catch {
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Sanitize a single property value
|
|
345
|
+
*/
|
|
346
|
+
function sanitizeValue(value, options, warnings) {
|
|
347
|
+
if (value === null || value === void 0) return {
|
|
348
|
+
value,
|
|
349
|
+
modified: false
|
|
350
|
+
};
|
|
351
|
+
let modified = false;
|
|
352
|
+
if (typeof value === "string") {
|
|
353
|
+
let sanitized = value;
|
|
354
|
+
const size = getValueSize(value);
|
|
355
|
+
const maxSize = options.maxPropertySize ?? MAX_PROPERTY_VALUE_SIZE;
|
|
356
|
+
if (size > maxSize) {
|
|
357
|
+
sanitized = value.slice(0, Math.max(0, maxSize));
|
|
358
|
+
warnings.push(`String value truncated from ${size} to ${maxSize} bytes`);
|
|
359
|
+
modified = true;
|
|
360
|
+
}
|
|
361
|
+
if (options.stripPII && containsPII(sanitized)) {
|
|
362
|
+
sanitized = redactPII(sanitized);
|
|
363
|
+
warnings.push("PII detected and redacted from value");
|
|
364
|
+
modified = true;
|
|
365
|
+
}
|
|
366
|
+
if (options.stripHTML) {
|
|
367
|
+
const stripped = stripHTML(sanitized);
|
|
368
|
+
if (stripped !== sanitized) {
|
|
369
|
+
sanitized = stripped;
|
|
370
|
+
warnings.push("HTML/scripts stripped from value");
|
|
371
|
+
modified = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
value: sanitized,
|
|
376
|
+
modified
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (typeof value !== "object") return {
|
|
380
|
+
value,
|
|
381
|
+
modified: false
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
value,
|
|
385
|
+
modified: false
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Sanitize analytics properties recursively.
|
|
390
|
+
*
|
|
391
|
+
* Comprehensive sanitization of analytics properties including:
|
|
392
|
+
* - Size limit enforcement
|
|
393
|
+
* - Depth limit enforcement
|
|
394
|
+
* - PII detection and redaction
|
|
395
|
+
* - HTML/script tag removal
|
|
396
|
+
* - Dangerous key filtering
|
|
397
|
+
* - Circular reference detection
|
|
398
|
+
*
|
|
399
|
+
* @param {T} properties - Properties to sanitize
|
|
400
|
+
* @param {SanitizationOptions} [options] - Sanitization options
|
|
401
|
+
* @returns {SanitizationResult<T>} Sanitization result with sanitized data and warnings
|
|
402
|
+
*
|
|
403
|
+
* @throws {Error} If properties contain circular references or exceed payload size limit
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* ```typescript
|
|
407
|
+
* const result = sanitizeProperties({
|
|
408
|
+
* user_email: 'test@example.com',
|
|
409
|
+
* phone: '555-1234',
|
|
410
|
+
* description: '<script>alert("xss")<\/script>Hello',
|
|
411
|
+
* }, {
|
|
412
|
+
* stripPII: true,
|
|
413
|
+
* stripHTML: true,
|
|
414
|
+
* });
|
|
415
|
+
*
|
|
416
|
+
* console.log(result.data);
|
|
417
|
+
* // {
|
|
418
|
+
* // user_email: '[REDACTED_EMAIL]',
|
|
419
|
+
* // phone: '[REDACTED_PHONE]',
|
|
420
|
+
* // description: 'Hello'
|
|
421
|
+
* // }
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
function sanitizeProperties(properties, options = {}) {
|
|
425
|
+
const warnings = [];
|
|
426
|
+
let modified = false;
|
|
427
|
+
try {
|
|
428
|
+
JSON.stringify(properties);
|
|
429
|
+
} catch {
|
|
430
|
+
throw new Error("Properties contain circular references");
|
|
431
|
+
}
|
|
432
|
+
const totalSize = getValueSize(properties);
|
|
433
|
+
const maxPayloadSize = options.maxPayloadSize ?? MAX_PAYLOAD_SIZE;
|
|
434
|
+
if (totalSize > maxPayloadSize) throw new Error(`Payload size ${totalSize} bytes exceeds maximum ${maxPayloadSize} bytes`);
|
|
435
|
+
/**
|
|
436
|
+
* Recursive sanitization helper
|
|
437
|
+
*/
|
|
438
|
+
function sanitizeRecursive(obj, depth = 0) {
|
|
439
|
+
const maxDepth = options.maxDepth ?? MAX_DEPTH;
|
|
440
|
+
if (depth > maxDepth) {
|
|
441
|
+
warnings.push(`Maximum nesting depth ${maxDepth} exceeded, truncating`);
|
|
442
|
+
modified = true;
|
|
443
|
+
return {};
|
|
444
|
+
}
|
|
445
|
+
const sanitized = {};
|
|
446
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
447
|
+
const keyValidation = isValidPropertyKey(key, options);
|
|
448
|
+
if (!keyValidation.valid) {
|
|
449
|
+
warnings.push(`Skipping dangerous key "${key}": ${keyValidation.reason}`);
|
|
450
|
+
modified = true;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (value === null || value === void 0) {
|
|
454
|
+
sanitized[key] = value;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (Array.isArray(value)) {
|
|
458
|
+
sanitized[key] = value.map((item) => {
|
|
459
|
+
if (typeof item === "object" && item !== null) return sanitizeRecursive(item, depth + 1);
|
|
460
|
+
const result = sanitizeValue(item, options, warnings);
|
|
461
|
+
if (result.modified) modified = true;
|
|
462
|
+
return result.value;
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (typeof value === "object") {
|
|
467
|
+
sanitized[key] = sanitizeRecursive(value, depth + 1);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const result = sanitizeValue(value, options, warnings);
|
|
471
|
+
if (result.modified) modified = true;
|
|
472
|
+
sanitized[key] = result.value;
|
|
473
|
+
}
|
|
474
|
+
return sanitized;
|
|
475
|
+
}
|
|
476
|
+
const sanitized = sanitizeRecursive(properties);
|
|
477
|
+
if (warnings.length > 0 && true) logWarn("Analytics properties sanitization warnings", { warnings });
|
|
478
|
+
return {
|
|
479
|
+
data: sanitized,
|
|
480
|
+
modified,
|
|
481
|
+
warnings
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Validate event name.
|
|
486
|
+
*
|
|
487
|
+
* Checks that an event name is safe and follows best practices:
|
|
488
|
+
* - Non-empty string
|
|
489
|
+
* - Not exceeding 255 characters
|
|
490
|
+
* - No XSS patterns
|
|
491
|
+
*
|
|
492
|
+
* @param {string} event - Event name to validate
|
|
493
|
+
* @returns {{ valid: boolean; reason?: string }} Validation result with reason if invalid
|
|
494
|
+
*
|
|
495
|
+
* @example
|
|
496
|
+
* ```typescript
|
|
497
|
+
* const result = validateEventName('Button Clicked');
|
|
498
|
+
* if (!result.valid) {
|
|
499
|
+
* console.error('Invalid event name:', result.reason);
|
|
500
|
+
* }
|
|
501
|
+
* ```
|
|
502
|
+
*/
|
|
503
|
+
function validateEventName(event) {
|
|
504
|
+
if (!event || typeof event !== "string") return {
|
|
505
|
+
valid: false,
|
|
506
|
+
reason: "Event name must be a non-empty string"
|
|
507
|
+
};
|
|
508
|
+
if (event.trim() === "") return {
|
|
509
|
+
valid: false,
|
|
510
|
+
reason: "Event name cannot be empty or whitespace only"
|
|
511
|
+
};
|
|
512
|
+
if (event.length > 255) return {
|
|
513
|
+
valid: false,
|
|
514
|
+
reason: "Event name cannot exceed 255 characters"
|
|
515
|
+
};
|
|
516
|
+
if (Object.values(XSS_PATTERNS).some((pattern) => pattern.test(event))) return {
|
|
517
|
+
valid: false,
|
|
518
|
+
reason: "Event name contains potentially malicious content"
|
|
519
|
+
};
|
|
520
|
+
return { valid: true };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/shared/utils/manager.ts
|
|
525
|
+
/**
|
|
526
|
+
* @fileoverview Analytics Manager - Core orchestration for multi-provider analytics
|
|
527
|
+
*
|
|
528
|
+
* This module provides the core AnalyticsManager class that orchestrates multiple
|
|
529
|
+
* analytics providers. It includes:
|
|
530
|
+
*
|
|
531
|
+
* - **Multi-Provider Support**: Manages multiple providers simultaneously
|
|
532
|
+
* - **Event Deduplication**: LRU cache prevents duplicate events
|
|
533
|
+
* - **Rate Limiting**: Per-provider rate limiting (100 calls/second default)
|
|
534
|
+
* - **Batch Processing**: Concurrent batch processing with configurable concurrency
|
|
535
|
+
* - **Performance Metrics**: Tracks provider performance and reliability
|
|
536
|
+
* - **Error Handling**: Comprehensive error handling with graceful degradation
|
|
537
|
+
*
|
|
538
|
+
* **Node.js 22+ Features Used**:
|
|
539
|
+
* - `Promise.withResolvers()`: External promise control for complex workflows
|
|
540
|
+
* - `AbortSignal.timeout()`: Context-aware timeouts for provider operations
|
|
541
|
+
* - High-resolution timing: Precise performance measurement (`process.hrtime.bigint()`)
|
|
542
|
+
* - `Object.hasOwn()`: Safer property existence checks
|
|
543
|
+
*
|
|
544
|
+
* **Optimizations**:
|
|
545
|
+
* - Spread operator instead of `structuredClone()` for better performance
|
|
546
|
+
* - LRU cache for event deduplication (prevents memory leaks)
|
|
547
|
+
* - Concurrent batch processing with configurable concurrency
|
|
548
|
+
* - Comprehensive input sanitization and validation
|
|
549
|
+
* - Per-provider rate limiting
|
|
550
|
+
*
|
|
551
|
+
* @module @od-oneapp/analytics/shared/utils/manager
|
|
552
|
+
*/
|
|
553
|
+
/**
|
|
554
|
+
* LRU Cache for event deduplication.
|
|
555
|
+
*
|
|
556
|
+
* Prevents memory leaks by limiting cache size. Uses least-recently-used eviction
|
|
557
|
+
* policy to maintain bounded memory usage.
|
|
558
|
+
*
|
|
559
|
+
* @template K - Cache key type
|
|
560
|
+
* @template V - Cache value type
|
|
561
|
+
*
|
|
562
|
+
* @internal
|
|
563
|
+
*/
|
|
564
|
+
var LRUCache = class {
|
|
565
|
+
cache = /* @__PURE__ */ new Map();
|
|
566
|
+
maxSize;
|
|
567
|
+
constructor(maxSize = 1e3) {
|
|
568
|
+
this.maxSize = maxSize;
|
|
569
|
+
}
|
|
570
|
+
get(key) {
|
|
571
|
+
const value = this.cache.get(key);
|
|
572
|
+
if (value !== void 0) {
|
|
573
|
+
this.cache.delete(key);
|
|
574
|
+
this.cache.set(key, value);
|
|
575
|
+
}
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
set(key, value) {
|
|
579
|
+
if (this.cache.has(key)) this.cache.delete(key);
|
|
580
|
+
this.cache.set(key, value);
|
|
581
|
+
if (this.cache.size > this.maxSize) {
|
|
582
|
+
const firstKey = this.cache.keys().next().value;
|
|
583
|
+
if (firstKey !== void 0) this.cache.delete(firstKey);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
has(key) {
|
|
587
|
+
return this.cache.get(key) !== void 0;
|
|
588
|
+
}
|
|
589
|
+
clear() {
|
|
590
|
+
this.cache.clear();
|
|
591
|
+
}
|
|
592
|
+
get size() {
|
|
593
|
+
return this.cache.size;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Analytics Manager - Main entry point for analytics tracking.
|
|
598
|
+
*
|
|
599
|
+
* Orchestrates multiple analytics providers, handles event deduplication,
|
|
600
|
+
* rate limiting, batch processing, and error handling.
|
|
601
|
+
*
|
|
602
|
+
* **Key Features**:
|
|
603
|
+
* - Multi-provider support (PostHog, Segment, Vercel, Console)
|
|
604
|
+
* - Event deduplication via LRU cache
|
|
605
|
+
* - Rate limiting (100 calls/second per provider)
|
|
606
|
+
* - Batch processing with concurrency control
|
|
607
|
+
* - Performance metrics tracking
|
|
608
|
+
* - Graceful error handling
|
|
609
|
+
*
|
|
610
|
+
* **Usage**:
|
|
611
|
+
* ```typescript
|
|
612
|
+
* const manager = new AnalyticsManager(config, providerRegistry);
|
|
613
|
+
* await manager.initialize();
|
|
614
|
+
* await manager.emit(track('Event Name', { property: 'value' }));
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
var AnalyticsManager = class {
|
|
618
|
+
providers = /* @__PURE__ */ new Map();
|
|
619
|
+
context = {};
|
|
620
|
+
isInitialized = false;
|
|
621
|
+
providerMetrics = /* @__PURE__ */ new Map();
|
|
622
|
+
eventCache = new LRUCache(1e3);
|
|
623
|
+
rateLimiter = new RateLimiter({
|
|
624
|
+
maxCalls: 100,
|
|
625
|
+
windowMs: 1e3,
|
|
626
|
+
maxConcurrent: 10,
|
|
627
|
+
queueExcess: false
|
|
628
|
+
});
|
|
629
|
+
constructor(config, availableProviders) {
|
|
630
|
+
this.config = config;
|
|
631
|
+
this.availableProviders = availableProviders;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Initialize all configured providers
|
|
635
|
+
* Throws error if all providers fail to initialize
|
|
636
|
+
*/
|
|
637
|
+
async initialize() {
|
|
638
|
+
if (this.isInitialized) return;
|
|
639
|
+
const initPromises = [];
|
|
640
|
+
const initStartTime = process.hrtime.bigint();
|
|
641
|
+
for (const [providerName, providerConfig] of Object.entries(this.config.providers)) {
|
|
642
|
+
const providerFactory = this.availableProviders[providerName];
|
|
643
|
+
if (providerFactory) try {
|
|
644
|
+
const provider = providerFactory(providerConfig);
|
|
645
|
+
this.providers.set(providerName, provider);
|
|
646
|
+
initPromises.push((async () => {
|
|
647
|
+
const providerInitStart = typeof process.hrtime?.bigint === "function" ? process.hrtime.bigint() : BigInt(Date.now() * 1e6);
|
|
648
|
+
try {
|
|
649
|
+
const timeoutSignal = AbortSignal.timeout(1e4);
|
|
650
|
+
const initPromise = provider.initialize(providerConfig);
|
|
651
|
+
await Promise.race([initPromise, new Promise((_resolve, reject) => {
|
|
652
|
+
timeoutSignal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`Provider ${providerName} initialization timed out`)));
|
|
653
|
+
})]);
|
|
654
|
+
const now = typeof process.hrtime?.bigint === "function" ? process.hrtime.bigint() : BigInt(Date.now() * 1e6);
|
|
655
|
+
this.providerMetrics.set(providerName, {
|
|
656
|
+
initTime: now - providerInitStart,
|
|
657
|
+
callCount: 0,
|
|
658
|
+
errorCount: 0,
|
|
659
|
+
lastUsed: now
|
|
660
|
+
});
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (this.config.onError) this.config.onError(error, {
|
|
663
|
+
provider: providerName,
|
|
664
|
+
method: "initialize"
|
|
665
|
+
});
|
|
666
|
+
this.providers.delete(providerName);
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
})());
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (this.config.onError) this.config.onError(error, {
|
|
672
|
+
provider: providerName,
|
|
673
|
+
method: "create"
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
else if (this.config.debug && this.config.onInfo) this.config.onInfo(`Provider ${providerName} not available in this environment`);
|
|
677
|
+
}
|
|
678
|
+
const results = await Promise.allSettled(initPromises);
|
|
679
|
+
const initEndTime = process.hrtime.bigint();
|
|
680
|
+
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
681
|
+
const failureCount = results.filter((r) => r.status === "rejected").length;
|
|
682
|
+
if (successCount === 0 && initPromises.length > 0) throw new Error(`Analytics initialization failed: All ${initPromises.length} providers failed to initialize`);
|
|
683
|
+
this.isInitialized = true;
|
|
684
|
+
if (this.config.debug && this.config.onInfo) {
|
|
685
|
+
const initTimeMs = Number(initEndTime - initStartTime) / 1e6;
|
|
686
|
+
this.config.onInfo(`Analytics initialized in ${initTimeMs.toFixed(2)}ms with ${successCount} providers (${failureCount} failed): ${[...this.providers.keys()].join(", ")}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Set global analytics context
|
|
691
|
+
* FIX #13: Use spread operator instead of structuredClone for better performance
|
|
692
|
+
*/
|
|
693
|
+
setContext(context) {
|
|
694
|
+
this.context = {
|
|
695
|
+
...this.context,
|
|
696
|
+
...context
|
|
697
|
+
};
|
|
698
|
+
for (const [providerName, provider] of this.providers) if (provider.setContext) try {
|
|
699
|
+
provider.setContext({ ...this.context });
|
|
700
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
701
|
+
if (metrics) metrics.lastUsed = process.hrtime.bigint();
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (this.config.onError) this.config.onError(error, {
|
|
704
|
+
provider: providerName,
|
|
705
|
+
method: "setContext",
|
|
706
|
+
context: Object.keys(context).join(", ")
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Get current analytics context
|
|
712
|
+
* FIX #13: Use spread operator instead of structuredClone
|
|
713
|
+
*/
|
|
714
|
+
getContext() {
|
|
715
|
+
return { ...this.context };
|
|
716
|
+
}
|
|
717
|
+
async track(eventOrPayload, properties, options) {
|
|
718
|
+
if (typeof eventOrPayload === "object") {
|
|
719
|
+
const payload = eventOrPayload;
|
|
720
|
+
return this.track(payload.event, payload.properties, {
|
|
721
|
+
...options,
|
|
722
|
+
context: {
|
|
723
|
+
...this.context,
|
|
724
|
+
...payload.context
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
const event = eventOrPayload;
|
|
729
|
+
if (!this.isInitialized) {
|
|
730
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
731
|
+
provider: "analytics",
|
|
732
|
+
event,
|
|
733
|
+
method: "track"
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const eventValidation = validateEventName(event);
|
|
738
|
+
if (!eventValidation.valid) {
|
|
739
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Invalid event name: ${eventValidation.reason}`), {
|
|
740
|
+
provider: "analytics",
|
|
741
|
+
event,
|
|
742
|
+
method: "track"
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const sanitized = sanitizeProperties(properties ?? {}, {
|
|
747
|
+
stripPII: false,
|
|
748
|
+
stripHTML: true,
|
|
749
|
+
allowDangerousKeys: false
|
|
750
|
+
});
|
|
751
|
+
const targetProviders = this.getTargetProviders(options);
|
|
752
|
+
const enhancedProperties = {
|
|
753
|
+
...this.context,
|
|
754
|
+
...sanitized.data
|
|
755
|
+
};
|
|
756
|
+
const promises = [...targetProviders.entries()].map(async ([name, provider]) => {
|
|
757
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
758
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${name}`);
|
|
759
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${name}`), {
|
|
760
|
+
provider: name,
|
|
761
|
+
event,
|
|
762
|
+
method: "track"
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
await provider.track(event, enhancedProperties, this.context);
|
|
768
|
+
const metrics = this.providerMetrics.get(name);
|
|
769
|
+
if (metrics) {
|
|
770
|
+
metrics.callCount++;
|
|
771
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
772
|
+
}
|
|
773
|
+
} catch (error) {
|
|
774
|
+
const metrics = this.providerMetrics.get(name);
|
|
775
|
+
if (metrics) metrics.errorCount++;
|
|
776
|
+
if (this.config.onError) this.config.onError(error, {
|
|
777
|
+
provider: name,
|
|
778
|
+
event,
|
|
779
|
+
method: "track"
|
|
780
|
+
});
|
|
781
|
+
} finally {
|
|
782
|
+
this.rateLimiter.release();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
await Promise.allSettled(promises);
|
|
786
|
+
}
|
|
787
|
+
async identify(userIdOrPayload, traits, options) {
|
|
788
|
+
if (typeof userIdOrPayload === "object") {
|
|
789
|
+
const payload = userIdOrPayload;
|
|
790
|
+
return this.identify(payload.userId, payload.traits, {
|
|
791
|
+
...options,
|
|
792
|
+
context: {
|
|
793
|
+
...this.context,
|
|
794
|
+
...payload.context
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const userId = userIdOrPayload;
|
|
799
|
+
if (!this.isInitialized) {
|
|
800
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
801
|
+
provider: "analytics",
|
|
802
|
+
method: "identify",
|
|
803
|
+
userId
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const sanitized = sanitizeProperties(traits ?? {}, {
|
|
808
|
+
stripPII: false,
|
|
809
|
+
stripHTML: true,
|
|
810
|
+
allowDangerousKeys: false
|
|
811
|
+
});
|
|
812
|
+
this.setContext({
|
|
813
|
+
userId,
|
|
814
|
+
...sanitized.data
|
|
815
|
+
});
|
|
816
|
+
const targetProviders = this.getTargetProviders(options);
|
|
817
|
+
const enhancedTraits = { ...sanitized.data };
|
|
818
|
+
const promises = [...targetProviders.entries()].map(async ([name, provider]) => {
|
|
819
|
+
if (provider.identify) {
|
|
820
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
821
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${name}`);
|
|
822
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${name}`), {
|
|
823
|
+
provider: name,
|
|
824
|
+
userId,
|
|
825
|
+
method: "identify"
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
await provider.identify(userId, enhancedTraits, {
|
|
831
|
+
...options,
|
|
832
|
+
context: this.context
|
|
833
|
+
});
|
|
834
|
+
const metrics = this.providerMetrics.get(name);
|
|
835
|
+
if (metrics) {
|
|
836
|
+
metrics.callCount++;
|
|
837
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
838
|
+
}
|
|
839
|
+
} catch (error) {
|
|
840
|
+
const metrics = this.providerMetrics.get(name);
|
|
841
|
+
if (metrics) metrics.errorCount++;
|
|
842
|
+
if (this.config.onError) this.config.onError(error, {
|
|
843
|
+
provider: name,
|
|
844
|
+
method: "identify",
|
|
845
|
+
userId
|
|
846
|
+
});
|
|
847
|
+
} finally {
|
|
848
|
+
this.rateLimiter.release();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
await Promise.allSettled(promises);
|
|
853
|
+
}
|
|
854
|
+
async page(nameOrPayload, properties, options) {
|
|
855
|
+
if (typeof nameOrPayload === "object") {
|
|
856
|
+
const payload = nameOrPayload;
|
|
857
|
+
return this.page(payload.name, payload.properties, {
|
|
858
|
+
...options,
|
|
859
|
+
context: {
|
|
860
|
+
...this.context,
|
|
861
|
+
...payload.context
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const name = nameOrPayload;
|
|
866
|
+
if (!this.isInitialized) {
|
|
867
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
868
|
+
provider: "analytics",
|
|
869
|
+
name,
|
|
870
|
+
method: "page"
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const sanitized = sanitizeProperties(properties ?? {}, {
|
|
875
|
+
stripPII: false,
|
|
876
|
+
stripHTML: true,
|
|
877
|
+
allowDangerousKeys: false
|
|
878
|
+
});
|
|
879
|
+
const targetProviders = this.getTargetProviders(options);
|
|
880
|
+
const enhancedProperties = { ...sanitized.data };
|
|
881
|
+
const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
|
|
882
|
+
if (provider.page) {
|
|
883
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
884
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
|
|
885
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${providerName}`), {
|
|
886
|
+
provider: providerName,
|
|
887
|
+
name,
|
|
888
|
+
method: "page"
|
|
889
|
+
});
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
await provider.page(name ?? "", enhancedProperties, {
|
|
894
|
+
...options,
|
|
895
|
+
context: this.context
|
|
896
|
+
});
|
|
897
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
898
|
+
if (metrics) {
|
|
899
|
+
metrics.callCount++;
|
|
900
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
901
|
+
}
|
|
902
|
+
} catch (error) {
|
|
903
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
904
|
+
if (metrics) metrics.errorCount++;
|
|
905
|
+
if (this.config.onError) this.config.onError(error, {
|
|
906
|
+
provider: providerName,
|
|
907
|
+
name: name ?? "",
|
|
908
|
+
method: "page"
|
|
909
|
+
});
|
|
910
|
+
} finally {
|
|
911
|
+
this.rateLimiter.release();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
await Promise.allSettled(promises);
|
|
916
|
+
}
|
|
917
|
+
async screen(nameOrPayload, properties, options) {
|
|
918
|
+
if (typeof nameOrPayload === "object") {
|
|
919
|
+
const payload = nameOrPayload;
|
|
920
|
+
return this.screen(payload.name, payload.properties, {
|
|
921
|
+
...options,
|
|
922
|
+
context: {
|
|
923
|
+
...this.context,
|
|
924
|
+
...payload.context
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const name = nameOrPayload;
|
|
929
|
+
if (!this.isInitialized) {
|
|
930
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
931
|
+
provider: "analytics",
|
|
932
|
+
name,
|
|
933
|
+
method: "screen"
|
|
934
|
+
});
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const sanitized = sanitizeProperties(properties ?? {}, {
|
|
938
|
+
stripPII: false,
|
|
939
|
+
stripHTML: true,
|
|
940
|
+
allowDangerousKeys: false
|
|
941
|
+
});
|
|
942
|
+
const targetProviders = this.getTargetProviders(options);
|
|
943
|
+
const enhancedProperties = { ...sanitized.data };
|
|
944
|
+
const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
|
|
945
|
+
if (provider.screen) {
|
|
946
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
947
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
|
|
948
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${providerName}`), {
|
|
949
|
+
provider: providerName,
|
|
950
|
+
name,
|
|
951
|
+
method: "screen"
|
|
952
|
+
});
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
await provider.screen(name ?? "", enhancedProperties, {
|
|
957
|
+
...options,
|
|
958
|
+
context: this.context
|
|
959
|
+
});
|
|
960
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
961
|
+
if (metrics) {
|
|
962
|
+
metrics.callCount++;
|
|
963
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
964
|
+
}
|
|
965
|
+
} catch (error) {
|
|
966
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
967
|
+
if (metrics) metrics.errorCount++;
|
|
968
|
+
if (this.config.onError) this.config.onError(error, {
|
|
969
|
+
provider: providerName,
|
|
970
|
+
name: name ?? "",
|
|
971
|
+
method: "screen"
|
|
972
|
+
});
|
|
973
|
+
} finally {
|
|
974
|
+
this.rateLimiter.release();
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
await Promise.allSettled(promises);
|
|
979
|
+
}
|
|
980
|
+
async group(groupIdOrPayload, traits, options) {
|
|
981
|
+
if (typeof groupIdOrPayload === "object") {
|
|
982
|
+
const payload = groupIdOrPayload;
|
|
983
|
+
return this.group(payload.groupId, payload.traits, {
|
|
984
|
+
...options,
|
|
985
|
+
context: {
|
|
986
|
+
...this.context,
|
|
987
|
+
...payload.context
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
const groupId = groupIdOrPayload;
|
|
992
|
+
if (!this.isInitialized) {
|
|
993
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
994
|
+
provider: "analytics",
|
|
995
|
+
groupId,
|
|
996
|
+
method: "group"
|
|
997
|
+
});
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const sanitized = sanitizeProperties(traits ?? {}, {
|
|
1001
|
+
stripPII: false,
|
|
1002
|
+
stripHTML: true,
|
|
1003
|
+
allowDangerousKeys: false
|
|
1004
|
+
});
|
|
1005
|
+
this.setContext({
|
|
1006
|
+
organizationId: groupId,
|
|
1007
|
+
...sanitized.data
|
|
1008
|
+
});
|
|
1009
|
+
const targetProviders = this.getTargetProviders(options);
|
|
1010
|
+
const enhancedTraits = { ...sanitized.data };
|
|
1011
|
+
const promises = [...targetProviders.entries()].map(async ([providerName, provider]) => {
|
|
1012
|
+
if (provider.group) {
|
|
1013
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
1014
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
|
|
1015
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${providerName}`), {
|
|
1016
|
+
provider: providerName,
|
|
1017
|
+
groupId,
|
|
1018
|
+
method: "group"
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
await provider.group(groupId, enhancedTraits, {
|
|
1024
|
+
...options,
|
|
1025
|
+
context: this.context
|
|
1026
|
+
});
|
|
1027
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
1028
|
+
if (metrics) {
|
|
1029
|
+
metrics.callCount++;
|
|
1030
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
1031
|
+
}
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
1034
|
+
if (metrics) metrics.errorCount++;
|
|
1035
|
+
if (this.config.onError) this.config.onError(error, {
|
|
1036
|
+
provider: providerName,
|
|
1037
|
+
groupId,
|
|
1038
|
+
method: "group"
|
|
1039
|
+
});
|
|
1040
|
+
} finally {
|
|
1041
|
+
this.rateLimiter.release();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
await Promise.allSettled(promises);
|
|
1046
|
+
}
|
|
1047
|
+
async alias(userIdOrPayload, previousId, options) {
|
|
1048
|
+
if (typeof userIdOrPayload === "object") {
|
|
1049
|
+
const payload = userIdOrPayload;
|
|
1050
|
+
return this.alias(payload.userId, payload.previousId, {
|
|
1051
|
+
...options,
|
|
1052
|
+
context: {
|
|
1053
|
+
...this.context,
|
|
1054
|
+
...payload.context
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const userId = userIdOrPayload;
|
|
1059
|
+
const prevId = previousId;
|
|
1060
|
+
if (!this.isInitialized) {
|
|
1061
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error("Analytics not initialized"), {
|
|
1062
|
+
provider: "analytics",
|
|
1063
|
+
method: "alias",
|
|
1064
|
+
previousId: prevId,
|
|
1065
|
+
userId
|
|
1066
|
+
});
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const promises = [...this.getTargetProviders(options).entries()].map(async ([providerName, provider]) => {
|
|
1070
|
+
if (provider.alias) {
|
|
1071
|
+
if (!await this.rateLimiter.tryAcquire()) {
|
|
1072
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo(`Rate limit exceeded for provider ${providerName}`);
|
|
1073
|
+
if (this.config.onError) this.config.onError(/* @__PURE__ */ new Error(`Rate limit exceeded for provider ${providerName}`), {
|
|
1074
|
+
provider: providerName,
|
|
1075
|
+
userId,
|
|
1076
|
+
previousId,
|
|
1077
|
+
method: "alias"
|
|
1078
|
+
});
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
try {
|
|
1082
|
+
await provider.alias(userId, prevId, this.context);
|
|
1083
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
1084
|
+
if (metrics) {
|
|
1085
|
+
metrics.callCount++;
|
|
1086
|
+
metrics.lastUsed = process.hrtime.bigint();
|
|
1087
|
+
}
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
const metrics = this.providerMetrics.get(providerName);
|
|
1090
|
+
if (metrics) metrics.errorCount++;
|
|
1091
|
+
if (this.config.onError) this.config.onError(error, {
|
|
1092
|
+
provider: providerName,
|
|
1093
|
+
method: "alias",
|
|
1094
|
+
previousId: prevId,
|
|
1095
|
+
userId
|
|
1096
|
+
});
|
|
1097
|
+
} finally {
|
|
1098
|
+
this.rateLimiter.release();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
await Promise.allSettled(promises);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Get list of active provider names
|
|
1106
|
+
*/
|
|
1107
|
+
getActiveProviders() {
|
|
1108
|
+
return [...this.providers.keys()];
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Get a specific provider instance
|
|
1112
|
+
*/
|
|
1113
|
+
getProvider(name) {
|
|
1114
|
+
return this.providers.get(name);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Reset analytics context and identity
|
|
1118
|
+
*/
|
|
1119
|
+
reset() {
|
|
1120
|
+
this.context = {};
|
|
1121
|
+
this.eventCache.clear();
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Shutdown all providers and cleanup resources
|
|
1125
|
+
*/
|
|
1126
|
+
async shutdown() {
|
|
1127
|
+
const shutdownPromises = [...this.providers.entries()].map(async ([name, provider]) => {
|
|
1128
|
+
try {
|
|
1129
|
+
if (typeof provider.destroy === "function") await provider.destroy();
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (this.config.onError) this.config.onError(error, {
|
|
1132
|
+
provider: name,
|
|
1133
|
+
method: "shutdown"
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
await Promise.allSettled(shutdownPromises);
|
|
1138
|
+
this.providers.clear();
|
|
1139
|
+
this.isInitialized = false;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Process any emitter payload with Node 22+ optimizations
|
|
1143
|
+
* FIX #12: Use LRU cache for event deduplication (prevents memory leaks)
|
|
1144
|
+
*/
|
|
1145
|
+
async emit(payload, options) {
|
|
1146
|
+
const emitStartTime = process.hrtime.bigint();
|
|
1147
|
+
const createCacheKey = (p) => {
|
|
1148
|
+
const sorted = (obj) => {
|
|
1149
|
+
if (obj === null || typeof obj !== "object" || obj instanceof Array) return obj;
|
|
1150
|
+
const sortedObj = {};
|
|
1151
|
+
for (const key of Object.keys(obj).sort()) sortedObj[key] = sorted(obj[key]);
|
|
1152
|
+
return sortedObj;
|
|
1153
|
+
};
|
|
1154
|
+
return JSON.stringify(sorted(p));
|
|
1155
|
+
};
|
|
1156
|
+
const cacheKey = createCacheKey(payload);
|
|
1157
|
+
if (this.eventCache.has(cacheKey)) {
|
|
1158
|
+
if (this.eventCache.get(cacheKey)?.processed) {
|
|
1159
|
+
if (this.config.debug && this.config.onInfo) this.config.onInfo("Skipping duplicate event processing");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
this.eventCache.set(cacheKey, {
|
|
1164
|
+
timestamp: emitStartTime,
|
|
1165
|
+
processed: true
|
|
1166
|
+
});
|
|
1167
|
+
try {
|
|
1168
|
+
if (options?.timeout) {
|
|
1169
|
+
const timeoutSignal = AbortSignal.timeout(options.timeout);
|
|
1170
|
+
await Promise.race([this.processPayloadByType(payload), new Promise((_resolve, reject) => {
|
|
1171
|
+
timeoutSignal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`Event processing timed out after ${options.timeout}ms`)));
|
|
1172
|
+
})]);
|
|
1173
|
+
} else await this.processPayloadByType(payload);
|
|
1174
|
+
if (this.config.debug && this.config.onInfo) {
|
|
1175
|
+
const processingTime = Number(process.hrtime.bigint() - emitStartTime) / 1e6;
|
|
1176
|
+
this.config.onInfo(`Event processed in ${processingTime.toFixed(2)}ms`);
|
|
1177
|
+
}
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
this.eventCache.set(cacheKey, {
|
|
1180
|
+
timestamp: emitStartTime,
|
|
1181
|
+
processed: false
|
|
1182
|
+
});
|
|
1183
|
+
throw error;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Route payload to appropriate method based on type
|
|
1188
|
+
*/
|
|
1189
|
+
async processPayloadByType(payload) {
|
|
1190
|
+
switch (payload.type) {
|
|
1191
|
+
case "track": return this.track(payload);
|
|
1192
|
+
case "identify": return this.identify(payload);
|
|
1193
|
+
case "page": return this.page(payload);
|
|
1194
|
+
case "screen": return this.screen(payload);
|
|
1195
|
+
case "group": return this.group(payload);
|
|
1196
|
+
case "alias": return this.alias(payload);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Process an emitter payload (legacy method - use emit() instead)
|
|
1201
|
+
* @deprecated Use emit() for better type safety
|
|
1202
|
+
*/
|
|
1203
|
+
async processEmitterPayload(payload) {
|
|
1204
|
+
return this.emit(payload);
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Batch emit multiple payloads with Node 22+ optimizations
|
|
1208
|
+
* FIX #14: Process chunks concurrently instead of sequentially
|
|
1209
|
+
* FIX #13: Use spread operator instead of structuredClone
|
|
1210
|
+
*/
|
|
1211
|
+
async emitBatch(payloads, options) {
|
|
1212
|
+
const batchStartTime = process.hrtime.bigint();
|
|
1213
|
+
const concurrency = options?.concurrency ?? 10;
|
|
1214
|
+
if (payloads.length === 0) return;
|
|
1215
|
+
const clonedPayloads = payloads.map((p) => structuredClone(p));
|
|
1216
|
+
const chunks = [];
|
|
1217
|
+
for (let i = 0; i < clonedPayloads.length; i += concurrency) chunks.push(clonedPayloads.slice(i, i + concurrency));
|
|
1218
|
+
const processChunk = async (chunk) => {
|
|
1219
|
+
const chunkPromises = chunk.map((payload) => this.emit(payload, options?.timeout !== void 0 ? { timeout: options.timeout } : void 0));
|
|
1220
|
+
if (options?.failFast) await Promise.all(chunkPromises);
|
|
1221
|
+
else await Promise.allSettled(chunkPromises);
|
|
1222
|
+
};
|
|
1223
|
+
const chunkPromises = chunks.map((chunk) => processChunk(chunk));
|
|
1224
|
+
await Promise.all(chunkPromises);
|
|
1225
|
+
if (this.config.debug && this.config.onInfo) {
|
|
1226
|
+
const batchTime = Number(process.hrtime.bigint() - batchStartTime) / 1e6;
|
|
1227
|
+
this.config.onInfo(`Batch of ${payloads.length} events processed in ${batchTime.toFixed(2)}ms (concurrency: ${concurrency})`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Create a bound emitter function for convenience
|
|
1232
|
+
*/
|
|
1233
|
+
createEmitter() {
|
|
1234
|
+
return this.emit.bind(this);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Track an ecommerce event specification
|
|
1238
|
+
* @deprecated Use emit(ecommerce.EVENT_NAME(...)) instead
|
|
1239
|
+
*/
|
|
1240
|
+
async trackEcommerce(eventSpec) {
|
|
1241
|
+
return this.track(eventSpec.name, eventSpec.properties);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Get target providers based on tracking options
|
|
1245
|
+
*/
|
|
1246
|
+
getTargetProviders(options) {
|
|
1247
|
+
let targetProviders = new Map(this.providers);
|
|
1248
|
+
if (options) {
|
|
1249
|
+
if (options.providers && Object.hasOwn(options, "providers")) for (const [name, config] of Object.entries(options.providers)) {
|
|
1250
|
+
const factory = this.availableProviders[name];
|
|
1251
|
+
if (factory) try {
|
|
1252
|
+
const provider = factory(config);
|
|
1253
|
+
targetProviders.set(name, provider);
|
|
1254
|
+
this.providerMetrics.set(name, {
|
|
1255
|
+
initTime: process.hrtime.bigint(),
|
|
1256
|
+
callCount: 0,
|
|
1257
|
+
errorCount: 0,
|
|
1258
|
+
lastUsed: process.hrtime.bigint()
|
|
1259
|
+
});
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
if (this.config.onError) this.config.onError(error, {
|
|
1262
|
+
provider: name,
|
|
1263
|
+
method: "runtime-create"
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (options.only && Object.hasOwn(options, "only")) {
|
|
1268
|
+
const onlyProviders = /* @__PURE__ */ new Map();
|
|
1269
|
+
for (const name of options.only) if (targetProviders.has(name)) onlyProviders.set(name, targetProviders.get(name));
|
|
1270
|
+
targetProviders = onlyProviders;
|
|
1271
|
+
}
|
|
1272
|
+
if (options.exclude && Object.hasOwn(options, "exclude")) for (const name of options.exclude) targetProviders.delete(name);
|
|
1273
|
+
}
|
|
1274
|
+
return targetProviders;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Get analytics performance metrics using Node 22+ features
|
|
1278
|
+
*/
|
|
1279
|
+
getAnalyticsMetrics() {
|
|
1280
|
+
const now = process.hrtime.bigint();
|
|
1281
|
+
const providers = {};
|
|
1282
|
+
for (const [providerName, metrics] of this.providerMetrics) providers[providerName] = {
|
|
1283
|
+
initTimeMs: Number(metrics.initTime) / 1e6,
|
|
1284
|
+
callCount: metrics.callCount,
|
|
1285
|
+
errorCount: metrics.errorCount,
|
|
1286
|
+
lastUsedMs: Number(now - metrics.lastUsed) / 1e6,
|
|
1287
|
+
successRate: metrics.callCount > 0 ? 1 - metrics.errorCount / metrics.callCount : 1
|
|
1288
|
+
};
|
|
1289
|
+
return {
|
|
1290
|
+
providers,
|
|
1291
|
+
events: {
|
|
1292
|
+
totalProcessed: this.eventCache.size,
|
|
1293
|
+
cacheSize: this.eventCache.size,
|
|
1294
|
+
memoryUsage: process.memoryUsage()
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Health check for analytics system using Node 22+ timing
|
|
1300
|
+
* FIX #16: Send actual test events instead of just setContext
|
|
1301
|
+
*/
|
|
1302
|
+
async healthCheck(timeout = 5e3) {
|
|
1303
|
+
const checkStartTime = process.hrtime.bigint();
|
|
1304
|
+
const providerHealth = {};
|
|
1305
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
1306
|
+
try {
|
|
1307
|
+
const healthPromises = [...this.providers.entries()].map(async ([name, provider]) => {
|
|
1308
|
+
try {
|
|
1309
|
+
await Promise.race([provider.track("__health_check__", {
|
|
1310
|
+
timestamp: Date.now(),
|
|
1311
|
+
provider: name
|
|
1312
|
+
}), new Promise((_resolve, reject) => {
|
|
1313
|
+
timeoutSignal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("Health check timeout")));
|
|
1314
|
+
})]);
|
|
1315
|
+
providerHealth[name] = true;
|
|
1316
|
+
} catch {
|
|
1317
|
+
providerHealth[name] = false;
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
await Promise.allSettled(healthPromises);
|
|
1321
|
+
const checkEndTime = process.hrtime.bigint();
|
|
1322
|
+
const totalCheckTime = Number(checkEndTime - checkStartTime) / 1e6;
|
|
1323
|
+
return {
|
|
1324
|
+
healthy: Object.values(providerHealth).filter(Boolean).length > 0 && this.isInitialized,
|
|
1325
|
+
providers: providerHealth,
|
|
1326
|
+
metrics: this.getAnalyticsMetrics(),
|
|
1327
|
+
totalCheckTime
|
|
1328
|
+
};
|
|
1329
|
+
} catch {
|
|
1330
|
+
return {
|
|
1331
|
+
healthy: false,
|
|
1332
|
+
providers: providerHealth,
|
|
1333
|
+
metrics: this.getAnalyticsMetrics(),
|
|
1334
|
+
totalCheckTime: Number(process.hrtime.bigint() - checkStartTime) / 1e6
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
/**
|
|
1340
|
+
* Factory function to create analytics manager
|
|
1341
|
+
*/
|
|
1342
|
+
function createAnalyticsManager(config, availableProviders) {
|
|
1343
|
+
return new AnalyticsManager(config, availableProviders);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/shared/emitters/helpers.ts
|
|
1348
|
+
/**
|
|
1349
|
+
* @fileoverview Helper Functions for Analytics Emitters
|
|
1350
|
+
*
|
|
1351
|
+
* These utilities make it easier to use the emitter-first approach for analytics.
|
|
1352
|
+
* Provides builders, batch processing, session management, and convenience
|
|
1353
|
+
* functions for common tracking patterns.
|
|
1354
|
+
*
|
|
1355
|
+
* **Builders**:
|
|
1356
|
+
* - `ContextBuilder`: Build consistent context across events
|
|
1357
|
+
* - `PayloadBuilder`: Chain emitter creation with shared options
|
|
1358
|
+
* - `EventBatch`: Batch related events with shared context
|
|
1359
|
+
*
|
|
1360
|
+
* **Session Management**:
|
|
1361
|
+
* - `createUserSession()`: Create a user session tracking flow
|
|
1362
|
+
* - `createAnonymousSession()`: Create an anonymous user tracking flow
|
|
1363
|
+
*
|
|
1364
|
+
* **Convenience Functions**:
|
|
1365
|
+
* - `withMetadata()`: Add consistent metadata to events
|
|
1366
|
+
* - `withUTM()`: Add UTM parameters to events
|
|
1367
|
+
* - Type guards: `isTrackPayload()`, `isIdentifyPayload()`, etc.
|
|
1368
|
+
*
|
|
1369
|
+
* @module @od-oneapp/analytics/shared/emitters/helpers
|
|
1370
|
+
*/
|
|
1371
|
+
/**
|
|
1372
|
+
* Builder for creating consistent context across analytics events.
|
|
1373
|
+
*
|
|
1374
|
+
* @remarks
|
|
1375
|
+
* Use this builder to create a reusable context object that can be applied
|
|
1376
|
+
* to multiple events. This ensures consistency and reduces repetition.
|
|
1377
|
+
*
|
|
1378
|
+
* @example
|
|
1379
|
+
* ```typescript
|
|
1380
|
+
* const context = new ContextBuilder()
|
|
1381
|
+
* .setUser('user-123', { plan: 'pro' })
|
|
1382
|
+
* .setOrganization('org-456')
|
|
1383
|
+
* .setPage({ path: '/dashboard', title: 'Dashboard' })
|
|
1384
|
+
* .build();
|
|
1385
|
+
*
|
|
1386
|
+
* await analytics.emit(track('Button Clicked', {}, { context }));
|
|
1387
|
+
* ```
|
|
1388
|
+
*/
|
|
1389
|
+
var ContextBuilder = class {
|
|
1390
|
+
context = {};
|
|
1391
|
+
constructor(initialContext) {
|
|
1392
|
+
if (initialContext) this.context = { ...initialContext };
|
|
1393
|
+
}
|
|
1394
|
+
setUser(_userId, traits) {
|
|
1395
|
+
this.context.traits = {
|
|
1396
|
+
...this.context.traits,
|
|
1397
|
+
...traits
|
|
1398
|
+
};
|
|
1399
|
+
return this;
|
|
1400
|
+
}
|
|
1401
|
+
setOrganization(groupId) {
|
|
1402
|
+
this.context.groupId = groupId;
|
|
1403
|
+
return this;
|
|
1404
|
+
}
|
|
1405
|
+
setPage(pageInfo) {
|
|
1406
|
+
this.context.page = {
|
|
1407
|
+
...this.context.page,
|
|
1408
|
+
...pageInfo
|
|
1409
|
+
};
|
|
1410
|
+
return this;
|
|
1411
|
+
}
|
|
1412
|
+
setCampaign(utmParams) {
|
|
1413
|
+
this.context.campaign = {
|
|
1414
|
+
...this.context.campaign,
|
|
1415
|
+
...utmParams
|
|
1416
|
+
};
|
|
1417
|
+
return this;
|
|
1418
|
+
}
|
|
1419
|
+
setDevice(deviceInfo) {
|
|
1420
|
+
this.context.device = {
|
|
1421
|
+
...this.context.device,
|
|
1422
|
+
...deviceInfo
|
|
1423
|
+
};
|
|
1424
|
+
return this;
|
|
1425
|
+
}
|
|
1426
|
+
build() {
|
|
1427
|
+
return { ...this.context };
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* Builder for chaining emitter creation with shared options.
|
|
1432
|
+
*
|
|
1433
|
+
* @remarks
|
|
1434
|
+
* Use this builder to create multiple events with shared options like
|
|
1435
|
+
* timestamp, anonymousId, or integrations. This is useful when tracking
|
|
1436
|
+
* multiple related events in a single flow.
|
|
1437
|
+
*
|
|
1438
|
+
* @example
|
|
1439
|
+
* ```typescript
|
|
1440
|
+
* const builder = new PayloadBuilder(context)
|
|
1441
|
+
* .withTimestamp(new Date())
|
|
1442
|
+
* .withAnonymousId('anon-123');
|
|
1443
|
+
*
|
|
1444
|
+
* await analytics.emit(builder.track('Event 1', {}));
|
|
1445
|
+
* await analytics.emit(builder.track('Event 2', {}));
|
|
1446
|
+
* ```
|
|
1447
|
+
*/
|
|
1448
|
+
var PayloadBuilder = class {
|
|
1449
|
+
options = {};
|
|
1450
|
+
constructor(context) {
|
|
1451
|
+
if (context) this.options.context = context;
|
|
1452
|
+
}
|
|
1453
|
+
withTimestamp(timestamp) {
|
|
1454
|
+
this.options.timestamp = timestamp;
|
|
1455
|
+
return this;
|
|
1456
|
+
}
|
|
1457
|
+
withAnonymousId(anonymousId) {
|
|
1458
|
+
this.options.anonymousId = anonymousId;
|
|
1459
|
+
return this;
|
|
1460
|
+
}
|
|
1461
|
+
withIntegrations(integrations) {
|
|
1462
|
+
this.options.integrations = integrations;
|
|
1463
|
+
return this;
|
|
1464
|
+
}
|
|
1465
|
+
track(event, properties) {
|
|
1466
|
+
return track(event, properties, this.options);
|
|
1467
|
+
}
|
|
1468
|
+
identify(userId, traits) {
|
|
1469
|
+
return identify(userId, traits, this.options);
|
|
1470
|
+
}
|
|
1471
|
+
page(name, properties) {
|
|
1472
|
+
return page(void 0, name, properties, this.options);
|
|
1473
|
+
}
|
|
1474
|
+
group(groupId, traits) {
|
|
1475
|
+
return group(groupId, traits, this.options);
|
|
1476
|
+
}
|
|
1477
|
+
alias(userId, previousId) {
|
|
1478
|
+
return alias(userId, previousId, this.options);
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
/**
|
|
1482
|
+
* Batch processor for related events with shared context.
|
|
1483
|
+
*
|
|
1484
|
+
* @remarks
|
|
1485
|
+
* Use this class to collect multiple events and emit them together with
|
|
1486
|
+
* a shared context. This is useful for tracking user flows or multi-step
|
|
1487
|
+
* processes where events should be grouped together.
|
|
1488
|
+
*
|
|
1489
|
+
* @example
|
|
1490
|
+
* ```typescript
|
|
1491
|
+
* const batch = new EventBatch(context)
|
|
1492
|
+
* .addTrack('Step 1 Completed', { step: 1 })
|
|
1493
|
+
* .addTrack('Step 2 Completed', { step: 2 })
|
|
1494
|
+
* .addTrack('Flow Completed', { totalSteps: 2 });
|
|
1495
|
+
*
|
|
1496
|
+
* for (const event of batch.getEvents()) {
|
|
1497
|
+
* await analytics.emit(event);
|
|
1498
|
+
* }
|
|
1499
|
+
* ```
|
|
1500
|
+
*/
|
|
1501
|
+
var EventBatch = class {
|
|
1502
|
+
events = [];
|
|
1503
|
+
sharedContext;
|
|
1504
|
+
constructor(context) {
|
|
1505
|
+
this.sharedContext = context ?? {};
|
|
1506
|
+
}
|
|
1507
|
+
add(payload) {
|
|
1508
|
+
const enrichedPayload = {
|
|
1509
|
+
...payload,
|
|
1510
|
+
context: {
|
|
1511
|
+
...this.sharedContext,
|
|
1512
|
+
...payload.context
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
this.events.push(enrichedPayload);
|
|
1516
|
+
return this;
|
|
1517
|
+
}
|
|
1518
|
+
addTrack(event, properties) {
|
|
1519
|
+
return this.add(track(event, properties, { context: this.sharedContext }));
|
|
1520
|
+
}
|
|
1521
|
+
addIdentify(userId, traits) {
|
|
1522
|
+
return this.add(identify(userId, traits, { context: this.sharedContext }));
|
|
1523
|
+
}
|
|
1524
|
+
addPage(name, properties) {
|
|
1525
|
+
return this.add(page(void 0, name, properties, { context: this.sharedContext }));
|
|
1526
|
+
}
|
|
1527
|
+
addGroup(groupId, traits) {
|
|
1528
|
+
return this.add(group(groupId, traits, { context: this.sharedContext }));
|
|
1529
|
+
}
|
|
1530
|
+
getEvents() {
|
|
1531
|
+
return [...this.events];
|
|
1532
|
+
}
|
|
1533
|
+
clear() {
|
|
1534
|
+
this.events = [];
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Creates a user session tracking flow with pre-configured context.
|
|
1539
|
+
*
|
|
1540
|
+
* @remarks
|
|
1541
|
+
* This helper creates a session object with methods for tracking events
|
|
1542
|
+
* within a specific user session. All events will include the sessionId
|
|
1543
|
+
* and user context automatically.
|
|
1544
|
+
*
|
|
1545
|
+
* @param userId - Unique identifier for the user
|
|
1546
|
+
* @param sessionId - Unique identifier for the session
|
|
1547
|
+
* @returns Session object with `identify`, `track`, `page`, and `group` methods
|
|
1548
|
+
*
|
|
1549
|
+
* @example
|
|
1550
|
+
* ```typescript
|
|
1551
|
+
* const session = createUserSession('user-123', 'session-456');
|
|
1552
|
+
* await analytics.emit(session.track('Button Clicked', { button: 'signup' }));
|
|
1553
|
+
* await analytics.emit(session.page('Dashboard'));
|
|
1554
|
+
* ```
|
|
1555
|
+
*/
|
|
1556
|
+
function createUserSession(userId, sessionId) {
|
|
1557
|
+
const context = new ContextBuilder().setUser(userId).build();
|
|
1558
|
+
return {
|
|
1559
|
+
identify: (traits) => identify(userId, {
|
|
1560
|
+
...traits,
|
|
1561
|
+
sessionId
|
|
1562
|
+
}, { context }),
|
|
1563
|
+
track: (event, properties) => track(event, {
|
|
1564
|
+
...properties,
|
|
1565
|
+
sessionId
|
|
1566
|
+
}, { context }),
|
|
1567
|
+
page: (name, properties) => page(void 0, name, {
|
|
1568
|
+
...properties,
|
|
1569
|
+
sessionId
|
|
1570
|
+
}, { context }),
|
|
1571
|
+
group: (groupId, traits) => group(groupId, {
|
|
1572
|
+
...traits,
|
|
1573
|
+
sessionId
|
|
1574
|
+
}, { context })
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Creates an anonymous user tracking flow.
|
|
1579
|
+
*
|
|
1580
|
+
* @remarks
|
|
1581
|
+
* This helper creates a session object for tracking anonymous users before
|
|
1582
|
+
* they are identified. When the user signs up or logs in, use the `alias`
|
|
1583
|
+
* method to link their anonymous activity to their user ID.
|
|
1584
|
+
*
|
|
1585
|
+
* @param anonymousId - Unique identifier for the anonymous user
|
|
1586
|
+
* @returns Session object with `track`, `page`, `identify`, and `alias` methods
|
|
1587
|
+
*
|
|
1588
|
+
* @example
|
|
1589
|
+
* ```typescript
|
|
1590
|
+
* const anonymousSession = createAnonymousSession('anon-123');
|
|
1591
|
+
* await analytics.emit(anonymousSession.track('Page Viewed', { page: 'home' }));
|
|
1592
|
+
*
|
|
1593
|
+
* // Later, when user signs up
|
|
1594
|
+
* await analytics.emit(anonymousSession.alias('user-123'));
|
|
1595
|
+
* await analytics.emit(anonymousSession.identify('user-123', { email: 'user@example.com' }));
|
|
1596
|
+
* ```
|
|
1597
|
+
*/
|
|
1598
|
+
function createAnonymousSession(anonymousId) {
|
|
1599
|
+
const options = { anonymousId };
|
|
1600
|
+
return {
|
|
1601
|
+
track: (event, properties) => track(event, properties, options),
|
|
1602
|
+
page: (name, properties) => page(void 0, name, properties, options),
|
|
1603
|
+
identify: (userId, traits) => identify(userId, traits, options),
|
|
1604
|
+
alias: (userId) => alias(userId, anonymousId, options)
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Type guard to check if a payload is a track event.
|
|
1609
|
+
*
|
|
1610
|
+
* @param payload - The payload to check
|
|
1611
|
+
* @returns True if the payload is a track event
|
|
1612
|
+
*/
|
|
1613
|
+
const isTrackPayload = (payload) => payload.type === "track";
|
|
1614
|
+
/**
|
|
1615
|
+
* Type guard to check if a payload is an identify event.
|
|
1616
|
+
*
|
|
1617
|
+
* @param payload - The payload to check
|
|
1618
|
+
* @returns True if the payload is an identify event
|
|
1619
|
+
*/
|
|
1620
|
+
const isIdentifyPayload = (payload) => payload.type === "identify";
|
|
1621
|
+
/**
|
|
1622
|
+
* Type guard to check if a payload is a page event.
|
|
1623
|
+
*
|
|
1624
|
+
* @param payload - The payload to check
|
|
1625
|
+
* @returns True if the payload is a page event
|
|
1626
|
+
*/
|
|
1627
|
+
const isPagePayload = (payload) => payload.type === "page";
|
|
1628
|
+
/**
|
|
1629
|
+
* Type guard to check if a payload is a group event.
|
|
1630
|
+
*
|
|
1631
|
+
* @param payload - The payload to check
|
|
1632
|
+
* @returns True if the payload is a group event
|
|
1633
|
+
*/
|
|
1634
|
+
const isGroupPayload = (payload) => payload.type === "group";
|
|
1635
|
+
/**
|
|
1636
|
+
* Type guard to check if a payload is an alias event.
|
|
1637
|
+
*
|
|
1638
|
+
* @param payload - The payload to check
|
|
1639
|
+
* @returns True if the payload is an alias event
|
|
1640
|
+
*/
|
|
1641
|
+
const isAliasPayload = (payload) => payload.type === "alias";
|
|
1642
|
+
/**
|
|
1643
|
+
* Adds consistent metadata to an analytics event payload.
|
|
1644
|
+
*
|
|
1645
|
+
* @remarks
|
|
1646
|
+
* This helper enriches an event payload with metadata that will be included
|
|
1647
|
+
* in the event's context.app object. Useful for tracking version, source,
|
|
1648
|
+
* or other application-level metadata.
|
|
1649
|
+
*
|
|
1650
|
+
* @param payload - The event payload to enrich
|
|
1651
|
+
* @param metadata - Metadata to add (version, source, or custom properties)
|
|
1652
|
+
* @returns The enriched payload with metadata in context.app
|
|
1653
|
+
*
|
|
1654
|
+
* @example
|
|
1655
|
+
* ```typescript
|
|
1656
|
+
* const event = track('Button Clicked', { button: 'signup' });
|
|
1657
|
+
* const enriched = withMetadata(event, { version: '1.0.0', source: 'webapp' });
|
|
1658
|
+
* await analytics.emit(enriched);
|
|
1659
|
+
* ```
|
|
1660
|
+
*/
|
|
1661
|
+
function withMetadata(payload, metadata) {
|
|
1662
|
+
return {
|
|
1663
|
+
...payload,
|
|
1664
|
+
context: {
|
|
1665
|
+
...payload.context,
|
|
1666
|
+
app: {
|
|
1667
|
+
...payload.context?.app,
|
|
1668
|
+
...metadata
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Adds UTM parameters to an analytics event payload.
|
|
1675
|
+
*
|
|
1676
|
+
* @remarks
|
|
1677
|
+
* This helper enriches an event payload with UTM campaign parameters that
|
|
1678
|
+
* will be included in the event's context.campaign object. Useful for
|
|
1679
|
+
* tracking marketing campaign attribution.
|
|
1680
|
+
*
|
|
1681
|
+
* @param payload - The event payload to enrich
|
|
1682
|
+
* @param utm - UTM parameters (source, medium, campaign, term, content)
|
|
1683
|
+
* @returns The enriched payload with UTM parameters in context.campaign
|
|
1684
|
+
*
|
|
1685
|
+
* @example
|
|
1686
|
+
* ```typescript
|
|
1687
|
+
* const event = track('Signup Started', {});
|
|
1688
|
+
* const enriched = withUTM(event, {
|
|
1689
|
+
* source: 'google',
|
|
1690
|
+
* medium: 'cpc',
|
|
1691
|
+
* campaign: 'summer-sale'
|
|
1692
|
+
* });
|
|
1693
|
+
* await analytics.emit(enriched);
|
|
1694
|
+
* ```
|
|
1695
|
+
*/
|
|
1696
|
+
function withUTM(payload, utm) {
|
|
1697
|
+
return {
|
|
1698
|
+
...payload,
|
|
1699
|
+
context: {
|
|
1700
|
+
...payload.context,
|
|
1701
|
+
campaign: {
|
|
1702
|
+
...payload.context?.campaign,
|
|
1703
|
+
...utm
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
//#endregion
|
|
1710
|
+
//#region src/shared/utils/posthog-bootstrap.ts
|
|
1711
|
+
/**
|
|
1712
|
+
* Generate a unique distinct ID (compatible with PostHog format)
|
|
1713
|
+
*/
|
|
1714
|
+
function generateDistinctId() {
|
|
1715
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 15)}`;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Parse PostHog cookie to extract distinct ID
|
|
1719
|
+
*/
|
|
1720
|
+
function parsePostHogCookie(cookieValue, _projectApiKey) {
|
|
1721
|
+
try {
|
|
1722
|
+
const parsed = JSON.parse(cookieValue);
|
|
1723
|
+
if (parsed && typeof parsed.distinct_id === "string") return parsed;
|
|
1724
|
+
return null;
|
|
1725
|
+
} catch {
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Get PostHog cookie name for a project
|
|
1731
|
+
*/
|
|
1732
|
+
function getPostHogCookieName(projectApiKey) {
|
|
1733
|
+
return `ph_${projectApiKey}_posthog`;
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Extract distinct ID from cookies (Next.js compatible)
|
|
1737
|
+
*/
|
|
1738
|
+
function getDistinctIdFromCookies(cookies, projectApiKey) {
|
|
1739
|
+
try {
|
|
1740
|
+
const cookieName = getPostHogCookieName(projectApiKey);
|
|
1741
|
+
if (cookies && typeof cookies.get === "function") {
|
|
1742
|
+
const cookie = cookies.get(cookieName);
|
|
1743
|
+
if (cookie?.value) return parsePostHogCookie(cookie.value, projectApiKey)?.distinct_id ?? null;
|
|
1744
|
+
}
|
|
1745
|
+
if (typeof cookies === "string") {
|
|
1746
|
+
const cookieMatch = cookies.match(new RegExp(`${cookieName}=([^;]+)`));
|
|
1747
|
+
if (cookieMatch?.[1]) return parsePostHogCookie(decodeURIComponent(cookieMatch[1]), projectApiKey)?.distinct_id ?? null;
|
|
1748
|
+
}
|
|
1749
|
+
return null;
|
|
1750
|
+
} catch {
|
|
1751
|
+
return null;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Create bootstrap data for PostHog client initialization
|
|
1756
|
+
*/
|
|
1757
|
+
function createBootstrapData(distinctId) {
|
|
1758
|
+
return { distinctID: distinctId };
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Create minimal bootstrap data when full data is unavailable
|
|
1762
|
+
*/
|
|
1763
|
+
function createMinimalBootstrapData(distinctId) {
|
|
1764
|
+
return { distinctID: distinctId ?? generateDistinctId() };
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
//#endregion
|
|
1768
|
+
export { AnalyticsManager as _, ContextBuilder as a, validateEventName as b, createAnonymousSession as c, isGroupPayload as d, isIdentifyPayload as f, withUTM as g, withMetadata as h, getDistinctIdFromCookies as i, createUserSession as l, isTrackPayload as m, createMinimalBootstrapData as n, EventBatch as o, isPagePayload as p, generateDistinctId as r, PayloadBuilder as s, createBootstrapData as t, isAliasPayload as u, createAnalyticsManager as v, sanitizeProperties as y };
|
|
1769
|
+
//# sourceMappingURL=posthog-bootstrap-CYfIy_WS.mjs.map
|