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