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