@datalyr/web 1.1.0 → 1.1.1
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 +244 -48
- package/dist/attribution.d.ts +15 -2
- package/dist/attribution.d.ts.map +1 -1
- package/dist/auto-identify.d.ts +100 -0
- package/dist/auto-identify.d.ts.map +1 -0
- package/dist/container.d.ts +46 -10
- package/dist/container.d.ts.map +1 -1
- package/dist/datalyr.cjs.js +1395 -187
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +1395 -187
- package/dist/datalyr.esm.js.map +1 -1
- package/dist/datalyr.esm.min.js +1 -1
- package/dist/datalyr.esm.min.js.map +1 -1
- package/dist/datalyr.js +1395 -187
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/encryption.d.ts +63 -0
- package/dist/encryption.d.ts.map +1 -0
- package/dist/fingerprint.d.ts +0 -2
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/queue.d.ts +12 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/session.d.ts +16 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/storage.d.ts +31 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/types.d.ts +9 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/datalyr.cjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.1.
|
|
2
|
+
* @datalyr/web v1.1.1
|
|
3
3
|
* Datalyr Web SDK - Modern attribution tracking for web applications
|
|
4
4
|
* (c) 2025 Datalyr Inc.
|
|
5
5
|
* Released under the MIT License
|
|
@@ -38,17 +38,214 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
|
|
|
38
38
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Encryption Module (SEC-03 Fix)
|
|
43
|
+
*
|
|
44
|
+
* Encrypts sensitive PII data before storing in localStorage
|
|
45
|
+
* Uses Web Crypto API for AES-GCM encryption
|
|
46
|
+
*
|
|
47
|
+
* SECURITY NOTE: This protects against casual localStorage reads,
|
|
48
|
+
* but determined XSS attacks can still access the encryption key in memory.
|
|
49
|
+
* Primary defense is preventing XSS (see container.ts sandboxing).
|
|
50
|
+
*/
|
|
51
|
+
class DataEncryption {
|
|
52
|
+
constructor() {
|
|
53
|
+
this.key = null;
|
|
54
|
+
this.salt = null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Initialize encryption with workspace-specific key
|
|
58
|
+
*
|
|
59
|
+
* FIXED (SEC-03): Now uses random per-device salt stored in localStorage
|
|
60
|
+
*
|
|
61
|
+
* @param workspaceId - Used to derive encryption key
|
|
62
|
+
* @param deviceId - Device-specific identifier for key derivation
|
|
63
|
+
*/
|
|
64
|
+
initialize(workspaceId, deviceId) {
|
|
65
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
66
|
+
if (!crypto.subtle) {
|
|
67
|
+
const error = new Error('Web Crypto API not available - encryption cannot be initialized');
|
|
68
|
+
console.error('[Datalyr Encryption]', error.message);
|
|
69
|
+
throw error; // FIXED: Fail loudly instead of silently
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
// FIXED (SEC-03): Generate or retrieve random per-device salt
|
|
74
|
+
const saltKey = 'dl_encryption_salt';
|
|
75
|
+
let saltBase64 = localStorage.getItem(saltKey);
|
|
76
|
+
if (!saltBase64) {
|
|
77
|
+
// Generate new random 32-byte salt
|
|
78
|
+
this.salt = crypto.getRandomValues(new Uint8Array(32));
|
|
79
|
+
saltBase64 = this.arrayBufferToBase64(this.salt);
|
|
80
|
+
localStorage.setItem(saltKey, saltBase64);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Use existing salt
|
|
84
|
+
this.salt = this.base64ToArrayBuffer(saltBase64);
|
|
85
|
+
}
|
|
86
|
+
// Derive key material from workspace ID + device ID
|
|
87
|
+
const keyString = `datalyr:${workspaceId}:${deviceId}`;
|
|
88
|
+
const keyData = encoder.encode(keyString);
|
|
89
|
+
// Import key material
|
|
90
|
+
const baseKey = yield crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveKey']);
|
|
91
|
+
// Derive actual encryption key using PBKDF2 with random salt
|
|
92
|
+
this.key = yield crypto.subtle.deriveKey({
|
|
93
|
+
name: 'PBKDF2',
|
|
94
|
+
salt: this.salt, // FIXED: Random per-device salt instead of static
|
|
95
|
+
iterations: 100000, // 100k iterations for security
|
|
96
|
+
hash: 'SHA-256'
|
|
97
|
+
}, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
console.error('[Datalyr Encryption] Failed to initialize:', error);
|
|
101
|
+
this.key = null;
|
|
102
|
+
throw error; // FIXED: Fail loudly
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Encrypt sensitive data
|
|
108
|
+
*
|
|
109
|
+
* FIXED (SEC-03): No longer silently falls back to unencrypted storage
|
|
110
|
+
*
|
|
111
|
+
* @param data - Plain text or object to encrypt
|
|
112
|
+
* @returns Base64-encoded encrypted data with IV
|
|
113
|
+
* @throws Error if encryption is not available or fails
|
|
114
|
+
*/
|
|
115
|
+
encrypt(data) {
|
|
116
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
if (!this.key || !crypto.subtle) {
|
|
118
|
+
// FIXED: Fail loudly instead of silently storing unencrypted
|
|
119
|
+
throw new Error('Encryption not initialized - cannot encrypt sensitive data');
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
|
|
123
|
+
const encoder = new TextEncoder();
|
|
124
|
+
const plaintextBytes = encoder.encode(plaintext);
|
|
125
|
+
// Generate random IV (Initialization Vector)
|
|
126
|
+
const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes for GCM
|
|
127
|
+
// Encrypt
|
|
128
|
+
const ciphertext = yield crypto.subtle.encrypt({ name: 'AES-GCM', iv }, this.key, plaintextBytes);
|
|
129
|
+
// Combine IV + ciphertext for storage
|
|
130
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
131
|
+
combined.set(iv, 0);
|
|
132
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
133
|
+
// Return base64-encoded
|
|
134
|
+
return this.arrayBufferToBase64(combined);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error('[Datalyr Encryption] Encryption failed:', error);
|
|
138
|
+
throw error; // FIXED: Fail loudly
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decrypt sensitive data
|
|
144
|
+
*
|
|
145
|
+
* FIXED (SEC-03): Still allows backwards compatibility for migration,
|
|
146
|
+
* but logs warnings when unencrypted data is detected
|
|
147
|
+
*
|
|
148
|
+
* @param encryptedData - Base64-encoded encrypted data
|
|
149
|
+
* @returns Decrypted data (parsed as JSON if possible)
|
|
150
|
+
*/
|
|
151
|
+
decrypt(encryptedData) {
|
|
152
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
153
|
+
if (!this.key || !crypto.subtle) {
|
|
154
|
+
// Backwards compatibility: Try to parse as unencrypted JSON
|
|
155
|
+
console.warn('[Datalyr Encryption] Decryption not available - reading potentially unencrypted data');
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(encryptedData);
|
|
158
|
+
}
|
|
159
|
+
catch (_a) {
|
|
160
|
+
return encryptedData;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
// Decode base64
|
|
165
|
+
const combined = this.base64ToArrayBuffer(encryptedData);
|
|
166
|
+
// Extract IV (first 12 bytes) and ciphertext
|
|
167
|
+
const iv = combined.slice(0, 12);
|
|
168
|
+
const ciphertext = combined.slice(12);
|
|
169
|
+
// Decrypt
|
|
170
|
+
const plaintextBytes = yield crypto.subtle.decrypt({ name: 'AES-GCM', iv }, this.key, ciphertext);
|
|
171
|
+
// Decode and parse
|
|
172
|
+
const decoder = new TextDecoder();
|
|
173
|
+
const plaintext = decoder.decode(plaintextBytes);
|
|
174
|
+
// Try to parse as JSON
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(plaintext);
|
|
177
|
+
}
|
|
178
|
+
catch (_b) {
|
|
179
|
+
return plaintext;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// Backwards compatibility: Try to parse as unencrypted (for migration)
|
|
184
|
+
console.warn('[Datalyr Encryption] Decryption failed - attempting to read as unencrypted data (migration mode)');
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(encryptedData);
|
|
187
|
+
}
|
|
188
|
+
catch (_c) {
|
|
189
|
+
return encryptedData;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if encryption is available and initialized
|
|
196
|
+
*/
|
|
197
|
+
isAvailable() {
|
|
198
|
+
return this.key !== null && crypto.subtle !== undefined;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Helper: Convert ArrayBuffer to Base64
|
|
202
|
+
*/
|
|
203
|
+
arrayBufferToBase64(buffer) {
|
|
204
|
+
let binary = '';
|
|
205
|
+
const bytes = new Uint8Array(buffer);
|
|
206
|
+
const len = bytes.byteLength;
|
|
207
|
+
for (let i = 0; i < len; i++) {
|
|
208
|
+
binary += String.fromCharCode(bytes[i]);
|
|
209
|
+
}
|
|
210
|
+
return btoa(binary);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Helper: Convert Base64 to ArrayBuffer
|
|
214
|
+
*/
|
|
215
|
+
base64ToArrayBuffer(base64) {
|
|
216
|
+
const binary = atob(base64);
|
|
217
|
+
const len = binary.length;
|
|
218
|
+
const bytes = new Uint8Array(len);
|
|
219
|
+
for (let i = 0; i < len; i++) {
|
|
220
|
+
bytes[i] = binary.charCodeAt(i);
|
|
221
|
+
}
|
|
222
|
+
return bytes;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Destroy encryption keys (called on SDK destroy)
|
|
226
|
+
*
|
|
227
|
+
* FIXED (SEC-03): Also clears salt reference
|
|
228
|
+
*/
|
|
229
|
+
destroy() {
|
|
230
|
+
this.key = null;
|
|
231
|
+
this.salt = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Singleton instance
|
|
235
|
+
const dataEncryption = new DataEncryption();
|
|
236
|
+
|
|
41
237
|
/**
|
|
42
238
|
* Storage Module
|
|
43
239
|
* Safe storage wrapper with fallbacks for Safari private mode
|
|
240
|
+
* SEC-03 Fix: Added encryption support for PII data
|
|
44
241
|
*/
|
|
45
242
|
class SafeStorage {
|
|
46
243
|
constructor(storage) {
|
|
47
244
|
this.memory = new Map();
|
|
48
|
-
this.prefix = '
|
|
245
|
+
this.prefix = 'dl_';
|
|
49
246
|
// Test if storage is available
|
|
50
247
|
try {
|
|
51
|
-
const testKey = '
|
|
248
|
+
const testKey = 'dl_test__' + Math.random();
|
|
52
249
|
storage.setItem(testKey, '1');
|
|
53
250
|
storage.removeItem(testKey);
|
|
54
251
|
this.storage = storage;
|
|
@@ -149,6 +346,117 @@ class SafeStorage {
|
|
|
149
346
|
return [];
|
|
150
347
|
}
|
|
151
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Migrate data from legacy '__dl_dl_*' keys to new 'dl_*' keys
|
|
351
|
+
*
|
|
352
|
+
* This fixes the double-prefix issue from earlier SDK versions.
|
|
353
|
+
* Called automatically during SDK initialization.
|
|
354
|
+
*
|
|
355
|
+
* @returns Number of keys migrated
|
|
356
|
+
*/
|
|
357
|
+
migrateFromLegacyPrefix() {
|
|
358
|
+
if (!this.storage) {
|
|
359
|
+
return 0; // Can't migrate in memory-only mode
|
|
360
|
+
}
|
|
361
|
+
let migratedCount = 0;
|
|
362
|
+
const legacyPrefix = '__dl_dl_';
|
|
363
|
+
try {
|
|
364
|
+
// Find all legacy keys
|
|
365
|
+
const legacyKeys = [];
|
|
366
|
+
for (let i = 0; i < this.storage.length; i++) {
|
|
367
|
+
const key = this.storage.key(i);
|
|
368
|
+
if (key && key.startsWith(legacyPrefix)) {
|
|
369
|
+
legacyKeys.push(key);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Migrate each legacy key
|
|
373
|
+
legacyKeys.forEach(legacyKey => {
|
|
374
|
+
try {
|
|
375
|
+
// Get value from legacy key
|
|
376
|
+
const value = this.storage.getItem(legacyKey);
|
|
377
|
+
if (value) {
|
|
378
|
+
// Extract the actual key name (remove '__dl_' prefix, keep 'dl_' part)
|
|
379
|
+
const actualKey = legacyKey.slice('__dl_'.length); // e.g., '__dl_dl_anonymous_id' -> 'dl_anonymous_id'
|
|
380
|
+
// Only migrate if new key doesn't already exist (don't overwrite newer data)
|
|
381
|
+
if (!this.storage.getItem(actualKey)) {
|
|
382
|
+
this.storage.setItem(actualKey, value);
|
|
383
|
+
migratedCount++;
|
|
384
|
+
}
|
|
385
|
+
// Remove legacy key after successful migration
|
|
386
|
+
this.storage.removeItem(legacyKey);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
console.warn(`[Datalyr Storage] Failed to migrate legacy key: ${legacyKey}`, e);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
if (migratedCount > 0) {
|
|
394
|
+
console.log(`[Datalyr Storage] Migrated ${migratedCount} keys from legacy prefix`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
console.warn('[Datalyr Storage] Error during legacy key migration:', e);
|
|
399
|
+
}
|
|
400
|
+
return migratedCount;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Get encrypted value from storage (SEC-03 Fix)
|
|
404
|
+
*
|
|
405
|
+
* @param key - Storage key
|
|
406
|
+
* @param defaultValue - Default value if not found
|
|
407
|
+
* @returns Decrypted value
|
|
408
|
+
*/
|
|
409
|
+
getEncrypted(key_1) {
|
|
410
|
+
return __awaiter(this, arguments, void 0, function* (key, defaultValue = null) {
|
|
411
|
+
const fullKey = this.prefix + key;
|
|
412
|
+
try {
|
|
413
|
+
// Get encrypted data
|
|
414
|
+
let encryptedData = null;
|
|
415
|
+
if (this.storage) {
|
|
416
|
+
encryptedData = this.storage.getItem(fullKey);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
encryptedData = this.memory.get(fullKey) || null;
|
|
420
|
+
}
|
|
421
|
+
if (!encryptedData) {
|
|
422
|
+
return defaultValue;
|
|
423
|
+
}
|
|
424
|
+
// Decrypt
|
|
425
|
+
const decrypted = yield dataEncryption.decrypt(encryptedData);
|
|
426
|
+
return decrypted;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
console.warn('[Datalyr Storage] Failed to decrypt:', key, error);
|
|
430
|
+
return defaultValue;
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Set encrypted value in storage (SEC-03 Fix)
|
|
436
|
+
*
|
|
437
|
+
* FIXED (SEC-03): Now throws error if encryption fails instead of silent fallback
|
|
438
|
+
*
|
|
439
|
+
* @param key - Storage key
|
|
440
|
+
* @param value - Value to encrypt and store
|
|
441
|
+
* @returns Success boolean
|
|
442
|
+
* @throws Error if encryption is not available or fails
|
|
443
|
+
*/
|
|
444
|
+
setEncrypted(key, value) {
|
|
445
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
446
|
+
const fullKey = this.prefix + key;
|
|
447
|
+
// FIXED: encrypt() now throws instead of returning null
|
|
448
|
+
const encrypted = yield dataEncryption.encrypt(value);
|
|
449
|
+
// Store encrypted data
|
|
450
|
+
if (this.storage) {
|
|
451
|
+
this.storage.setItem(fullKey, encrypted);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
this.memory.set(fullKey, encrypted);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
152
460
|
}
|
|
153
461
|
// Cookie operations
|
|
154
462
|
class CookieStorage {
|
|
@@ -529,13 +837,9 @@ class IdentityManager {
|
|
|
529
837
|
document.cookie = `${name}=${encodedValue}; domain=${rootDomain}; path=/; max-age=31536000; SameSite=Lax${secure}`;
|
|
530
838
|
// Verify cookie was set successfully (cookies.get already decodes)
|
|
531
839
|
const verifyValue = cookies.get(name);
|
|
532
|
-
if (verifyValue
|
|
533
|
-
console.log(`[Datalyr] Set root domain cookie: ${name} on domain: ${rootDomain}`);
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
840
|
+
if (verifyValue !== value) {
|
|
536
841
|
// Fallback: try without domain (current subdomain only)
|
|
537
842
|
document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`;
|
|
538
|
-
console.log(`[Datalyr] Set cookie without domain (fallback): ${name}`);
|
|
539
843
|
}
|
|
540
844
|
}
|
|
541
845
|
catch (e) {
|
|
@@ -674,13 +978,14 @@ class IdentityManager {
|
|
|
674
978
|
* Session Management Module
|
|
675
979
|
*/
|
|
676
980
|
class SessionManager {
|
|
677
|
-
constructor(timeout =
|
|
981
|
+
constructor(timeout = 60 * 60 * 1000) {
|
|
678
982
|
this.sessionId = null;
|
|
679
983
|
this.sessionData = null;
|
|
680
984
|
this.lastActivity = Date.now();
|
|
681
985
|
this.SESSION_KEY = 'dl_session_data';
|
|
682
986
|
this.activityCheckInterval = null;
|
|
683
987
|
this.activityListeners = [];
|
|
988
|
+
this.sessionCreationLock = false; // FIXED (DATA-02): Mutex to prevent race conditions
|
|
684
989
|
this.sessionTimeout = timeout;
|
|
685
990
|
this.initSession();
|
|
686
991
|
this.setupActivityMonitor();
|
|
@@ -711,21 +1016,66 @@ class SessionManager {
|
|
|
711
1016
|
}
|
|
712
1017
|
/**
|
|
713
1018
|
* Create a new session
|
|
1019
|
+
*
|
|
1020
|
+
* FIXED (DATA-02): Added mutex lock to prevent race conditions
|
|
714
1021
|
*/
|
|
715
1022
|
createNewSession() {
|
|
1023
|
+
// FIXED (DATA-02): Check if session creation already in progress
|
|
1024
|
+
if (this.sessionCreationLock) {
|
|
1025
|
+
// Wait for ongoing session creation
|
|
1026
|
+
console.log('[Datalyr Session] Session creation already in progress, returning existing ID');
|
|
1027
|
+
return this.sessionId || '';
|
|
1028
|
+
}
|
|
1029
|
+
// Acquire lock
|
|
1030
|
+
this.sessionCreationLock = true;
|
|
1031
|
+
try {
|
|
1032
|
+
const now = Date.now();
|
|
1033
|
+
this.sessionId = `sess_${generateUUID()}`;
|
|
1034
|
+
this.sessionData = {
|
|
1035
|
+
id: this.sessionId,
|
|
1036
|
+
startTime: now,
|
|
1037
|
+
lastActivity: now,
|
|
1038
|
+
pageViews: 0,
|
|
1039
|
+
events: 0,
|
|
1040
|
+
duration: 0,
|
|
1041
|
+
isActive: true
|
|
1042
|
+
};
|
|
1043
|
+
this.saveSession();
|
|
1044
|
+
// Issue #25: Increment AFTER session created successfully
|
|
1045
|
+
this.incrementSessionCount();
|
|
1046
|
+
return this.sessionId;
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
// Always release lock
|
|
1050
|
+
this.sessionCreationLock = false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Rotate session ID (security measure)
|
|
1055
|
+
*
|
|
1056
|
+
* FIXED (DATA-05): Called when user identifies to prevent session fixation attacks
|
|
1057
|
+
* Keeps session data but generates new session ID
|
|
1058
|
+
*/
|
|
1059
|
+
rotateSessionId() {
|
|
716
1060
|
const now = Date.now();
|
|
1061
|
+
const oldSessionId = this.sessionId;
|
|
1062
|
+
// Generate new session ID
|
|
717
1063
|
this.sessionId = `sess_${generateUUID()}`;
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
lastActivity
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1064
|
+
// Preserve session data but update ID
|
|
1065
|
+
if (this.sessionData) {
|
|
1066
|
+
this.sessionData.id = this.sessionId;
|
|
1067
|
+
this.sessionData.lastActivity = now;
|
|
1068
|
+
this.saveSession();
|
|
1069
|
+
// Clean up old session data
|
|
1070
|
+
if (oldSessionId) {
|
|
1071
|
+
storage.remove(`dl_session_${oldSessionId}_attribution`);
|
|
1072
|
+
}
|
|
1073
|
+
console.log(`[Datalyr Session] Rotated session ID from ${oldSessionId} to ${this.sessionId}`);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
// No existing session, create new one
|
|
1077
|
+
this.createNewSession();
|
|
1078
|
+
}
|
|
729
1079
|
return this.sessionId;
|
|
730
1080
|
}
|
|
731
1081
|
/**
|
|
@@ -745,18 +1095,22 @@ class SessionManager {
|
|
|
745
1095
|
}
|
|
746
1096
|
/**
|
|
747
1097
|
* Update session activity
|
|
1098
|
+
* FIXED (CRITICAL-04): Check session validity BEFORE updating lastActivity
|
|
1099
|
+
* This ensures sessions actually timeout after the configured period
|
|
748
1100
|
*/
|
|
749
1101
|
updateActivity(eventType) {
|
|
750
1102
|
const now = Date.now();
|
|
751
|
-
// Check
|
|
1103
|
+
// CRITICAL FIX: Check session validity BEFORE updating lastActivity
|
|
1104
|
+
// If we update lastActivity first, sessions will never timeout!
|
|
752
1105
|
if (!this.sessionData || !this.isSessionValid(this.sessionData, now)) {
|
|
753
1106
|
this.createNewSession();
|
|
754
1107
|
return;
|
|
755
1108
|
}
|
|
1109
|
+
// Session is valid - update activity timestamp
|
|
756
1110
|
this.lastActivity = now;
|
|
757
1111
|
this.sessionData.lastActivity = now;
|
|
758
1112
|
this.sessionData.duration = now - this.sessionData.startTime;
|
|
759
|
-
// Update counters
|
|
1113
|
+
// Update counters (only if session is valid)
|
|
760
1114
|
if (eventType === 'pageview' || eventType === 'page_view') {
|
|
761
1115
|
this.sessionData.pageViews++;
|
|
762
1116
|
}
|
|
@@ -774,6 +1128,7 @@ class SessionManager {
|
|
|
774
1128
|
}
|
|
775
1129
|
/**
|
|
776
1130
|
* End the current session
|
|
1131
|
+
* Fixed Issue #23: Stop activity monitor when session ends
|
|
777
1132
|
*/
|
|
778
1133
|
endSession() {
|
|
779
1134
|
if (this.sessionData) {
|
|
@@ -782,6 +1137,8 @@ class SessionManager {
|
|
|
782
1137
|
}
|
|
783
1138
|
this.sessionId = null;
|
|
784
1139
|
this.sessionData = null;
|
|
1140
|
+
// Issue #23: Clean up activity monitor when session ends
|
|
1141
|
+
this.destroy();
|
|
785
1142
|
}
|
|
786
1143
|
/**
|
|
787
1144
|
* Save session to storage
|
|
@@ -805,19 +1162,29 @@ class SessionManager {
|
|
|
805
1162
|
}
|
|
806
1163
|
/**
|
|
807
1164
|
* Store session attribution
|
|
1165
|
+
* Fixed Issue #24: Use single key instead of one per session
|
|
808
1166
|
*/
|
|
809
1167
|
storeAttribution(attribution) {
|
|
810
|
-
|
|
811
|
-
storage.set(key, Object.assign(Object.assign({}, attribution), { sessionId: this.sessionId, timestamp: Date.now() }));
|
|
1168
|
+
storage.set('dl_current_session_attribution', Object.assign(Object.assign({}, attribution), { sessionId: this.sessionId, timestamp: Date.now() }));
|
|
812
1169
|
}
|
|
813
1170
|
/**
|
|
814
1171
|
* Get session attribution
|
|
1172
|
+
* Fixed Issue #24: Use single key instead of one per session
|
|
1173
|
+
* FIXED (CRITICAL-04): Verify session is still active before returning attribution
|
|
815
1174
|
*/
|
|
816
1175
|
getAttribution() {
|
|
817
1176
|
if (!this.sessionId)
|
|
818
1177
|
return null;
|
|
819
|
-
|
|
820
|
-
|
|
1178
|
+
// CRITICAL FIX: Verify session is still active
|
|
1179
|
+
if (!this.isSessionActive()) {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
const data = storage.get('dl_current_session_attribution');
|
|
1183
|
+
// Only return if it matches current session
|
|
1184
|
+
if (data && data.sessionId === this.sessionId) {
|
|
1185
|
+
return data;
|
|
1186
|
+
}
|
|
1187
|
+
return null;
|
|
821
1188
|
}
|
|
822
1189
|
/**
|
|
823
1190
|
* Get session metrics
|
|
@@ -894,6 +1261,7 @@ class SessionManager {
|
|
|
894
1261
|
*/
|
|
895
1262
|
class AttributionManager {
|
|
896
1263
|
constructor(options = {}) {
|
|
1264
|
+
this.queryParamsCache = null;
|
|
897
1265
|
this.UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
898
1266
|
// Updated to match dl.js - includes ALL ad platform click IDs
|
|
899
1267
|
this.CLICK_IDS = [
|
|
@@ -922,15 +1290,27 @@ class AttributionManager {
|
|
|
922
1290
|
'medium', // Generic medium (non-UTM)
|
|
923
1291
|
'gad_source' // Google Ads source parameter
|
|
924
1292
|
];
|
|
925
|
-
this.attributionWindow = options.attributionWindow ||
|
|
1293
|
+
this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
|
|
926
1294
|
// Merge default tracked params with user-provided ones
|
|
927
1295
|
this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
|
|
928
1296
|
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Clear query params cache (called on page navigation)
|
|
1299
|
+
* FIXED: Prevents stale attribution data on SPA navigation
|
|
1300
|
+
*/
|
|
1301
|
+
clearCache() {
|
|
1302
|
+
this.queryParamsCache = null;
|
|
1303
|
+
}
|
|
929
1304
|
/**
|
|
930
1305
|
* Capture current attribution from URL
|
|
931
1306
|
*/
|
|
932
1307
|
captureAttribution() {
|
|
933
|
-
|
|
1308
|
+
// Cache query params to avoid multiple parses within same page load (Issue #3)
|
|
1309
|
+
// NOTE: Cache is cleared on page navigation to prevent stale data
|
|
1310
|
+
const params = this.queryParamsCache || getAllQueryParams();
|
|
1311
|
+
if (!this.queryParamsCache) {
|
|
1312
|
+
this.queryParamsCache = params;
|
|
1313
|
+
}
|
|
934
1314
|
const attribution = {
|
|
935
1315
|
timestamp: Date.now()
|
|
936
1316
|
};
|
|
@@ -977,31 +1357,57 @@ class AttributionManager {
|
|
|
977
1357
|
return attribution;
|
|
978
1358
|
}
|
|
979
1359
|
/**
|
|
980
|
-
* Store first touch attribution
|
|
1360
|
+
* Store first touch attribution with 90-day expiration
|
|
1361
|
+
*
|
|
1362
|
+
* FIXED (DATA-01): Removed paid priority logic that was corrupting first-touch attribution.
|
|
1363
|
+
* First-touch is now IMMUTABLE except for expiration - this ensures accurate revenue attribution.
|
|
981
1364
|
*/
|
|
982
1365
|
storeFirstTouch(attribution) {
|
|
983
1366
|
const existing = storage.get('dl_first_touch');
|
|
1367
|
+
let shouldStore = false;
|
|
984
1368
|
if (!existing) {
|
|
985
|
-
|
|
1369
|
+
// No existing attribution - store first touch
|
|
1370
|
+
shouldStore = true;
|
|
1371
|
+
}
|
|
1372
|
+
else if (existing.expires_at && Date.now() >= existing.expires_at) {
|
|
1373
|
+
// Existing attribution expired - replace it
|
|
1374
|
+
shouldStore = true;
|
|
1375
|
+
}
|
|
1376
|
+
// REMOVED PAID PRIORITY LOGIC - First-touch must be immutable for accurate attribution
|
|
1377
|
+
// If there's existing valid attribution, keep it (true first-touch strategy)
|
|
1378
|
+
if (shouldStore) {
|
|
1379
|
+
storage.set('dl_first_touch', Object.assign(Object.assign({}, attribution), { captured_at: Date.now(), expires_at: Date.now() + this.attributionWindow }));
|
|
986
1380
|
}
|
|
987
1381
|
}
|
|
988
1382
|
/**
|
|
989
1383
|
* Get first touch attribution
|
|
1384
|
+
* Checks expiry and removes if expired (Issue #4)
|
|
990
1385
|
*/
|
|
991
1386
|
getFirstTouch() {
|
|
992
|
-
|
|
1387
|
+
const data = storage.get('dl_first_touch');
|
|
1388
|
+
if (data && data.expires_at && Date.now() >= data.expires_at) {
|
|
1389
|
+
storage.remove('dl_first_touch');
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
return data;
|
|
993
1393
|
}
|
|
994
1394
|
/**
|
|
995
|
-
* Store last touch attribution
|
|
1395
|
+
* Store last touch attribution with 90-day expiration
|
|
996
1396
|
*/
|
|
997
1397
|
storeLastTouch(attribution) {
|
|
998
|
-
storage.set('dl_last_touch', Object.assign(Object.assign({}, attribution), {
|
|
1398
|
+
storage.set('dl_last_touch', Object.assign(Object.assign({}, attribution), { captured_at: Date.now(), expires_at: Date.now() + this.attributionWindow }));
|
|
999
1399
|
}
|
|
1000
1400
|
/**
|
|
1001
1401
|
* Get last touch attribution
|
|
1402
|
+
* Checks expiry and removes if expired (Issue #4)
|
|
1002
1403
|
*/
|
|
1003
1404
|
getLastTouch() {
|
|
1004
|
-
|
|
1405
|
+
const data = storage.get('dl_last_touch');
|
|
1406
|
+
if (data && data.expires_at && Date.now() >= data.expires_at) {
|
|
1407
|
+
storage.remove('dl_last_touch');
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
return data;
|
|
1005
1411
|
}
|
|
1006
1412
|
/**
|
|
1007
1413
|
* Add touchpoint to customer journey
|
|
@@ -1069,16 +1475,18 @@ class AttributionManager {
|
|
|
1069
1475
|
}
|
|
1070
1476
|
/**
|
|
1071
1477
|
* Check if we have a specific click ID in current params
|
|
1478
|
+
* Uses cached params to avoid multiple URL parses (Issue #3)
|
|
1072
1479
|
*/
|
|
1073
1480
|
hasClickId(clickIdType) {
|
|
1074
|
-
const params = getAllQueryParams();
|
|
1481
|
+
const params = this.queryParamsCache || getAllQueryParams();
|
|
1075
1482
|
return !!params[clickIdType];
|
|
1076
1483
|
}
|
|
1077
1484
|
/**
|
|
1078
1485
|
* Get current fbclid from URL if present
|
|
1486
|
+
* Uses cached params to avoid multiple URL parses (Issue #3)
|
|
1079
1487
|
*/
|
|
1080
1488
|
getCurrentFbclid() {
|
|
1081
|
-
const params = getAllQueryParams();
|
|
1489
|
+
const params = this.queryParamsCache || getAllQueryParams();
|
|
1082
1490
|
return params.fbclid || null;
|
|
1083
1491
|
}
|
|
1084
1492
|
/**
|
|
@@ -1088,7 +1496,17 @@ class AttributionManager {
|
|
|
1088
1496
|
const firstTouch = this.getFirstTouch();
|
|
1089
1497
|
const lastTouch = this.getLastTouch();
|
|
1090
1498
|
const journey = this.getJourney();
|
|
1091
|
-
|
|
1499
|
+
let current = this.captureAttribution();
|
|
1500
|
+
// CRITICAL FIX: If current session has no attribution (direct/organic),
|
|
1501
|
+
// fallback to persistent attribution from localStorage (90-day window)
|
|
1502
|
+
const hasCurrentAttribution = !!(current.source || current.medium || current.clickId || current.campaign);
|
|
1503
|
+
if (!hasCurrentAttribution && firstTouch) {
|
|
1504
|
+
// Check if persistent attribution is still valid
|
|
1505
|
+
if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
|
|
1506
|
+
// Use persistent attribution but keep current page context
|
|
1507
|
+
current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1092
1510
|
// Capture advertising cookies automatically
|
|
1093
1511
|
const adCookies = this.captureAdCookies();
|
|
1094
1512
|
// Update first/last touch if needed
|
|
@@ -1240,6 +1658,8 @@ class EventQueue {
|
|
|
1240
1658
|
this.recentEventIds = new Set();
|
|
1241
1659
|
this.MAX_RECENT_EVENT_IDS = 1000;
|
|
1242
1660
|
this.OFFLINE_QUEUE_KEY = 'dl_offline_queue';
|
|
1661
|
+
this.flushLock = false; // FIXED (DATA-03): Mutex to prevent race conditions
|
|
1662
|
+
this.offlineQueueLock = false; // FIXED (DATA-03): Separate lock for offline queue operations
|
|
1243
1663
|
this.config = {
|
|
1244
1664
|
batchSize: config.batchSize || 10,
|
|
1245
1665
|
flushInterval: config.flushInterval || 5000,
|
|
@@ -1266,16 +1686,23 @@ class EventQueue {
|
|
|
1266
1686
|
* Add event to queue
|
|
1267
1687
|
*/
|
|
1268
1688
|
enqueue(event) {
|
|
1269
|
-
const eventName = event.
|
|
1689
|
+
const eventName = event.event_name; // Use snake_case
|
|
1270
1690
|
// Check for duplicates (within 500ms window)
|
|
1271
1691
|
if (this.isDuplicateEvent(event)) {
|
|
1272
1692
|
this.log('Duplicate event suppressed:', eventName);
|
|
1273
1693
|
return;
|
|
1274
1694
|
}
|
|
1275
|
-
// Critical events
|
|
1695
|
+
// CRITICAL FIX (CRITICAL-05): Critical events need proper error handling
|
|
1696
|
+
// Instead of calling sendBatch() without await, we add them to queue
|
|
1697
|
+
// with immediate flush AND move to offline queue on failure
|
|
1276
1698
|
if (this.config.criticalEvents.includes(eventName)) {
|
|
1277
1699
|
this.log('Critical event, sending immediately:', eventName);
|
|
1278
|
-
|
|
1700
|
+
// Send immediately with proper error handling
|
|
1701
|
+
this.sendBatch([event]).catch((error) => {
|
|
1702
|
+
this.log('Critical event send failed, adding to offline queue:', eventName, error);
|
|
1703
|
+
// Move to offline queue to ensure it's not lost
|
|
1704
|
+
this.moveToOfflineQueue([event]);
|
|
1705
|
+
});
|
|
1279
1706
|
return;
|
|
1280
1707
|
}
|
|
1281
1708
|
// Add to queue
|
|
@@ -1288,13 +1715,15 @@ class EventQueue {
|
|
|
1288
1715
|
}
|
|
1289
1716
|
/**
|
|
1290
1717
|
* Check if event is duplicate
|
|
1718
|
+
* Fixed Issue #32: Use content-based hash instead of UUID
|
|
1291
1719
|
*/
|
|
1292
1720
|
isDuplicateEvent(event) {
|
|
1293
|
-
|
|
1294
|
-
|
|
1721
|
+
// Create content-based hash from eventName + timestamp + key properties
|
|
1722
|
+
const contentHash = this.createEventHash(event);
|
|
1723
|
+
if (this.recentEventIds.has(contentHash)) {
|
|
1295
1724
|
return true;
|
|
1296
1725
|
}
|
|
1297
|
-
this.recentEventIds.add(
|
|
1726
|
+
this.recentEventIds.add(contentHash);
|
|
1298
1727
|
// Clean up old event IDs
|
|
1299
1728
|
if (this.recentEventIds.size > this.MAX_RECENT_EVENT_IDS) {
|
|
1300
1729
|
const toDelete = this.recentEventIds.size - this.MAX_RECENT_EVENT_IDS;
|
|
@@ -1308,6 +1737,24 @@ class EventQueue {
|
|
|
1308
1737
|
}
|
|
1309
1738
|
return false;
|
|
1310
1739
|
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Create content-based hash for duplicate detection (Issue #32)
|
|
1742
|
+
*/
|
|
1743
|
+
createEventHash(event) {
|
|
1744
|
+
const content = [
|
|
1745
|
+
event.event_name, // Use snake_case
|
|
1746
|
+
event.timestamp,
|
|
1747
|
+
JSON.stringify(event.event_data || {}) // Use snake_case
|
|
1748
|
+
].join('|');
|
|
1749
|
+
// Simple hash function
|
|
1750
|
+
let hash = 0;
|
|
1751
|
+
for (let i = 0; i < content.length; i++) {
|
|
1752
|
+
const char = content.charCodeAt(i);
|
|
1753
|
+
hash = ((hash << 5) - hash) + char;
|
|
1754
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
1755
|
+
}
|
|
1756
|
+
return hash.toString(36);
|
|
1757
|
+
}
|
|
1311
1758
|
/**
|
|
1312
1759
|
* Check if we should flush the queue
|
|
1313
1760
|
*/
|
|
@@ -1333,20 +1780,29 @@ class EventQueue {
|
|
|
1333
1780
|
}
|
|
1334
1781
|
/**
|
|
1335
1782
|
* Flush the queue
|
|
1783
|
+
* FIXED (DATA-03): Enhanced protection against concurrent flushes
|
|
1336
1784
|
*/
|
|
1337
1785
|
flush() {
|
|
1338
1786
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1339
|
-
//
|
|
1340
|
-
if (this.flushPromise) {
|
|
1341
|
-
return this.flushPromise;
|
|
1787
|
+
// FIXED (DATA-03): Check both promise and lock for concurrent flush protection
|
|
1788
|
+
if (this.flushPromise || this.flushLock) {
|
|
1789
|
+
return this.flushPromise || Promise.resolve();
|
|
1790
|
+
}
|
|
1791
|
+
// Acquire lock
|
|
1792
|
+
this.flushLock = true;
|
|
1793
|
+
try {
|
|
1794
|
+
this.flushPromise = this._flush();
|
|
1795
|
+
yield this.flushPromise;
|
|
1796
|
+
}
|
|
1797
|
+
finally {
|
|
1798
|
+
this.flushPromise = null;
|
|
1799
|
+
this.flushLock = false;
|
|
1342
1800
|
}
|
|
1343
|
-
this.flushPromise = this._flush();
|
|
1344
|
-
yield this.flushPromise;
|
|
1345
|
-
this.flushPromise = null;
|
|
1346
1801
|
});
|
|
1347
1802
|
}
|
|
1348
1803
|
/**
|
|
1349
1804
|
* Internal flush implementation
|
|
1805
|
+
* FIXED (CRITICAL-06): Don't remove events from queue until send succeeds
|
|
1350
1806
|
*/
|
|
1351
1807
|
_flush() {
|
|
1352
1808
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -1365,16 +1821,21 @@ class EventQueue {
|
|
|
1365
1821
|
this.moveToOfflineQueue();
|
|
1366
1822
|
return;
|
|
1367
1823
|
}
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1824
|
+
// CRITICAL FIX: Use slice() to COPY events, don't remove yet
|
|
1825
|
+
// Only remove after successful send to prevent data loss
|
|
1826
|
+
const batchSize = Math.min(this.config.batchSize, this.queue.length);
|
|
1827
|
+
const events = this.queue.slice(0, batchSize);
|
|
1370
1828
|
try {
|
|
1371
1829
|
yield this.sendBatch(events);
|
|
1830
|
+
// SUCCESS: Now it's safe to remove events from queue
|
|
1831
|
+
this.queue.splice(0, batchSize);
|
|
1832
|
+
this.log(`Successfully sent and removed ${batchSize} events from queue`);
|
|
1372
1833
|
}
|
|
1373
1834
|
catch (error) {
|
|
1374
1835
|
this.log('Failed to send batch:', error);
|
|
1375
|
-
//
|
|
1376
|
-
|
|
1377
|
-
this.
|
|
1836
|
+
// Don't remove from queue - events stay for retry
|
|
1837
|
+
// Move to offline queue for persistent storage
|
|
1838
|
+
this.moveToOfflineQueue(events);
|
|
1378
1839
|
}
|
|
1379
1840
|
});
|
|
1380
1841
|
}
|
|
@@ -1406,10 +1867,14 @@ class EventQueue {
|
|
|
1406
1867
|
if (response.status === 429) {
|
|
1407
1868
|
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
|
|
1408
1869
|
this.log(`Rate limited, retrying after ${retryAfter}s`);
|
|
1870
|
+
// FIXED (CRITICAL-06): Don't unshift() - events are still in queue!
|
|
1871
|
+
// With the new fix, events aren't removed until success, so they're
|
|
1872
|
+
// already in the queue. Just schedule a retry flush.
|
|
1409
1873
|
setTimeout(() => {
|
|
1410
|
-
this.
|
|
1874
|
+
this.flush();
|
|
1411
1875
|
}, retryAfter * 1000);
|
|
1412
|
-
|
|
1876
|
+
// Throw to trigger catch block which moves events to offline queue
|
|
1877
|
+
throw new Error(`Rate limited (429), retry after ${retryAfter}s`);
|
|
1413
1878
|
}
|
|
1414
1879
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1415
1880
|
}
|
|
@@ -1470,11 +1935,39 @@ class EventQueue {
|
|
|
1470
1935
|
}
|
|
1471
1936
|
/**
|
|
1472
1937
|
* Move events to offline queue
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1938
|
+
* Fixed Issue #33: Enforce size limit when adding, not just when saving
|
|
1939
|
+
* FIXED (DATA-03): Added mutex lock to prevent race conditions
|
|
1940
|
+
* FIXED (CRITICAL-06): Can now accept specific events or move entire queue
|
|
1941
|
+
*/
|
|
1942
|
+
moveToOfflineQueue(events) {
|
|
1943
|
+
// FIXED (DATA-03): Check if offline queue operation already in progress
|
|
1944
|
+
if (this.offlineQueueLock) {
|
|
1945
|
+
console.warn('[Datalyr Queue] Offline queue operation already in progress');
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
// Acquire lock
|
|
1949
|
+
this.offlineQueueLock = true;
|
|
1950
|
+
try {
|
|
1951
|
+
if (events) {
|
|
1952
|
+
// Move specific events to offline queue
|
|
1953
|
+
this.offlineQueue.push(...events);
|
|
1954
|
+
}
|
|
1955
|
+
else {
|
|
1956
|
+
// Move entire queue to offline queue
|
|
1957
|
+
this.offlineQueue.push(...this.queue);
|
|
1958
|
+
this.queue = [];
|
|
1959
|
+
}
|
|
1960
|
+
// Issue #33: Enforce limit here, not just in saveOfflineQueue
|
|
1961
|
+
if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
|
|
1962
|
+
const excess = this.offlineQueue.length - this.config.maxOfflineQueueSize;
|
|
1963
|
+
this.offlineQueue.splice(0, excess); // Remove oldest events
|
|
1964
|
+
}
|
|
1965
|
+
this.saveOfflineQueue();
|
|
1966
|
+
}
|
|
1967
|
+
finally {
|
|
1968
|
+
// Always release lock
|
|
1969
|
+
this.offlineQueueLock = false;
|
|
1970
|
+
}
|
|
1478
1971
|
}
|
|
1479
1972
|
/**
|
|
1480
1973
|
* Load offline queue from storage
|
|
@@ -1606,8 +2099,6 @@ class EventQueue {
|
|
|
1606
2099
|
*/
|
|
1607
2100
|
class FingerprintCollector {
|
|
1608
2101
|
constructor(options = {}) {
|
|
1609
|
-
this.heavyFingerprintDone = false;
|
|
1610
|
-
this.fingerprintCache = {};
|
|
1611
2102
|
this.privacyMode = options.privacyMode || 'standard';
|
|
1612
2103
|
this.enableFingerprinting = options.enableFingerprinting !== false;
|
|
1613
2104
|
}
|
|
@@ -1755,6 +2246,9 @@ class ContainerManager {
|
|
|
1755
2246
|
this.sessionLoadedScripts = new Set();
|
|
1756
2247
|
this.pixels = null;
|
|
1757
2248
|
this.initialized = false;
|
|
2249
|
+
this.sandboxedIframes = []; // FIXED (ISSUE-02): Track iframes for cleanup
|
|
2250
|
+
this.iframeCleanupTimeouts = new Map(); // FIXED (ISSUE-02): Track cleanup timeouts
|
|
2251
|
+
this.messageHandler = null; // FIXED (ISSUE-02): Track message listener
|
|
1758
2252
|
this.workspaceId = options.workspaceId;
|
|
1759
2253
|
// Container scripts use the same endpoint as tracking (ingest)
|
|
1760
2254
|
this.endpoint = options.endpoint || 'https://ingest.datalyr.com';
|
|
@@ -1762,6 +2256,19 @@ class ContainerManager {
|
|
|
1762
2256
|
// Load session scripts from storage
|
|
1763
2257
|
const sessionScripts = storage.get('dl_session_scripts', []);
|
|
1764
2258
|
this.sessionLoadedScripts = new Set(sessionScripts);
|
|
2259
|
+
// FIXED (ISSUE-02): Set up postMessage listener for iframe cleanup
|
|
2260
|
+
this.messageHandler = (event) => {
|
|
2261
|
+
if (event.data && event.data.type === 'datalyr_script_complete') {
|
|
2262
|
+
const scriptId = event.data.scriptId;
|
|
2263
|
+
this.log('Received script completion signal:', scriptId);
|
|
2264
|
+
// Find and clean up the corresponding iframe
|
|
2265
|
+
const iframe = this.sandboxedIframes.find(iframe => iframe.dataset.datalyrScript === scriptId);
|
|
2266
|
+
if (iframe) {
|
|
2267
|
+
this.cleanupIframe(iframe);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
window.addEventListener('message', this.messageHandler);
|
|
1765
2272
|
}
|
|
1766
2273
|
/**
|
|
1767
2274
|
* Initialize container and load scripts
|
|
@@ -1929,45 +2436,142 @@ class ContainerManager {
|
|
|
1929
2436
|
}
|
|
1930
2437
|
}
|
|
1931
2438
|
/**
|
|
1932
|
-
* Load inline JavaScript
|
|
2439
|
+
* Load inline JavaScript in sandboxed iframe
|
|
2440
|
+
* SECURITY: User-provided scripts run in isolated context to prevent XSS
|
|
2441
|
+
* FIXED (ISSUE-02): Added cleanup mechanism to prevent memory leaks
|
|
1933
2442
|
*/
|
|
1934
2443
|
loadInlineScript(script) {
|
|
1935
|
-
//
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2444
|
+
// SECURITY FIX: Run in sandboxed iframe instead of main page context
|
|
2445
|
+
// This prevents access to parent window, cookies, and localStorage
|
|
2446
|
+
const iframe = document.createElement('iframe');
|
|
2447
|
+
iframe.style.display = 'none';
|
|
2448
|
+
iframe.setAttribute('sandbox', 'allow-scripts'); // Minimal permissions
|
|
2449
|
+
iframe.dataset.datalyrScript = script.id;
|
|
2450
|
+
// FIXED (ISSUE-02): Track iframe for cleanup
|
|
2451
|
+
this.sandboxedIframes.push(iframe);
|
|
2452
|
+
// Create isolated script context with completion signal
|
|
2453
|
+
const iframeDoc = `
|
|
2454
|
+
<!DOCTYPE html>
|
|
2455
|
+
<html>
|
|
2456
|
+
<head>
|
|
2457
|
+
<meta charset="UTF-8">
|
|
2458
|
+
</head>
|
|
2459
|
+
<body>
|
|
2460
|
+
<script>
|
|
2461
|
+
// User-provided script runs here in isolation
|
|
2462
|
+
try {
|
|
2463
|
+
${script.content}
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
console.error('[Datalyr Container] Script execution error:', error);
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// FIXED (ISSUE-02): Signal completion for cleanup
|
|
2469
|
+
// Scripts have 5 seconds to execute before iframe is removed
|
|
2470
|
+
setTimeout(function() {
|
|
2471
|
+
try {
|
|
2472
|
+
parent.postMessage({ type: 'datalyr_script_complete', scriptId: '${script.id}' }, '*');
|
|
2473
|
+
} catch (e) {
|
|
2474
|
+
// Ignore postMessage errors from sandbox
|
|
2475
|
+
}
|
|
2476
|
+
}, 5000);
|
|
2477
|
+
</script>
|
|
2478
|
+
</body>
|
|
2479
|
+
</html>
|
|
2480
|
+
`;
|
|
2481
|
+
document.body.appendChild(iframe);
|
|
2482
|
+
// Write content to iframe (safe because sandbox prevents parent access)
|
|
2483
|
+
if (iframe.contentDocument) {
|
|
2484
|
+
iframe.contentDocument.open();
|
|
2485
|
+
iframe.contentDocument.write(iframeDoc);
|
|
2486
|
+
iframe.contentDocument.close();
|
|
2487
|
+
}
|
|
2488
|
+
// FIXED (ISSUE-02): Remove iframe after execution (30 seconds max as fallback)
|
|
2489
|
+
// If script completes in 5s, postMessage will trigger early cleanup
|
|
2490
|
+
const timeoutId = window.setTimeout(() => {
|
|
2491
|
+
this.cleanupIframe(iframe);
|
|
2492
|
+
}, 30000);
|
|
2493
|
+
// Store timeout ID for cleanup
|
|
2494
|
+
this.iframeCleanupTimeouts.set(iframe, timeoutId);
|
|
2495
|
+
this.log('Loaded inline script in sandbox:', script.id);
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Clean up a sandboxed iframe
|
|
2499
|
+
* FIXED (ISSUE-02): Prevents memory leaks from iframe accumulation
|
|
2500
|
+
*/
|
|
2501
|
+
cleanupIframe(iframe) {
|
|
2502
|
+
try {
|
|
2503
|
+
// FIXED (ISSUE-02): Clear pending timeout to prevent duplicate cleanup
|
|
2504
|
+
const timeoutId = this.iframeCleanupTimeouts.get(iframe);
|
|
2505
|
+
if (timeoutId) {
|
|
2506
|
+
clearTimeout(timeoutId);
|
|
2507
|
+
this.iframeCleanupTimeouts.delete(iframe);
|
|
2508
|
+
}
|
|
2509
|
+
// Remove from tracking array
|
|
2510
|
+
const index = this.sandboxedIframes.indexOf(iframe);
|
|
2511
|
+
if (index > -1) {
|
|
2512
|
+
this.sandboxedIframes.splice(index, 1);
|
|
2513
|
+
}
|
|
2514
|
+
// Remove from DOM
|
|
2515
|
+
if (iframe.parentNode) {
|
|
2516
|
+
iframe.parentNode.removeChild(iframe);
|
|
2517
|
+
this.log('Cleaned up sandboxed iframe:', iframe.dataset.datalyrScript);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
catch (error) {
|
|
2521
|
+
this.log('Error cleaning up iframe:', error);
|
|
1939
2522
|
}
|
|
1940
|
-
const scriptElement = document.createElement('script');
|
|
1941
|
-
scriptElement.textContent = script.content;
|
|
1942
|
-
scriptElement.dataset.datalyrScript = script.id;
|
|
1943
|
-
scriptElement.setAttribute('data-nonce', this.generateNonce());
|
|
1944
|
-
document.head.appendChild(scriptElement);
|
|
1945
2523
|
}
|
|
1946
2524
|
/**
|
|
1947
|
-
*
|
|
2525
|
+
* Clean up all sandboxed iframes
|
|
2526
|
+
* FIXED (ISSUE-02): Called on SDK destroy to prevent memory leaks
|
|
2527
|
+
*/
|
|
2528
|
+
cleanupAllIframes() {
|
|
2529
|
+
// Clean up all iframes
|
|
2530
|
+
const iframes = [...this.sandboxedIframes]; // Copy array since we're modifying it
|
|
2531
|
+
iframes.forEach(iframe => this.cleanupIframe(iframe));
|
|
2532
|
+
// FIXED (ISSUE-02): Remove message listener to prevent memory leak
|
|
2533
|
+
if (this.messageHandler) {
|
|
2534
|
+
window.removeEventListener('message', this.messageHandler);
|
|
2535
|
+
this.messageHandler = null;
|
|
2536
|
+
}
|
|
2537
|
+
// Clear any remaining timeouts
|
|
2538
|
+
this.iframeCleanupTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
|
2539
|
+
this.iframeCleanupTimeouts.clear();
|
|
2540
|
+
this.log(`Cleaned up ${iframes.length} sandboxed iframes`);
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Load external JavaScript with SRI validation
|
|
2544
|
+
* SECURITY (Phase 1.2): SRI is now REQUIRED for external scripts
|
|
1948
2545
|
*/
|
|
1949
2546
|
loadExternalScript(script) {
|
|
2547
|
+
var _a;
|
|
1950
2548
|
// Validate URL before loading
|
|
1951
2549
|
if (!this.isValidScriptUrl(script.content)) {
|
|
1952
2550
|
this.log('Blocked invalid script URL:', script.content);
|
|
1953
2551
|
return;
|
|
1954
2552
|
}
|
|
2553
|
+
// SECURITY FIX: Require SRI (Subresource Integrity) for external scripts
|
|
2554
|
+
if (!((_a = script.settings) === null || _a === void 0 ? void 0 : _a.integrity)) {
|
|
2555
|
+
console.error(`[Datalyr Container] SECURITY: External script "${script.id}" blocked - missing SRI hash.\n` +
|
|
2556
|
+
`All external scripts MUST include an integrity hash to prevent CDN compromise attacks.\n` +
|
|
2557
|
+
`Generate SRI hash at: https://www.srihash.org/\n` +
|
|
2558
|
+
`Example: { integrity: "sha384-..." }`);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
1955
2561
|
const scriptElement = document.createElement('script');
|
|
1956
2562
|
scriptElement.src = script.content;
|
|
1957
2563
|
scriptElement.dataset.datalyrScript = script.id;
|
|
1958
2564
|
// Apply settings
|
|
1959
2565
|
if (script.settings) {
|
|
2566
|
+
scriptElement.integrity = script.settings.integrity; // REQUIRED
|
|
2567
|
+
scriptElement.crossOrigin = script.settings.crossorigin || 'anonymous'; // Required for SRI
|
|
1960
2568
|
if (script.settings.async !== false)
|
|
1961
2569
|
scriptElement.async = true;
|
|
1962
2570
|
if (script.settings.defer)
|
|
1963
2571
|
scriptElement.defer = true;
|
|
1964
|
-
if (script.settings.integrity)
|
|
1965
|
-
scriptElement.integrity = script.settings.integrity;
|
|
1966
|
-
if (script.settings.crossorigin)
|
|
1967
|
-
scriptElement.crossOrigin = script.settings.crossorigin;
|
|
1968
2572
|
}
|
|
1969
2573
|
else {
|
|
1970
|
-
//
|
|
2574
|
+
// This should never happen now that integrity is required
|
|
1971
2575
|
scriptElement.async = true;
|
|
1972
2576
|
}
|
|
1973
2577
|
document.head.appendChild(scriptElement);
|
|
@@ -2112,10 +2716,13 @@ class ContainerManager {
|
|
|
2112
2716
|
*/
|
|
2113
2717
|
trackToPixels(eventName, properties = {}) {
|
|
2114
2718
|
var _a, _b, _c, _e, _f, _g;
|
|
2719
|
+
// Sanitize inputs to prevent XSS
|
|
2720
|
+
const sanitizedEventName = this.sanitizeEventName(eventName);
|
|
2721
|
+
const sanitizedProperties = this.sanitizeProperties(properties);
|
|
2115
2722
|
// Track to Meta Pixel
|
|
2116
2723
|
if (((_b = (_a = this.pixels) === null || _a === void 0 ? void 0 : _a.meta) === null || _b === void 0 ? void 0 : _b.enabled) && window.fbq) {
|
|
2117
2724
|
try {
|
|
2118
|
-
window.fbq('track',
|
|
2725
|
+
window.fbq('track', sanitizedEventName, sanitizedProperties);
|
|
2119
2726
|
}
|
|
2120
2727
|
catch (error) {
|
|
2121
2728
|
this.log('Error tracking Meta Pixel event:', error);
|
|
@@ -2124,7 +2731,7 @@ class ContainerManager {
|
|
|
2124
2731
|
// Track to Google Tag
|
|
2125
2732
|
if (((_e = (_c = this.pixels) === null || _c === void 0 ? void 0 : _c.google) === null || _e === void 0 ? void 0 : _e.enabled) && window.gtag) {
|
|
2126
2733
|
try {
|
|
2127
|
-
window.gtag('event',
|
|
2734
|
+
window.gtag('event', sanitizedEventName, sanitizedProperties);
|
|
2128
2735
|
}
|
|
2129
2736
|
catch (error) {
|
|
2130
2737
|
this.log('Error tracking Google Tag event:', error);
|
|
@@ -2142,8 +2749,8 @@ class ContainerManager {
|
|
|
2142
2749
|
'Search': 'Search',
|
|
2143
2750
|
'Lead': 'SubmitForm'
|
|
2144
2751
|
};
|
|
2145
|
-
const tiktokEvent = tiktokEventMap[
|
|
2146
|
-
window.ttq.track(tiktokEvent,
|
|
2752
|
+
const tiktokEvent = tiktokEventMap[sanitizedEventName] || sanitizedEventName;
|
|
2753
|
+
window.ttq.track(tiktokEvent, sanitizedProperties);
|
|
2147
2754
|
}
|
|
2148
2755
|
catch (error) {
|
|
2149
2756
|
this.log('Error tracking TikTok Pixel event:', error);
|
|
@@ -2166,49 +2773,25 @@ class ContainerManager {
|
|
|
2166
2773
|
return Array.from(this.loadedScripts);
|
|
2167
2774
|
}
|
|
2168
2775
|
/**
|
|
2169
|
-
*
|
|
2776
|
+
* SECURITY MODEL (Phase 1.1 Fix):
|
|
2777
|
+
*
|
|
2778
|
+
* Inline scripts run in sandboxed iframes with 'allow-scripts' only.
|
|
2779
|
+
* This prevents:
|
|
2780
|
+
* - Access to parent window/document
|
|
2781
|
+
* - Access to cookies and localStorage
|
|
2782
|
+
* - Cross-origin requests
|
|
2783
|
+
* - Popup creation
|
|
2784
|
+
* - Form submission
|
|
2785
|
+
*
|
|
2786
|
+
* External scripts MUST have SRI (Subresource Integrity) hashes.
|
|
2787
|
+
* This prevents:
|
|
2788
|
+
* - CDN compromise attacks
|
|
2789
|
+
* - Man-in-the-middle script injection
|
|
2790
|
+
* - Unauthorized script modifications
|
|
2791
|
+
*
|
|
2792
|
+
* Previous regex-based validation was removed because it's trivially bypassable.
|
|
2793
|
+
* Sandboxing provides defense-in-depth regardless of script content.
|
|
2170
2794
|
*/
|
|
2171
|
-
extractAppEndpoint(endpoint) {
|
|
2172
|
-
if (!endpoint) {
|
|
2173
|
-
return 'https://app.datalyr.com';
|
|
2174
|
-
}
|
|
2175
|
-
// If it's already an app endpoint, use it
|
|
2176
|
-
if (endpoint.includes('app.datalyr.com')) {
|
|
2177
|
-
return endpoint;
|
|
2178
|
-
}
|
|
2179
|
-
// Convert ingest endpoint to app endpoint
|
|
2180
|
-
if (endpoint.includes('ingest.datalyr.com')) {
|
|
2181
|
-
return 'https://app.datalyr.com';
|
|
2182
|
-
}
|
|
2183
|
-
// For local development
|
|
2184
|
-
if (endpoint.includes('localhost') || endpoint.includes('127.0.0.1')) {
|
|
2185
|
-
// Assume app is on port 3000 if ingest is on 3001
|
|
2186
|
-
return endpoint.replace(':3001', ':3000').replace('/ingest', '');
|
|
2187
|
-
}
|
|
2188
|
-
// For custom endpoints, try to extract the base domain
|
|
2189
|
-
try {
|
|
2190
|
-
const url = new URL(endpoint);
|
|
2191
|
-
return `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
|
|
2192
|
-
}
|
|
2193
|
-
catch (_a) {
|
|
2194
|
-
return 'https://app.datalyr.com';
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
/**
|
|
2198
|
-
* Check for malicious patterns in inline scripts
|
|
2199
|
-
*/
|
|
2200
|
-
containsMaliciousPatterns(content) {
|
|
2201
|
-
// Basic patterns that might indicate malicious content
|
|
2202
|
-
const dangerousPatterns = [
|
|
2203
|
-
/<script[^>]*>/gi, // Script tags within content
|
|
2204
|
-
/document\.cookie/gi, // Direct cookie access
|
|
2205
|
-
/eval\s*\(/gi, // eval usage
|
|
2206
|
-
/Function\s*\(/gi, // Function constructor
|
|
2207
|
-
/innerHTML\s*=/gi, // Direct innerHTML assignment
|
|
2208
|
-
/document\.write/gi, // document.write usage
|
|
2209
|
-
];
|
|
2210
|
-
return dangerousPatterns.some(pattern => pattern.test(content));
|
|
2211
|
-
}
|
|
2212
2795
|
/**
|
|
2213
2796
|
* Validate script URL
|
|
2214
2797
|
*/
|
|
@@ -2230,12 +2813,61 @@ class ContainerManager {
|
|
|
2230
2813
|
}
|
|
2231
2814
|
}
|
|
2232
2815
|
/**
|
|
2233
|
-
*
|
|
2816
|
+
* Sanitize event name - whitelist alphanumeric, underscore, dollar sign
|
|
2234
2817
|
*/
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2818
|
+
sanitizeEventName(eventName) {
|
|
2819
|
+
if (typeof eventName !== 'string') {
|
|
2820
|
+
return 'unknown_event';
|
|
2821
|
+
}
|
|
2822
|
+
// Allow alphanumeric, underscore, dollar sign, and spaces
|
|
2823
|
+
return eventName.replace(/[^a-zA-Z0-9_$ ]/g, '').substring(0, 100);
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Recursively sanitize properties object
|
|
2827
|
+
*/
|
|
2828
|
+
sanitizeProperties(properties) {
|
|
2829
|
+
if (properties === null || properties === undefined) {
|
|
2830
|
+
return {};
|
|
2831
|
+
}
|
|
2832
|
+
if (typeof properties !== 'object') {
|
|
2833
|
+
return this.sanitizeValue(properties);
|
|
2834
|
+
}
|
|
2835
|
+
if (Array.isArray(properties)) {
|
|
2836
|
+
return properties.map(item => this.sanitizeValue(item));
|
|
2837
|
+
}
|
|
2838
|
+
const sanitized = {};
|
|
2839
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
2840
|
+
// Sanitize key
|
|
2841
|
+
const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '').substring(0, 100);
|
|
2842
|
+
if (sanitizedKey) {
|
|
2843
|
+
sanitized[sanitizedKey] = this.sanitizeValue(value);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
return sanitized;
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Sanitize individual value
|
|
2850
|
+
*/
|
|
2851
|
+
sanitizeValue(value) {
|
|
2852
|
+
if (value === null || value === undefined) {
|
|
2853
|
+
return value;
|
|
2854
|
+
}
|
|
2855
|
+
if (typeof value === 'string') {
|
|
2856
|
+
// Remove potential XSS patterns
|
|
2857
|
+
return value
|
|
2858
|
+
.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
|
2859
|
+
.replace(/<[^>]+>/g, '')
|
|
2860
|
+
.replace(/javascript:/gi, '')
|
|
2861
|
+
.replace(/on\w+\s*=/gi, '')
|
|
2862
|
+
.substring(0, 1000);
|
|
2863
|
+
}
|
|
2864
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
2865
|
+
return value;
|
|
2866
|
+
}
|
|
2867
|
+
if (typeof value === 'object') {
|
|
2868
|
+
return this.sanitizeProperties(value);
|
|
2869
|
+
}
|
|
2870
|
+
return String(value).substring(0, 1000);
|
|
2239
2871
|
}
|
|
2240
2872
|
/**
|
|
2241
2873
|
* Debug logging
|
|
@@ -2247,6 +2879,437 @@ class ContainerManager {
|
|
|
2247
2879
|
}
|
|
2248
2880
|
}
|
|
2249
2881
|
|
|
2882
|
+
/**
|
|
2883
|
+
* Auto-Identify Module
|
|
2884
|
+
*
|
|
2885
|
+
* Automatically captures user identity (email) from:
|
|
2886
|
+
* 1. Fetch/XHR API requests (email in request/response)
|
|
2887
|
+
* 2. Form submissions (email inputs)
|
|
2888
|
+
* 3. Shopify-specific endpoints (/account.json)
|
|
2889
|
+
*
|
|
2890
|
+
* SECURITY:
|
|
2891
|
+
* - Email validation to prevent false positives
|
|
2892
|
+
* - Whitelist approach for trusted domains
|
|
2893
|
+
* - Rate limiting to prevent spam
|
|
2894
|
+
* - Privacy controls integration
|
|
2895
|
+
*/
|
|
2896
|
+
class AutoIdentifyManager {
|
|
2897
|
+
constructor(config = {}) {
|
|
2898
|
+
this.formListeners = [];
|
|
2899
|
+
this.lastIdentifyTime = 0;
|
|
2900
|
+
this.RATE_LIMIT_MS = 5000; // Don't auto-identify more than once per 5 seconds
|
|
2901
|
+
this.config = {
|
|
2902
|
+
enabled: config.enabled !== false,
|
|
2903
|
+
captureFromForms: config.captureFromForms !== false,
|
|
2904
|
+
captureFromAPI: config.captureFromAPI !== false,
|
|
2905
|
+
captureFromShopify: config.captureFromShopify !== false,
|
|
2906
|
+
trustedDomains: config.trustedDomains || [],
|
|
2907
|
+
debug: config.debug || false
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* Initialize auto-identify system
|
|
2912
|
+
*/
|
|
2913
|
+
initialize(identifyCallback) {
|
|
2914
|
+
if (!this.config.enabled) {
|
|
2915
|
+
this.log('Auto-identify disabled');
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
this.identifyCallback = identifyCallback;
|
|
2919
|
+
// Check if already identified
|
|
2920
|
+
const existingEmail = storage.get('dl_auto_identified_email');
|
|
2921
|
+
if (existingEmail) {
|
|
2922
|
+
this.log('User already auto-identified:', existingEmail);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
// Setup monitoring
|
|
2926
|
+
if (this.config.captureFromForms) {
|
|
2927
|
+
this.setupFormMonitoring();
|
|
2928
|
+
}
|
|
2929
|
+
if (this.config.captureFromAPI) {
|
|
2930
|
+
this.setupFetchInterception();
|
|
2931
|
+
this.setupXHRInterception();
|
|
2932
|
+
}
|
|
2933
|
+
if (this.config.captureFromShopify) {
|
|
2934
|
+
this.setupShopifyMonitoring();
|
|
2935
|
+
}
|
|
2936
|
+
this.log('Auto-identify initialized');
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Setup form monitoring for email capture
|
|
2940
|
+
*/
|
|
2941
|
+
setupFormMonitoring() {
|
|
2942
|
+
// Monitor existing forms
|
|
2943
|
+
this.scanForEmailForms();
|
|
2944
|
+
// Watch for new forms (SPAs)
|
|
2945
|
+
const observer = new MutationObserver(() => {
|
|
2946
|
+
this.scanForEmailForms();
|
|
2947
|
+
});
|
|
2948
|
+
observer.observe(document.body, {
|
|
2949
|
+
childList: true,
|
|
2950
|
+
subtree: true
|
|
2951
|
+
});
|
|
2952
|
+
this.log('Form monitoring active');
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Scan DOM for forms with email inputs
|
|
2956
|
+
*/
|
|
2957
|
+
scanForEmailForms() {
|
|
2958
|
+
const forms = document.querySelectorAll('form');
|
|
2959
|
+
forms.forEach(form => {
|
|
2960
|
+
// Skip if already listening
|
|
2961
|
+
if (this.formListeners.some(l => l.element === form)) {
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
// Check if form has email input
|
|
2965
|
+
const emailInput = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
|
|
2966
|
+
if (emailInput) {
|
|
2967
|
+
const handler = (e) => this.handleFormSubmit(e, form);
|
|
2968
|
+
form.addEventListener('submit', handler);
|
|
2969
|
+
this.formListeners.push({ element: form, handler });
|
|
2970
|
+
this.log('Monitoring form:', form);
|
|
2971
|
+
}
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* Handle form submission
|
|
2976
|
+
*/
|
|
2977
|
+
handleFormSubmit(_event, form) {
|
|
2978
|
+
try {
|
|
2979
|
+
// Find email input
|
|
2980
|
+
const emailInput = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
|
|
2981
|
+
if (!emailInput)
|
|
2982
|
+
return;
|
|
2983
|
+
const email = emailInput.value.trim();
|
|
2984
|
+
if (this.isValidEmail(email)) {
|
|
2985
|
+
this.log('Email captured from form:', email);
|
|
2986
|
+
this.triggerIdentify(email, 'form');
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
catch (error) {
|
|
2990
|
+
this.log('Error handling form submit:', error);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Setup fetch interception for API email capture
|
|
2995
|
+
*/
|
|
2996
|
+
setupFetchInterception() {
|
|
2997
|
+
if (typeof window.fetch !== 'function')
|
|
2998
|
+
return;
|
|
2999
|
+
this.originalFetch = window.fetch;
|
|
3000
|
+
const self = this;
|
|
3001
|
+
window.fetch = function (input, init) {
|
|
3002
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3003
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
3004
|
+
// Check if trusted domain
|
|
3005
|
+
if (!self.isTrustedDomain(url)) {
|
|
3006
|
+
return self.originalFetch.call(window, input, init);
|
|
3007
|
+
}
|
|
3008
|
+
try {
|
|
3009
|
+
// Check request body for email
|
|
3010
|
+
if (init === null || init === void 0 ? void 0 : init.body) {
|
|
3011
|
+
self.extractEmailFromData(init.body, 'api-request');
|
|
3012
|
+
}
|
|
3013
|
+
// Make actual request
|
|
3014
|
+
const response = self.originalFetch.call(window, input, init);
|
|
3015
|
+
// Check response for email
|
|
3016
|
+
response.then((res) => __awaiter(this, void 0, void 0, function* () {
|
|
3017
|
+
var _a;
|
|
3018
|
+
if (res.ok && ((_a = res.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.includes('application/json'))) {
|
|
3019
|
+
try {
|
|
3020
|
+
const clone = res.clone();
|
|
3021
|
+
const data = yield clone.json();
|
|
3022
|
+
self.extractEmailFromData(data, 'api-response');
|
|
3023
|
+
}
|
|
3024
|
+
catch (error) {
|
|
3025
|
+
// Ignore JSON parse errors
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
})).catch(() => {
|
|
3029
|
+
// Ignore response errors
|
|
3030
|
+
});
|
|
3031
|
+
return response;
|
|
3032
|
+
}
|
|
3033
|
+
catch (error) {
|
|
3034
|
+
self.log('Error intercepting fetch:', error);
|
|
3035
|
+
return self.originalFetch.call(window, input, init);
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
3038
|
+
};
|
|
3039
|
+
this.log('Fetch interception active');
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Setup XHR interception for API email capture
|
|
3043
|
+
*/
|
|
3044
|
+
setupXHRInterception() {
|
|
3045
|
+
if (typeof XMLHttpRequest === 'undefined')
|
|
3046
|
+
return;
|
|
3047
|
+
const self = this;
|
|
3048
|
+
this.originalXHROpen = XMLHttpRequest.prototype.open;
|
|
3049
|
+
this.originalXHRSend = XMLHttpRequest.prototype.send;
|
|
3050
|
+
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
|
3051
|
+
this._datalyrUrl = typeof url === 'string' ? url : url.href;
|
|
3052
|
+
return self.originalXHROpen.apply(this, [method, url, ...args]);
|
|
3053
|
+
};
|
|
3054
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
3055
|
+
const url = this._datalyrUrl;
|
|
3056
|
+
if (url && self.isTrustedDomain(url)) {
|
|
3057
|
+
// Check request body
|
|
3058
|
+
if (body) {
|
|
3059
|
+
self.extractEmailFromData(body, 'api-request');
|
|
3060
|
+
}
|
|
3061
|
+
// Monitor response
|
|
3062
|
+
this.addEventListener('load', function () {
|
|
3063
|
+
if (this.status >= 200 && this.status < 300) {
|
|
3064
|
+
try {
|
|
3065
|
+
const contentType = this.getResponseHeader('content-type');
|
|
3066
|
+
if (contentType === null || contentType === void 0 ? void 0 : contentType.includes('application/json')) {
|
|
3067
|
+
const data = JSON.parse(this.responseText);
|
|
3068
|
+
self.extractEmailFromData(data, 'api-response');
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
catch (error) {
|
|
3072
|
+
// Ignore parse errors
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
return self.originalXHRSend.call(this, body);
|
|
3078
|
+
};
|
|
3079
|
+
this.log('XHR interception active');
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Setup Shopify-specific monitoring
|
|
3083
|
+
*/
|
|
3084
|
+
setupShopifyMonitoring() {
|
|
3085
|
+
// Detect Shopify
|
|
3086
|
+
if (!this.isShopify()) {
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
this.log('Shopify detected, setting up monitoring');
|
|
3090
|
+
// Try to fetch customer data immediately
|
|
3091
|
+
this.checkShopifyCustomer();
|
|
3092
|
+
// Check periodically (in case customer logs in later)
|
|
3093
|
+
this.shopifyCheckInterval = setInterval(() => {
|
|
3094
|
+
this.checkShopifyCustomer();
|
|
3095
|
+
}, 10000); // Check every 10 seconds
|
|
3096
|
+
this.log('Shopify monitoring active');
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* Check if running on Shopify
|
|
3100
|
+
*/
|
|
3101
|
+
isShopify() {
|
|
3102
|
+
return !!(window.Shopify ||
|
|
3103
|
+
document.querySelector('meta[name="shopify-checkout-api-token"]') ||
|
|
3104
|
+
window.location.hostname.includes('.myshopify.com'));
|
|
3105
|
+
}
|
|
3106
|
+
/**
|
|
3107
|
+
* Check Shopify customer endpoint for email
|
|
3108
|
+
*/
|
|
3109
|
+
checkShopifyCustomer() {
|
|
3110
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3111
|
+
var _a;
|
|
3112
|
+
try {
|
|
3113
|
+
const response = yield fetch('/account.json', {
|
|
3114
|
+
credentials: 'same-origin'
|
|
3115
|
+
});
|
|
3116
|
+
if (response.ok) {
|
|
3117
|
+
const data = yield response.json();
|
|
3118
|
+
if ((_a = data === null || data === void 0 ? void 0 : data.customer) === null || _a === void 0 ? void 0 : _a.email) {
|
|
3119
|
+
this.log('Email captured from Shopify:', data.customer.email);
|
|
3120
|
+
this.triggerIdentify(data.customer.email, 'shopify');
|
|
3121
|
+
// Stop checking once we have email
|
|
3122
|
+
if (this.shopifyCheckInterval) {
|
|
3123
|
+
clearInterval(this.shopifyCheckInterval);
|
|
3124
|
+
this.shopifyCheckInterval = undefined;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
catch (error) {
|
|
3130
|
+
// User not logged in or endpoint unavailable
|
|
3131
|
+
this.log('Shopify customer check failed:', error);
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Extract email from data (object, string, FormData)
|
|
3137
|
+
*/
|
|
3138
|
+
extractEmailFromData(data, source) {
|
|
3139
|
+
try {
|
|
3140
|
+
let emails = [];
|
|
3141
|
+
if (typeof data === 'string') {
|
|
3142
|
+
// Try to parse as JSON
|
|
3143
|
+
try {
|
|
3144
|
+
const parsed = JSON.parse(data);
|
|
3145
|
+
emails = this.findEmailsInObject(parsed);
|
|
3146
|
+
}
|
|
3147
|
+
catch (_a) {
|
|
3148
|
+
// Try to extract from string directly
|
|
3149
|
+
const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
3150
|
+
const matches = data.match(emailPattern);
|
|
3151
|
+
if (matches) {
|
|
3152
|
+
emails = matches;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
else if (data instanceof FormData) {
|
|
3157
|
+
// Extract from FormData
|
|
3158
|
+
data.forEach((value) => {
|
|
3159
|
+
if (typeof value === 'string' && this.isValidEmail(value)) {
|
|
3160
|
+
emails.push(value);
|
|
3161
|
+
}
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
else if (typeof data === 'object' && data !== null) {
|
|
3165
|
+
// Extract from object
|
|
3166
|
+
emails = this.findEmailsInObject(data);
|
|
3167
|
+
}
|
|
3168
|
+
// Trigger identify for first valid email
|
|
3169
|
+
if (emails.length > 0) {
|
|
3170
|
+
const email = emails[0];
|
|
3171
|
+
this.log(`Email found in ${source}:`, email);
|
|
3172
|
+
this.triggerIdentify(email, 'api');
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
catch (error) {
|
|
3176
|
+
this.log('Error extracting email from data:', error);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Recursively find emails in object
|
|
3181
|
+
*/
|
|
3182
|
+
findEmailsInObject(obj, depth = 0) {
|
|
3183
|
+
if (depth > 5)
|
|
3184
|
+
return []; // Prevent infinite recursion
|
|
3185
|
+
const emails = [];
|
|
3186
|
+
for (const key in obj) {
|
|
3187
|
+
if (!obj.hasOwnProperty(key))
|
|
3188
|
+
continue;
|
|
3189
|
+
const value = obj[key];
|
|
3190
|
+
// Check if key suggests email field
|
|
3191
|
+
if (/email/i.test(key) && typeof value === 'string' && this.isValidEmail(value)) {
|
|
3192
|
+
emails.push(value);
|
|
3193
|
+
}
|
|
3194
|
+
// Recursively check nested objects
|
|
3195
|
+
else if (typeof value === 'object' && value !== null) {
|
|
3196
|
+
emails.push(...this.findEmailsInObject(value, depth + 1));
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
return emails;
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* Check if domain is trusted for auto-identify
|
|
3203
|
+
*/
|
|
3204
|
+
isTrustedDomain(url) {
|
|
3205
|
+
try {
|
|
3206
|
+
const urlObj = new URL(url, window.location.origin);
|
|
3207
|
+
// Always trust same origin
|
|
3208
|
+
if (urlObj.origin === window.location.origin) {
|
|
3209
|
+
return true;
|
|
3210
|
+
}
|
|
3211
|
+
// Check trusted domains list
|
|
3212
|
+
if (this.config.trustedDomains.length === 0) {
|
|
3213
|
+
// If no trusted domains specified, only trust same origin
|
|
3214
|
+
return false;
|
|
3215
|
+
}
|
|
3216
|
+
return this.config.trustedDomains.some(domain => {
|
|
3217
|
+
return urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`);
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
catch (error) {
|
|
3221
|
+
return false;
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
/**
|
|
3225
|
+
* Validate email format
|
|
3226
|
+
*/
|
|
3227
|
+
isValidEmail(email) {
|
|
3228
|
+
if (!email || typeof email !== 'string')
|
|
3229
|
+
return false;
|
|
3230
|
+
// Basic email validation
|
|
3231
|
+
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
3232
|
+
if (!emailPattern.test(email))
|
|
3233
|
+
return false;
|
|
3234
|
+
// Exclude common test/dummy emails
|
|
3235
|
+
const dummyPatterns = [
|
|
3236
|
+
/test@/i,
|
|
3237
|
+
/example@/i,
|
|
3238
|
+
/demo@/i,
|
|
3239
|
+
/fake@/i,
|
|
3240
|
+
/@test\./i,
|
|
3241
|
+
/@example\./i
|
|
3242
|
+
];
|
|
3243
|
+
if (dummyPatterns.some(pattern => pattern.test(email))) {
|
|
3244
|
+
return false;
|
|
3245
|
+
}
|
|
3246
|
+
return true;
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Trigger identify with rate limiting
|
|
3250
|
+
*/
|
|
3251
|
+
triggerIdentify(email, source) {
|
|
3252
|
+
// Rate limiting
|
|
3253
|
+
const now = Date.now();
|
|
3254
|
+
if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
|
|
3255
|
+
this.log('Rate limited, skipping identify');
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
// Check if already identified
|
|
3259
|
+
const existingEmail = storage.get('dl_auto_identified_email');
|
|
3260
|
+
if (existingEmail === email) {
|
|
3261
|
+
this.log('Already identified with this email');
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
// Store email to prevent duplicate identification
|
|
3265
|
+
storage.set('dl_auto_identified_email', email);
|
|
3266
|
+
this.lastIdentifyTime = now;
|
|
3267
|
+
// Trigger callback
|
|
3268
|
+
if (this.identifyCallback) {
|
|
3269
|
+
this.log(`Auto-identifying user: ${email} (source: ${source})`);
|
|
3270
|
+
this.identifyCallback(email, source);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Destroy and cleanup
|
|
3275
|
+
*/
|
|
3276
|
+
destroy() {
|
|
3277
|
+
// Restore original fetch
|
|
3278
|
+
if (this.originalFetch) {
|
|
3279
|
+
window.fetch = this.originalFetch;
|
|
3280
|
+
this.originalFetch = undefined;
|
|
3281
|
+
}
|
|
3282
|
+
// Restore original XHR
|
|
3283
|
+
if (this.originalXHROpen) {
|
|
3284
|
+
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
|
3285
|
+
this.originalXHROpen = undefined;
|
|
3286
|
+
}
|
|
3287
|
+
if (this.originalXHRSend) {
|
|
3288
|
+
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
3289
|
+
this.originalXHRSend = undefined;
|
|
3290
|
+
}
|
|
3291
|
+
// Remove form listeners
|
|
3292
|
+
this.formListeners.forEach(({ element, handler }) => {
|
|
3293
|
+
element.removeEventListener('submit', handler);
|
|
3294
|
+
});
|
|
3295
|
+
this.formListeners = [];
|
|
3296
|
+
// Clear Shopify interval
|
|
3297
|
+
if (this.shopifyCheckInterval) {
|
|
3298
|
+
clearInterval(this.shopifyCheckInterval);
|
|
3299
|
+
this.shopifyCheckInterval = undefined;
|
|
3300
|
+
}
|
|
3301
|
+
this.log('Auto-identify destroyed');
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Debug logging
|
|
3305
|
+
*/
|
|
3306
|
+
log(...args) {
|
|
3307
|
+
if (this.config.debug) {
|
|
3308
|
+
console.log('[Datalyr Auto-Identify]', ...args);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
|
|
2250
3313
|
/**
|
|
2251
3314
|
* Datalyr Web SDK
|
|
2252
3315
|
* Modern attribution tracking for web applications
|
|
@@ -2259,9 +3322,9 @@ class Datalyr {
|
|
|
2259
3322
|
this.initialized = false;
|
|
2260
3323
|
this.errors = [];
|
|
2261
3324
|
this.MAX_ERRORS = 50;
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
3325
|
+
// FIXED (ISSUE-01): Async initialization promise to prevent race conditions
|
|
3326
|
+
this.initializationPromise = null;
|
|
3327
|
+
// Opt-out check moved to init() after cookies configured (Issue #14)
|
|
2265
3328
|
}
|
|
2266
3329
|
/**
|
|
2267
3330
|
* Initialize the SDK
|
|
@@ -2276,7 +3339,7 @@ class Datalyr {
|
|
|
2276
3339
|
throw new Error('[Datalyr] workspaceId is required');
|
|
2277
3340
|
}
|
|
2278
3341
|
// Set default config values
|
|
2279
|
-
this.config = Object.assign({ endpoint: 'https://ingest.datalyr.com', debug: false, batchSize: 10, flushInterval: 5000, flushAt: 10, criticalEvents: undefined, highPriorityEvents: undefined, sessionTimeout:
|
|
3342
|
+
this.config = Object.assign({ endpoint: 'https://ingest.datalyr.com', debug: false, batchSize: 10, flushInterval: 5000, flushAt: 10, criticalEvents: undefined, highPriorityEvents: undefined, sessionTimeout: 60 * 60 * 1000, trackSessions: true, attributionWindow: 90 * 24 * 60 * 60 * 1000, trackedParams: [], respectDoNotTrack: false, respectGlobalPrivacyControl: true, privacyMode: 'standard', cookieDomain: 'auto', cookieExpires: 365, secureCookie: 'auto', sameSite: 'Lax', cookiePrefix: '__dl_', enablePerformanceTracking: true, enableFingerprinting: true, maxRetries: 5, retryDelay: 1000, maxOfflineQueueSize: 100, trackSPA: true, trackPageViews: true, fallbackEndpoints: [], plugins: [] }, config);
|
|
2280
3343
|
// Initialize cookie storage with config
|
|
2281
3344
|
this.cookies = new CookieStorage({
|
|
2282
3345
|
domain: this.config.cookieDomain,
|
|
@@ -2284,6 +3347,10 @@ class Datalyr {
|
|
|
2284
3347
|
sameSite: this.config.sameSite,
|
|
2285
3348
|
secure: this.config.secureCookie
|
|
2286
3349
|
});
|
|
3350
|
+
// Migrate legacy storage keys (fixes double-prefix issue)
|
|
3351
|
+
storage.migrateFromLegacyPrefix();
|
|
3352
|
+
// Check opt-out AFTER cookies configured (Issue #14)
|
|
3353
|
+
this.optedOut = this.cookies.get('__dl_opt_out') === 'true';
|
|
2287
3354
|
// Initialize modules
|
|
2288
3355
|
this.identity = new IdentityManager();
|
|
2289
3356
|
this.session = new SessionManager(this.config.sessionTimeout);
|
|
@@ -2299,28 +3366,9 @@ class Datalyr {
|
|
|
2299
3366
|
// Set session ID in identity manager
|
|
2300
3367
|
const sessionId = this.session.getSessionId();
|
|
2301
3368
|
this.identity.setSessionId(sessionId);
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
if (this.config.trackSPA) {
|
|
2306
|
-
this.setupSPATracking();
|
|
2307
|
-
}
|
|
2308
|
-
// Initialize container manager if enabled
|
|
2309
|
-
if (this.config.enableContainer !== false) {
|
|
2310
|
-
this.container = new ContainerManager({
|
|
2311
|
-
workspaceId: this.config.workspaceId,
|
|
2312
|
-
endpoint: this.config.endpoint,
|
|
2313
|
-
debug: this.config.debug
|
|
2314
|
-
});
|
|
2315
|
-
// Initialize container asynchronously
|
|
2316
|
-
this.container.init().catch(error => {
|
|
2317
|
-
this.log('Container initialization failed:', error);
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
// Track initial page view if enabled
|
|
2321
|
-
if (this.config.trackPageViews) {
|
|
2322
|
-
this.page();
|
|
2323
|
-
}
|
|
3369
|
+
// FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
|
|
3370
|
+
// This allows encryption to initialize before any events are tracked
|
|
3371
|
+
this.initializeAsync();
|
|
2324
3372
|
// Setup page unload handler
|
|
2325
3373
|
this.setupUnloadHandler();
|
|
2326
3374
|
// Initialize plugins
|
|
@@ -2331,6 +3379,8 @@ class Datalyr {
|
|
|
2331
3379
|
this.log(`Plugin initialized: ${plugin.name}`);
|
|
2332
3380
|
}
|
|
2333
3381
|
catch (error) {
|
|
3382
|
+
// Issue #16: Always warn about plugin failures, even when debug is false
|
|
3383
|
+
console.warn(`[Datalyr] Plugin '${plugin.name}' failed to initialize:`, error);
|
|
2334
3384
|
this.trackError(error, { plugin: plugin.name });
|
|
2335
3385
|
}
|
|
2336
3386
|
}
|
|
@@ -2338,10 +3388,99 @@ class Datalyr {
|
|
|
2338
3388
|
this.initialized = true;
|
|
2339
3389
|
this.log('SDK initialized');
|
|
2340
3390
|
}
|
|
3391
|
+
/**
|
|
3392
|
+
* Complete async initialization (encryption, user properties, container, page view)
|
|
3393
|
+
*
|
|
3394
|
+
* FIXED (ISSUE-01): Separated async initialization to prevent race conditions
|
|
3395
|
+
* Encryption must complete before first events are tracked
|
|
3396
|
+
*/
|
|
3397
|
+
initializeAsync() {
|
|
3398
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3399
|
+
if (this.initializationPromise) {
|
|
3400
|
+
return this.initializationPromise;
|
|
3401
|
+
}
|
|
3402
|
+
this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
|
|
3403
|
+
try {
|
|
3404
|
+
// SEC-03 Fix: Initialize encryption for PII data
|
|
3405
|
+
const deviceId = this.identity.getAnonymousId();
|
|
3406
|
+
yield dataEncryption.initialize(this.config.workspaceId, deviceId);
|
|
3407
|
+
// Load encrypted user properties
|
|
3408
|
+
this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
|
|
3409
|
+
this.log('Encryption initialized, user properties loaded');
|
|
3410
|
+
// Setup SPA tracking if enabled
|
|
3411
|
+
if (this.config.trackSPA) {
|
|
3412
|
+
this.setupSPATracking();
|
|
3413
|
+
}
|
|
3414
|
+
// Initialize container manager if enabled
|
|
3415
|
+
if (this.config.enableContainer !== false) {
|
|
3416
|
+
this.container = new ContainerManager({
|
|
3417
|
+
workspaceId: this.config.workspaceId,
|
|
3418
|
+
endpoint: this.config.endpoint,
|
|
3419
|
+
debug: this.config.debug
|
|
3420
|
+
});
|
|
3421
|
+
// Initialize container asynchronously
|
|
3422
|
+
yield this.container.init().catch(error => {
|
|
3423
|
+
this.log('Container initialization failed:', error);
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
// Initialize auto-identify if explicitly enabled (opt-in)
|
|
3427
|
+
if (this.config.autoIdentify === true) {
|
|
3428
|
+
this.autoIdentify = new AutoIdentifyManager({
|
|
3429
|
+
enabled: true,
|
|
3430
|
+
captureFromForms: this.config.autoIdentifyForms,
|
|
3431
|
+
captureFromAPI: this.config.autoIdentifyAPI,
|
|
3432
|
+
captureFromShopify: this.config.autoIdentifyShopify,
|
|
3433
|
+
trustedDomains: this.config.autoIdentifyTrustedDomains,
|
|
3434
|
+
debug: this.config.debug
|
|
3435
|
+
});
|
|
3436
|
+
// Setup auto-identify callback
|
|
3437
|
+
this.autoIdentify.initialize((email, source) => {
|
|
3438
|
+
this.log(`Auto-identified user: ${email} from ${source}`);
|
|
3439
|
+
// Track auto-identify event
|
|
3440
|
+
this.track('$auto_identify', {
|
|
3441
|
+
email,
|
|
3442
|
+
source,
|
|
3443
|
+
timestamp: Date.now()
|
|
3444
|
+
});
|
|
3445
|
+
// Automatically call identify with email
|
|
3446
|
+
this.identify(email, { email });
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
// Track initial page view if enabled (AFTER encryption ready)
|
|
3450
|
+
if (this.config.trackPageViews) {
|
|
3451
|
+
this.page();
|
|
3452
|
+
}
|
|
3453
|
+
this.log('Async initialization complete');
|
|
3454
|
+
}
|
|
3455
|
+
catch (error) {
|
|
3456
|
+
console.error('[Datalyr] Async initialization failed:', error);
|
|
3457
|
+
// Fallback: Load unencrypted and continue
|
|
3458
|
+
this.userProperties = storage.get('dl_user_traits', {});
|
|
3459
|
+
}
|
|
3460
|
+
}))();
|
|
3461
|
+
return this.initializationPromise;
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* Wait for async initialization to complete
|
|
3466
|
+
* FIXED (ISSUE-01): Public method to await full initialization
|
|
3467
|
+
*/
|
|
3468
|
+
ready() {
|
|
3469
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
3470
|
+
if (!this.initialized) {
|
|
3471
|
+
throw new Error('[Datalyr] SDK not initialized. Call init() first.');
|
|
3472
|
+
}
|
|
3473
|
+
return this.initializationPromise || Promise.resolve();
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
2341
3476
|
/**
|
|
2342
3477
|
* Track an event
|
|
2343
3478
|
*/
|
|
2344
3479
|
track(eventName, properties = {}) {
|
|
3480
|
+
if (!this.initialized) {
|
|
3481
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
2345
3484
|
if (!this.shouldTrack())
|
|
2346
3485
|
return;
|
|
2347
3486
|
try {
|
|
@@ -2379,6 +3518,10 @@ class Datalyr {
|
|
|
2379
3518
|
* Identify a user
|
|
2380
3519
|
*/
|
|
2381
3520
|
identify(userId, traits = {}) {
|
|
3521
|
+
if (!this.initialized) {
|
|
3522
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
2382
3525
|
if (!this.shouldTrack())
|
|
2383
3526
|
return;
|
|
2384
3527
|
if (!userId) {
|
|
@@ -2386,11 +3529,23 @@ class Datalyr {
|
|
|
2386
3529
|
return;
|
|
2387
3530
|
}
|
|
2388
3531
|
try {
|
|
3532
|
+
// FIXED (DATA-05): Rotate session ID on identify to prevent session fixation
|
|
3533
|
+
if (this.session) {
|
|
3534
|
+
this.session.rotateSessionId();
|
|
3535
|
+
}
|
|
2389
3536
|
// Update identity
|
|
2390
3537
|
const identityLink = this.identity.identify(userId, traits);
|
|
2391
|
-
// Store user properties
|
|
3538
|
+
// SEC-03 Fix: Store user properties encrypted (contains PII)
|
|
3539
|
+
// FIXED (H2): Fail loudly instead of silent fallback to unencrypted
|
|
2392
3540
|
this.userProperties = Object.assign(Object.assign({}, this.userProperties), traits);
|
|
2393
|
-
storage.
|
|
3541
|
+
storage.setEncrypted('dl_user_traits', this.userProperties).catch(error => {
|
|
3542
|
+
console.error('[Datalyr] Failed to encrypt user traits - NOT storing unencrypted PII:', error);
|
|
3543
|
+
console.error('[Datalyr] User traits will only persist in memory until page reload');
|
|
3544
|
+
// DO NOT fallback to unencrypted storage - this would expose PII
|
|
3545
|
+
// This is a breaking change from the previous silent fallback behavior
|
|
3546
|
+
// If encryption fails, user traits are only stored in memory (this.userProperties)
|
|
3547
|
+
// and will be lost on page reload - but PII is NOT exposed in localStorage
|
|
3548
|
+
});
|
|
2394
3549
|
// Track $identify event
|
|
2395
3550
|
this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
|
|
2396
3551
|
// Call plugin handlers
|
|
@@ -2416,6 +3571,10 @@ class Datalyr {
|
|
|
2416
3571
|
* Track a page view
|
|
2417
3572
|
*/
|
|
2418
3573
|
page(properties = {}) {
|
|
3574
|
+
if (!this.initialized) {
|
|
3575
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
2419
3578
|
if (!this.shouldTrack())
|
|
2420
3579
|
return;
|
|
2421
3580
|
const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
|
|
@@ -2454,6 +3613,10 @@ class Datalyr {
|
|
|
2454
3613
|
* Associate user with a group/account
|
|
2455
3614
|
*/
|
|
2456
3615
|
group(groupId, traits = {}) {
|
|
3616
|
+
if (!this.initialized) {
|
|
3617
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
2457
3620
|
this.track('$group', {
|
|
2458
3621
|
group_id: groupId,
|
|
2459
3622
|
traits
|
|
@@ -2463,6 +3626,10 @@ class Datalyr {
|
|
|
2463
3626
|
* Alias one ID to another
|
|
2464
3627
|
*/
|
|
2465
3628
|
alias(userId, previousId) {
|
|
3629
|
+
if (!this.initialized) {
|
|
3630
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
2466
3633
|
const aliasData = this.identity.alias(userId, previousId);
|
|
2467
3634
|
this.track('$alias', aliasData);
|
|
2468
3635
|
}
|
|
@@ -2470,6 +3637,10 @@ class Datalyr {
|
|
|
2470
3637
|
* Reset the current user
|
|
2471
3638
|
*/
|
|
2472
3639
|
reset() {
|
|
3640
|
+
if (!this.initialized) {
|
|
3641
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
2473
3644
|
this.identity.reset();
|
|
2474
3645
|
this.userProperties = {};
|
|
2475
3646
|
storage.remove('dl_user_traits');
|
|
@@ -2538,8 +3709,12 @@ class Datalyr {
|
|
|
2538
3709
|
* Opt out of tracking
|
|
2539
3710
|
*/
|
|
2540
3711
|
optOut() {
|
|
3712
|
+
if (!this.initialized) {
|
|
3713
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
2541
3716
|
this.optedOut = true;
|
|
2542
|
-
cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
|
|
3717
|
+
this.cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
|
|
2543
3718
|
this.queue.clear();
|
|
2544
3719
|
this.log('User opted out');
|
|
2545
3720
|
}
|
|
@@ -2547,8 +3722,12 @@ class Datalyr {
|
|
|
2547
3722
|
* Opt in to tracking
|
|
2548
3723
|
*/
|
|
2549
3724
|
optIn() {
|
|
3725
|
+
if (!this.initialized) {
|
|
3726
|
+
console.warn('[Datalyr] SDK not initialized. Call init() first.');
|
|
3727
|
+
return;
|
|
3728
|
+
}
|
|
2550
3729
|
this.optedOut = false;
|
|
2551
|
-
cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
|
|
3730
|
+
this.cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
|
|
2552
3731
|
this.log('User opted in');
|
|
2553
3732
|
}
|
|
2554
3733
|
/**
|
|
@@ -2609,8 +3788,7 @@ class Datalyr {
|
|
|
2609
3788
|
if (this.config.enableFingerprinting) {
|
|
2610
3789
|
const fingerprintData = this.fingerprint.collect();
|
|
2611
3790
|
Object.assign(eventData, {
|
|
2612
|
-
|
|
2613
|
-
device_fingerprint: fingerprintData // Snake case alias
|
|
3791
|
+
device_fingerprint: fingerprintData // Use snake_case only (matches backend)
|
|
2614
3792
|
});
|
|
2615
3793
|
}
|
|
2616
3794
|
// Add browser context
|
|
@@ -2624,7 +3802,7 @@ class Datalyr {
|
|
|
2624
3802
|
viewport_width: window.innerWidth,
|
|
2625
3803
|
viewport_height: window.innerHeight
|
|
2626
3804
|
});
|
|
2627
|
-
// Create payload
|
|
3805
|
+
// Create payload using snake_case only (matches backend API and production script)
|
|
2628
3806
|
const identityFields = this.identity.getIdentityFields();
|
|
2629
3807
|
const eventId = generateUUID();
|
|
2630
3808
|
// Ensure we have all required identity fields
|
|
@@ -2633,31 +3811,25 @@ class Datalyr {
|
|
|
2633
3811
|
const visitorId = identityFields.visitor_id || anonymousId;
|
|
2634
3812
|
const sessionId = identityFields.session_id;
|
|
2635
3813
|
const payload = {
|
|
2636
|
-
// Required fields
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
eventName,
|
|
2642
|
-
event_name: eventName, // Snake case alias
|
|
2643
|
-
eventData,
|
|
2644
|
-
event_data: eventData, // Snake case alias
|
|
3814
|
+
// Required fields (snake_case only - no duplication)
|
|
3815
|
+
workspace_id: this.config.workspaceId,
|
|
3816
|
+
event_id: eventId,
|
|
3817
|
+
event_name: eventName,
|
|
3818
|
+
event_data: eventData,
|
|
2645
3819
|
source: 'web',
|
|
2646
3820
|
timestamp: new Date().toISOString(),
|
|
2647
|
-
// Identity fields (
|
|
3821
|
+
// Identity fields (snake_case only)
|
|
2648
3822
|
distinct_id: distinctId,
|
|
2649
3823
|
anonymous_id: anonymousId,
|
|
2650
3824
|
visitor_id: visitorId,
|
|
2651
|
-
visitorId: visitorId,
|
|
2652
3825
|
user_id: identityFields.user_id,
|
|
2653
3826
|
canonical_id: identityFields.canonical_id,
|
|
2654
|
-
sessionId: sessionId,
|
|
2655
3827
|
session_id: sessionId,
|
|
2656
3828
|
// Resolution metadata
|
|
2657
3829
|
resolution_method: 'browser_sdk',
|
|
2658
3830
|
resolution_confidence: 1.0,
|
|
2659
3831
|
// SDK metadata
|
|
2660
|
-
sdk_version: '1.
|
|
3832
|
+
sdk_version: '1.1.1',
|
|
2661
3833
|
sdk_name: 'datalyr-web-sdk'
|
|
2662
3834
|
};
|
|
2663
3835
|
return payload;
|
|
@@ -2682,36 +3854,48 @@ class Datalyr {
|
|
|
2682
3854
|
}
|
|
2683
3855
|
/**
|
|
2684
3856
|
* Setup SPA tracking
|
|
3857
|
+
* Fixed Issue #15: Store original methods for cleanup
|
|
3858
|
+
* Fixed CRITICAL-03: Clear attribution cache on navigation to prevent stale data
|
|
2685
3859
|
*/
|
|
2686
3860
|
setupSPATracking() {
|
|
2687
|
-
// Store original methods
|
|
2688
|
-
|
|
2689
|
-
|
|
3861
|
+
// Store original methods for cleanup
|
|
3862
|
+
this.originalPushState = history.pushState;
|
|
3863
|
+
this.originalReplaceState = history.replaceState;
|
|
2690
3864
|
const self = this;
|
|
2691
3865
|
// Override pushState
|
|
2692
3866
|
history.pushState = function (...args) {
|
|
2693
|
-
originalPushState.apply(history, args);
|
|
3867
|
+
self.originalPushState.apply(history, args);
|
|
2694
3868
|
setTimeout(() => {
|
|
3869
|
+
// Clear attribution cache to capture fresh URL params
|
|
3870
|
+
self.attribution.clearCache();
|
|
2695
3871
|
self.page();
|
|
2696
3872
|
}, 0);
|
|
2697
3873
|
};
|
|
2698
3874
|
// Override replaceState
|
|
2699
3875
|
history.replaceState = function (...args) {
|
|
2700
|
-
originalReplaceState.apply(history, args);
|
|
3876
|
+
self.originalReplaceState.apply(history, args);
|
|
2701
3877
|
setTimeout(() => {
|
|
3878
|
+
// Clear attribution cache to capture fresh URL params
|
|
3879
|
+
self.attribution.clearCache();
|
|
2702
3880
|
self.page();
|
|
2703
3881
|
}, 0);
|
|
2704
3882
|
};
|
|
2705
3883
|
// Listen for popstate
|
|
2706
|
-
|
|
3884
|
+
this.popstateHandler = () => {
|
|
2707
3885
|
setTimeout(() => {
|
|
2708
|
-
|
|
3886
|
+
// Clear attribution cache to capture fresh URL params
|
|
3887
|
+
self.attribution.clearCache();
|
|
3888
|
+
self.page();
|
|
2709
3889
|
}, 0);
|
|
2710
|
-
}
|
|
3890
|
+
};
|
|
3891
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
2711
3892
|
// Listen for hashchange
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
3893
|
+
this.hashchangeHandler = () => {
|
|
3894
|
+
// Clear attribution cache to capture fresh URL params
|
|
3895
|
+
self.attribution.clearCache();
|
|
3896
|
+
self.page();
|
|
3897
|
+
};
|
|
3898
|
+
window.addEventListener('hashchange', this.hashchangeHandler);
|
|
2715
3899
|
}
|
|
2716
3900
|
/**
|
|
2717
3901
|
* Setup page unload handler
|
|
@@ -2818,6 +4002,20 @@ class Datalyr {
|
|
|
2818
4002
|
* Destroy the SDK instance and cleanup resources
|
|
2819
4003
|
*/
|
|
2820
4004
|
destroy() {
|
|
4005
|
+
// Restore original history methods (Issue #15)
|
|
4006
|
+
if (this.originalPushState) {
|
|
4007
|
+
history.pushState = this.originalPushState;
|
|
4008
|
+
}
|
|
4009
|
+
if (this.originalReplaceState) {
|
|
4010
|
+
history.replaceState = this.originalReplaceState;
|
|
4011
|
+
}
|
|
4012
|
+
// Remove event listeners (Issue #15)
|
|
4013
|
+
if (this.popstateHandler) {
|
|
4014
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
4015
|
+
}
|
|
4016
|
+
if (this.hashchangeHandler) {
|
|
4017
|
+
window.removeEventListener('hashchange', this.hashchangeHandler);
|
|
4018
|
+
}
|
|
2821
4019
|
// Clean up queue
|
|
2822
4020
|
if (this.queue) {
|
|
2823
4021
|
this.queue.destroy();
|
|
@@ -2826,6 +4024,16 @@ class Datalyr {
|
|
|
2826
4024
|
if (this.session) {
|
|
2827
4025
|
this.session.destroy();
|
|
2828
4026
|
}
|
|
4027
|
+
// FIXED (ISSUE-02): Clean up sandboxed iframes to prevent memory leaks
|
|
4028
|
+
if (this.container) {
|
|
4029
|
+
this.container.cleanupAllIframes();
|
|
4030
|
+
}
|
|
4031
|
+
// Clean up auto-identify
|
|
4032
|
+
if (this.autoIdentify) {
|
|
4033
|
+
this.autoIdentify.destroy();
|
|
4034
|
+
}
|
|
4035
|
+
// SEC-03 Fix: Clean up encryption keys
|
|
4036
|
+
dataEncryption.destroy();
|
|
2829
4037
|
// Clear any remaining data
|
|
2830
4038
|
this.superProperties = {};
|
|
2831
4039
|
this.userProperties = {};
|