cryptique-sdk 1.2.19 → 1.2.21
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/lib/cjs/index.js +341 -582
- package/lib/esm/index.js +341 -582
- package/lib/umd/index.js +341 -582
- package/package.json +1 -1
package/lib/umd/index.js
CHANGED
|
@@ -96,8 +96,8 @@
|
|
|
96
96
|
|
|
97
97
|
// Geolocation API
|
|
98
98
|
GEOLOCATION: {
|
|
99
|
-
PRIMARY_URL: "https://ipinfo.io/json?token=
|
|
100
|
-
BACKUP_URL: "https://ipinfo.io/json?token=
|
|
99
|
+
PRIMARY_URL: "https://ipinfo.io/json?token=3a0c034aefbef8",
|
|
100
|
+
BACKUP_URL: "https://ipinfo.io/json?token=3a0c034aefbef8&http=1.1",
|
|
101
101
|
TIMEOUT_MS: 5000 // 5 second timeout
|
|
102
102
|
},
|
|
103
103
|
|
|
@@ -1143,7 +1143,6 @@
|
|
|
1143
1143
|
isFirstPage: true,
|
|
1144
1144
|
|
|
1145
1145
|
// JSONB fields
|
|
1146
|
-
interactions: {},
|
|
1147
1146
|
visited_pages: [],
|
|
1148
1147
|
|
|
1149
1148
|
// Also stored in locationData object for structured access (backward compatibility)
|
|
@@ -2088,7 +2087,6 @@
|
|
|
2088
2087
|
if (sessionData.utm_id === undefined) sessionData.utm_id = null;
|
|
2089
2088
|
|
|
2090
2089
|
// JSONB fields
|
|
2091
|
-
if (!sessionData.interactions) sessionData.interactions = {};
|
|
2092
2090
|
if (!sessionData.visited_pages) sessionData.visited_pages = [];
|
|
2093
2091
|
|
|
2094
2092
|
// Internal tracking
|
|
@@ -2153,8 +2151,7 @@
|
|
|
2153
2151
|
// UTM fields (already snake_case, no mapping needed)
|
|
2154
2152
|
|
|
2155
2153
|
// JSONB fields
|
|
2156
|
-
'visited_pages': 'visited_pages'
|
|
2157
|
-
'interactions': 'interactions' // Already snake_case
|
|
2154
|
+
'visited_pages': 'visited_pages' // Already snake_case
|
|
2158
2155
|
};
|
|
2159
2156
|
|
|
2160
2157
|
return fieldMap[internalName] || this.toSnakeCase(internalName);
|
|
@@ -3198,333 +3195,24 @@
|
|
|
3198
3195
|
}
|
|
3199
3196
|
};
|
|
3200
3197
|
|
|
3201
|
-
//
|
|
3202
|
-
//
|
|
3203
|
-
//
|
|
3204
|
-
//
|
|
3205
|
-
// Handles all user interactions (clicks, forms, scroll, hover, etc.)
|
|
3206
|
-
//
|
|
3207
|
-
// KEY PRINCIPLES:
|
|
3208
|
-
// 1. All interactions stored in categorized structure
|
|
3209
|
-
// 2. Automatic timestamp addition
|
|
3210
|
-
// 3. Total interactions count maintained
|
|
3211
|
-
// 4. Chronological sorting for backend
|
|
3212
|
-
// 5. Immediate session storage updates
|
|
3213
|
-
// 6. Prevents duplicate interactions
|
|
3214
|
-
//
|
|
3215
|
-
// BENEFITS:
|
|
3216
|
-
// - Centralized interaction tracking
|
|
3217
|
-
// - Consistent data structure
|
|
3218
|
-
// - Single place to fix interaction bugs
|
|
3219
|
-
// - Prevents repeating interactions
|
|
3220
|
-
// ============================================================================
|
|
3198
|
+
// SECTION 10: INTERACTION MANAGEMENT — REMOVED
|
|
3199
|
+
// Interactions JSONB tracking has been removed from sessions.
|
|
3200
|
+
// Important signals (clicks, errors, etc.) are captured as individual
|
|
3201
|
+
// queryable auto events in the events table instead.
|
|
3221
3202
|
|
|
3222
|
-
|
|
3223
|
-
* InteractionManager - Interaction tracking and management
|
|
3224
|
-
*
|
|
3225
|
-
* This object handles all user interaction tracking, storage, and management
|
|
3226
|
-
*/
|
|
3203
|
+
// No-op stub — all methods removed with interactions feature.
|
|
3227
3204
|
const InteractionManager = {
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
'hoverEvents',
|
|
3240
|
-
'formSubmissions',
|
|
3241
|
-
'fieldChanges',
|
|
3242
|
-
'validationErrors',
|
|
3243
|
-
'keyboardEvents',
|
|
3244
|
-
'copyPasteEvents',
|
|
3245
|
-
'contextMenuEvents',
|
|
3246
|
-
'dragDropEvents',
|
|
3247
|
-
'touchEvents',
|
|
3248
|
-
'windowEvents',
|
|
3249
|
-
'performanceEvents',
|
|
3250
|
-
'errorEvents',
|
|
3251
|
-
'networkEvents',
|
|
3252
|
-
'formAnalytics'
|
|
3253
|
-
],
|
|
3254
|
-
|
|
3255
|
-
/**
|
|
3256
|
-
* Initialize interactions structure
|
|
3257
|
-
*
|
|
3258
|
-
* Creates empty interactions object with all categories
|
|
3259
|
-
*/
|
|
3260
|
-
initialize() {
|
|
3261
|
-
if (!sessionData.interactions || Object.keys(sessionData.interactions).length === 0) {
|
|
3262
|
-
sessionData.interactions = {
|
|
3263
|
-
totalInteractions: 0,
|
|
3264
|
-
clicks: [],
|
|
3265
|
-
formInteractions: [],
|
|
3266
|
-
mediaInteractions: [],
|
|
3267
|
-
scrollEvents: [],
|
|
3268
|
-
focusEvents: [],
|
|
3269
|
-
hoverEvents: [],
|
|
3270
|
-
formSubmissions: [],
|
|
3271
|
-
fieldChanges: [],
|
|
3272
|
-
validationErrors: [],
|
|
3273
|
-
keyboardEvents: [],
|
|
3274
|
-
copyPasteEvents: [],
|
|
3275
|
-
contextMenuEvents: [],
|
|
3276
|
-
dragDropEvents: [],
|
|
3277
|
-
touchEvents: [],
|
|
3278
|
-
windowEvents: [],
|
|
3279
|
-
performanceEvents: [],
|
|
3280
|
-
errorEvents: [],
|
|
3281
|
-
networkEvents: [],
|
|
3282
|
-
formAnalytics: []
|
|
3283
|
-
};
|
|
3284
|
-
}
|
|
3285
|
-
},
|
|
3286
|
-
|
|
3287
|
-
/**
|
|
3288
|
-
* Add interaction to appropriate category
|
|
3289
|
-
*
|
|
3290
|
-
* Logic:
|
|
3291
|
-
* 1. Ensures interactions are initialized
|
|
3292
|
-
* 2. Adds timestamp if not provided
|
|
3293
|
-
* 3. Adds to specific category array
|
|
3294
|
-
* 4. Updates total interactions count
|
|
3295
|
-
* 5. Updates session activity
|
|
3296
|
-
* 6. Immediately updates session storage
|
|
3297
|
-
*
|
|
3298
|
-
* Parameters:
|
|
3299
|
-
* - category: Interaction category (e.g., 'clicks', 'formInteractions')
|
|
3300
|
-
* - interactionData: Interaction data object
|
|
3301
|
-
*/
|
|
3302
|
-
add(category, interactionData) {
|
|
3303
|
-
// Ensure interactions are initialized
|
|
3304
|
-
this.initialize();
|
|
3305
|
-
|
|
3306
|
-
// Validate category
|
|
3307
|
-
if (!this.CATEGORIES.includes(category)) {
|
|
3308
|
-
return;
|
|
3309
|
-
}
|
|
3310
|
-
|
|
3311
|
-
// Add timestamp if not provided
|
|
3312
|
-
if (!interactionData.timestamp) {
|
|
3313
|
-
interactionData.timestamp = nowIso();
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
// Add to specific category
|
|
3317
|
-
if (sessionData.interactions[category]) {
|
|
3318
|
-
sessionData.interactions[category].push(interactionData);
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
// Update total interactions count
|
|
3322
|
-
this.updateTotalCount();
|
|
3323
|
-
|
|
3324
|
-
// Update last activity
|
|
3325
|
-
sessionData.lastActivity = Date.now();
|
|
3326
|
-
|
|
3327
|
-
// Immediately update session storage
|
|
3328
|
-
this.updateSessionStorage();
|
|
3329
|
-
},
|
|
3330
|
-
|
|
3331
|
-
/**
|
|
3332
|
-
* Update total interactions count
|
|
3333
|
-
*
|
|
3334
|
-
* Calculates total from all category arrays
|
|
3335
|
-
*/
|
|
3336
|
-
updateTotalCount() {
|
|
3337
|
-
if (!sessionData.interactions) return;
|
|
3338
|
-
|
|
3339
|
-
let total = 0;
|
|
3340
|
-
for (const category of this.CATEGORIES) {
|
|
3341
|
-
if (sessionData.interactions[category] && Array.isArray(sessionData.interactions[category])) {
|
|
3342
|
-
total += sessionData.interactions[category].length;
|
|
3343
|
-
}
|
|
3344
|
-
}
|
|
3345
|
-
|
|
3346
|
-
sessionData.interactions.totalInteractions = total;
|
|
3347
|
-
},
|
|
3348
|
-
|
|
3349
|
-
/**
|
|
3350
|
-
* Update session storage with interactions
|
|
3351
|
-
*
|
|
3352
|
-
* Immediately syncs interactions to session storage
|
|
3353
|
-
*/
|
|
3354
|
-
updateSessionStorage() {
|
|
3355
|
-
try {
|
|
3356
|
-
const storedSession = StorageManager.loadSession();
|
|
3357
|
-
if (storedSession && storedSession.sessionData) {
|
|
3358
|
-
storedSession.sessionData.interactions = sessionData.interactions;
|
|
3359
|
-
StorageManager.saveSession(storedSession);
|
|
3360
|
-
}
|
|
3361
|
-
} catch (error) {
|
|
3362
|
-
}
|
|
3363
|
-
},
|
|
3364
|
-
|
|
3365
|
-
/**
|
|
3366
|
-
* Get all interactions sorted chronologically
|
|
3367
|
-
*
|
|
3368
|
-
* Collects all interactions from all categories, adds category identifiers,
|
|
3369
|
-
* and sorts by timestamp
|
|
3370
|
-
*
|
|
3371
|
-
* Returns: Array of interactions sorted by timestamp
|
|
3372
|
-
*/
|
|
3373
|
-
getChronological() {
|
|
3374
|
-
if (!sessionData.interactions) {
|
|
3375
|
-
return [];
|
|
3376
|
-
}
|
|
3377
|
-
|
|
3378
|
-
const allInteractions = [];
|
|
3379
|
-
|
|
3380
|
-
// Map of category to category identifier
|
|
3381
|
-
const categoryMap = {
|
|
3382
|
-
'clicks': { category: 'click', originalCategory: 'clicks' },
|
|
3383
|
-
'formInteractions': { category: 'formInteraction', originalCategory: 'formInteractions' },
|
|
3384
|
-
'mediaInteractions': { category: 'mediaInteraction', originalCategory: 'mediaInteractions' },
|
|
3385
|
-
'scrollEvents': { category: 'scrollEvent', originalCategory: 'scrollEvents' },
|
|
3386
|
-
'focusEvents': { category: 'focusEvent', originalCategory: 'focusEvents' },
|
|
3387
|
-
'hoverEvents': { category: 'hoverEvent', originalCategory: 'hoverEvents' },
|
|
3388
|
-
'formSubmissions': { category: 'formSubmission', originalCategory: 'formSubmissions' },
|
|
3389
|
-
'fieldChanges': { category: 'fieldChange', originalCategory: 'fieldChanges' },
|
|
3390
|
-
'validationErrors': { category: 'validationError', originalCategory: 'validationErrors' },
|
|
3391
|
-
'keyboardEvents': { category: 'keyboardEvent', originalCategory: 'keyboardEvents' },
|
|
3392
|
-
'copyPasteEvents': { category: 'copyPasteEvent', originalCategory: 'copyPasteEvents' },
|
|
3393
|
-
'contextMenuEvents': { category: 'contextMenuEvent', originalCategory: 'contextMenuEvents' },
|
|
3394
|
-
'dragDropEvents': { category: 'dragDropEvent', originalCategory: 'dragDropEvents' },
|
|
3395
|
-
'touchEvents': { category: 'touchEvent', originalCategory: 'touchEvents' },
|
|
3396
|
-
'windowEvents': { category: 'windowEvent', originalCategory: 'windowEvents' },
|
|
3397
|
-
'performanceEvents': { category: 'performanceEvent', originalCategory: 'performanceEvents' },
|
|
3398
|
-
'errorEvents': { category: 'errorEvent', originalCategory: 'errorEvents' },
|
|
3399
|
-
'networkEvents': { category: 'networkEvent', originalCategory: 'networkEvents' },
|
|
3400
|
-
'formAnalytics': { category: 'formAnalytic', originalCategory: 'formAnalytics' }
|
|
3401
|
-
};
|
|
3402
|
-
|
|
3403
|
-
// Collect all interactions from all categories
|
|
3404
|
-
for (const category of this.CATEGORIES) {
|
|
3405
|
-
if (sessionData.interactions[category] && Array.isArray(sessionData.interactions[category])) {
|
|
3406
|
-
const categoryInfo = categoryMap[category];
|
|
3407
|
-
sessionData.interactions[category].forEach(interaction => {
|
|
3408
|
-
allInteractions.push({
|
|
3409
|
-
...interaction,
|
|
3410
|
-
category: categoryInfo.category,
|
|
3411
|
-
originalCategory: categoryInfo.originalCategory
|
|
3412
|
-
});
|
|
3413
|
-
});
|
|
3414
|
-
}
|
|
3415
|
-
}
|
|
3416
|
-
|
|
3417
|
-
// Sort by timestamp (chronological order)
|
|
3418
|
-
allInteractions.sort((a, b) => {
|
|
3419
|
-
const timestampA = new Date(a.timestamp || 0);
|
|
3420
|
-
const timestampB = new Date(b.timestamp || 0);
|
|
3421
|
-
return timestampA - timestampB;
|
|
3422
|
-
});
|
|
3423
|
-
|
|
3424
|
-
return allInteractions;
|
|
3425
|
-
},
|
|
3426
|
-
|
|
3427
|
-
/**
|
|
3428
|
-
* Get chronological interactions for backend
|
|
3429
|
-
*
|
|
3430
|
-
* Returns both chronological array and categorized structure
|
|
3431
|
-
*
|
|
3432
|
-
* Returns: Object with chronological and categorized interactions
|
|
3433
|
-
*/
|
|
3434
|
-
getChronologicalForBackend() {
|
|
3435
|
-
const chronological = this.getChronological();
|
|
3436
|
-
|
|
3437
|
-
return {
|
|
3438
|
-
chronological: chronological,
|
|
3439
|
-
categorized: sessionData.interactions
|
|
3440
|
-
};
|
|
3441
|
-
},
|
|
3442
|
-
|
|
3443
|
-
/**
|
|
3444
|
-
* Deduplicate interactions
|
|
3445
|
-
*
|
|
3446
|
-
* Removes duplicate interactions based on timestamp, category, and key fields
|
|
3447
|
-
*
|
|
3448
|
-
* Logic:
|
|
3449
|
-
* - Compares interactions by timestamp, category, and element identifier
|
|
3450
|
-
* - Keeps first occurrence of duplicates
|
|
3451
|
-
* - Updates total count after deduplication
|
|
3452
|
-
*/
|
|
3453
|
-
deduplicate() {
|
|
3454
|
-
if (!sessionData.interactions) return;
|
|
3455
|
-
|
|
3456
|
-
const seen = new Set();
|
|
3457
|
-
|
|
3458
|
-
for (const category of this.CATEGORIES) {
|
|
3459
|
-
if (sessionData.interactions[category] && Array.isArray(sessionData.interactions[category])) {
|
|
3460
|
-
sessionData.interactions[category] = sessionData.interactions[category].filter(interaction => {
|
|
3461
|
-
// Create unique key from timestamp, category, and element identifier
|
|
3462
|
-
const elementId = interaction.elementId || interaction.target || interaction.selector || '';
|
|
3463
|
-
const key = `${interaction.timestamp}_${category}_${elementId}`;
|
|
3464
|
-
|
|
3465
|
-
if (seen.has(key)) {
|
|
3466
|
-
return false; // Duplicate, remove
|
|
3467
|
-
}
|
|
3468
|
-
|
|
3469
|
-
seen.add(key);
|
|
3470
|
-
return true; // Keep
|
|
3471
|
-
});
|
|
3472
|
-
}
|
|
3473
|
-
}
|
|
3474
|
-
|
|
3475
|
-
// Update total count after deduplication
|
|
3476
|
-
this.updateTotalCount();
|
|
3477
|
-
|
|
3478
|
-
// Update session storage
|
|
3479
|
-
this.updateSessionStorage();
|
|
3480
|
-
},
|
|
3481
|
-
|
|
3482
|
-
/**
|
|
3483
|
-
* Get interactions by category
|
|
3484
|
-
*
|
|
3485
|
-
* Returns all interactions for a specific category
|
|
3486
|
-
*
|
|
3487
|
-
* Parameters: category - Interaction category name
|
|
3488
|
-
* Returns: Array of interactions for that category
|
|
3489
|
-
*/
|
|
3490
|
-
getByCategory(category) {
|
|
3491
|
-
if (!sessionData.interactions) {
|
|
3492
|
-
return [];
|
|
3493
|
-
}
|
|
3494
|
-
|
|
3495
|
-
if (!this.CATEGORIES.includes(category)) {
|
|
3496
|
-
return [];
|
|
3497
|
-
}
|
|
3498
|
-
|
|
3499
|
-
return sessionData.interactions[category] || [];
|
|
3500
|
-
},
|
|
3501
|
-
|
|
3502
|
-
/**
|
|
3503
|
-
* Get total interactions count
|
|
3504
|
-
*
|
|
3505
|
-
* Returns total number of interactions across all categories
|
|
3506
|
-
*/
|
|
3507
|
-
getTotalCount() {
|
|
3508
|
-
if (!sessionData.interactions) {
|
|
3509
|
-
return 0;
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
return sessionData.interactions.totalInteractions || 0;
|
|
3513
|
-
},
|
|
3514
|
-
|
|
3515
|
-
/**
|
|
3516
|
-
* Clear all interactions
|
|
3517
|
-
*
|
|
3518
|
-
* Resets interactions object to empty state
|
|
3519
|
-
*/
|
|
3520
|
-
clear() {
|
|
3521
|
-
this.initialize();
|
|
3522
|
-
for (const category of this.CATEGORIES) {
|
|
3523
|
-
sessionData.interactions[category] = [];
|
|
3524
|
-
}
|
|
3525
|
-
sessionData.interactions.totalInteractions = 0;
|
|
3526
|
-
this.updateSessionStorage();
|
|
3527
|
-
}
|
|
3205
|
+
CATEGORIES: [],
|
|
3206
|
+
initialize() {},
|
|
3207
|
+
add() {},
|
|
3208
|
+
updateTotalCount() {},
|
|
3209
|
+
updateSessionStorage() {},
|
|
3210
|
+
getChronological() { return []; },
|
|
3211
|
+
getChronologicalForBackend() { return { chronological: [], categorized: {} }; },
|
|
3212
|
+
deduplicate() {},
|
|
3213
|
+
getByCategory() { return []; },
|
|
3214
|
+
getTotalCount() { return 0; },
|
|
3215
|
+
clear() {}
|
|
3528
3216
|
};
|
|
3529
3217
|
|
|
3530
3218
|
// ============================================================================
|
|
@@ -3918,6 +3606,8 @@
|
|
|
3918
3606
|
|
|
3919
3607
|
history.pushState = function(...args) {
|
|
3920
3608
|
originalPushState.apply(history, args);
|
|
3609
|
+
// Notify page_summary exit-type tracker that an SPA navigation occurred
|
|
3610
|
+
try { window.dispatchEvent(new CustomEvent('cq:spa-navigation')); } catch (_) {}
|
|
3921
3611
|
// Use requestAnimationFrame for better timing with React Router
|
|
3922
3612
|
requestAnimationFrame(() => {
|
|
3923
3613
|
self.track();
|
|
@@ -3926,6 +3616,8 @@
|
|
|
3926
3616
|
|
|
3927
3617
|
history.replaceState = function(...args) {
|
|
3928
3618
|
originalReplaceState.apply(history, args);
|
|
3619
|
+
// Notify page_summary exit-type tracker (replaceState = SPA redirect)
|
|
3620
|
+
try { window.dispatchEvent(new CustomEvent('cq:spa-navigation')); } catch (_) {}
|
|
3929
3621
|
// Use requestAnimationFrame for better timing with React Router
|
|
3930
3622
|
requestAnimationFrame(() => {
|
|
3931
3623
|
self.track();
|
|
@@ -3988,7 +3680,6 @@
|
|
|
3988
3680
|
// Ensure arrays exist
|
|
3989
3681
|
s.pageVisits = s.pageVisits || [];
|
|
3990
3682
|
s.visited_pages = s.visited_pages || [];
|
|
3991
|
-
s.interactions = s.interactions || { totalInteractions: 0 };
|
|
3992
3683
|
|
|
3993
3684
|
// Normalize timestamps to ISO strings
|
|
3994
3685
|
if (s.startTime && !(typeof s.startTime === 'string')) {
|
|
@@ -4043,171 +3734,8 @@
|
|
|
4043
3734
|
// First normalize the shape
|
|
4044
3735
|
sourceData = this.normalizeShape(sourceData);
|
|
4045
3736
|
|
|
4046
|
-
// Get
|
|
4047
|
-
const chronologicalInteractions = InteractionManager.getChronologicalForBackend();
|
|
4048
|
-
|
|
4049
|
-
// Get page visits and session-level interactions
|
|
3737
|
+
// Get page visits
|
|
4050
3738
|
const pageVisits = sourceData.visited_pages || sourceData.pageVisits || sourceData.visitedPages || [];
|
|
4051
|
-
const sessionInteractions = sourceData.interactions || {};
|
|
4052
|
-
|
|
4053
|
-
// CRITICAL FIX: Distribute session-level interactions to appropriate pages based on path
|
|
4054
|
-
const interactionTypes = [
|
|
4055
|
-
'clicks', 'mediaInteractions', 'contextMenuEvents', 'windowEvents',
|
|
4056
|
-
'formInteractions', 'scrollEvents', 'focusEvents', 'hoverEvents',
|
|
4057
|
-
'formSubmissions', 'fieldChanges',
|
|
4058
|
-
'keyboardEvents', 'copyPasteEvents', 'dragDropEvents',
|
|
4059
|
-
'touchEvents', 'formAnalytics'
|
|
4060
|
-
// Note: validationErrors, performanceEvents, errorEvents, networkEvents are no longer
|
|
4061
|
-
// stored in session interactions — they are emitted as individual queryable auto events
|
|
4062
|
-
// (form_validation_error, page_performance, js_error, network_error)
|
|
4063
|
-
];
|
|
4064
|
-
|
|
4065
|
-
// Helper to normalize path for matching
|
|
4066
|
-
const normalizePathForMatching = (path) => {
|
|
4067
|
-
if (!path) return '/';
|
|
4068
|
-
// Remove query params and hash
|
|
4069
|
-
const cleanPath = path.split('?')[0].split('#')[0];
|
|
4070
|
-
return cleanPath || '/';
|
|
4071
|
-
};
|
|
4072
|
-
|
|
4073
|
-
// Get session start time in milliseconds for alignment
|
|
4074
|
-
const sessionStartTime = sourceData.start_time || sourceData.startTime;
|
|
4075
|
-
const sessionStartMs = sessionStartTime
|
|
4076
|
-
? (typeof sessionStartTime === 'string' ? new Date(sessionStartTime).getTime() : sessionStartTime)
|
|
4077
|
-
: Date.now();
|
|
4078
|
-
|
|
4079
|
-
// Distribute interactions to pages and fix mount/unmount times
|
|
4080
|
-
// CRITICAL: Create copies to avoid mutating original objects
|
|
4081
|
-
const pagesWithInteractions = pageVisits.map((pageVisit, index) => {
|
|
4082
|
-
// Create a copy of the page visit to avoid mutating the original
|
|
4083
|
-
const pageVisitCopy = { ...pageVisit };
|
|
4084
|
-
const pagePath = normalizePathForMatching(pageVisitCopy.path || pageVisitCopy.url);
|
|
4085
|
-
const isFirstPage = index === 0 || pageVisitCopy.isEntry === true;
|
|
4086
|
-
|
|
4087
|
-
// CRITICAL FIX: If unmountTime is null, set it to next page's mountTime
|
|
4088
|
-
if (!pageVisitCopy.unmountTime && index < pageVisits.length - 1) {
|
|
4089
|
-
const nextPage = pageVisits[index + 1];
|
|
4090
|
-
if (nextPage && nextPage.mountTime) {
|
|
4091
|
-
pageVisitCopy.unmountTime = typeof nextPage.mountTime === 'number'
|
|
4092
|
-
? nextPage.mountTime
|
|
4093
|
-
: new Date(nextPage.mountTime).getTime();
|
|
4094
|
-
// Recalculate duration
|
|
4095
|
-
if (pageVisitCopy.mountTime) {
|
|
4096
|
-
const mountMs = typeof pageVisitCopy.mountTime === 'number'
|
|
4097
|
-
? pageVisitCopy.mountTime
|
|
4098
|
-
: new Date(pageVisitCopy.mountTime).getTime();
|
|
4099
|
-
pageVisitCopy.duration = Math.floor((pageVisitCopy.unmountTime - mountMs) / 1000);
|
|
4100
|
-
}
|
|
4101
|
-
}
|
|
4102
|
-
}
|
|
4103
|
-
|
|
4104
|
-
// If still no unmountTime and this is the last page, set to session endTime
|
|
4105
|
-
if (!pageVisitCopy.unmountTime && index === pageVisits.length - 1) {
|
|
4106
|
-
const endTime = sourceData.end_time || sourceData.endTime;
|
|
4107
|
-
if (endTime) {
|
|
4108
|
-
pageVisitCopy.unmountTime = typeof endTime === 'number'
|
|
4109
|
-
? endTime
|
|
4110
|
-
: new Date(endTime).getTime();
|
|
4111
|
-
// Recalculate duration
|
|
4112
|
-
if (pageVisitCopy.mountTime) {
|
|
4113
|
-
const mountMs = typeof pageVisitCopy.mountTime === 'number'
|
|
4114
|
-
? pageVisitCopy.mountTime
|
|
4115
|
-
: new Date(pageVisitCopy.mountTime).getTime();
|
|
4116
|
-
pageVisitCopy.duration = Math.floor((pageVisitCopy.unmountTime - mountMs) / 1000);
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
}
|
|
4120
|
-
|
|
4121
|
-
// FIX: Align first page mountTime with session start_time (only in copy, not original)
|
|
4122
|
-
if (isFirstPage && pageVisitCopy.mountTime) {
|
|
4123
|
-
const mountTimeMs = typeof pageVisitCopy.mountTime === 'number'
|
|
4124
|
-
? pageVisitCopy.mountTime
|
|
4125
|
-
: new Date(pageVisitCopy.mountTime).getTime();
|
|
4126
|
-
|
|
4127
|
-
// If mountTime is more than 5 minutes off from session start, align it
|
|
4128
|
-
const timeDiff = Math.abs(mountTimeMs - sessionStartMs);
|
|
4129
|
-
if (timeDiff > 5 * 60 * 1000) { // More than 5 minutes difference
|
|
4130
|
-
pageVisitCopy.mountTime = sessionStartMs;
|
|
4131
|
-
// Recalculate duration if unmountTime exists
|
|
4132
|
-
if (pageVisitCopy.unmountTime) {
|
|
4133
|
-
const unmountTimeMs = typeof pageVisitCopy.unmountTime === 'number'
|
|
4134
|
-
? pageVisitCopy.unmountTime
|
|
4135
|
-
: new Date(pageVisitCopy.unmountTime).getTime();
|
|
4136
|
-
pageVisitCopy.duration = Math.floor((unmountTimeMs - sessionStartMs) / 1000);
|
|
4137
|
-
}
|
|
4138
|
-
}
|
|
4139
|
-
}
|
|
4140
|
-
|
|
4141
|
-
// Ensure mountTime and unmountTime are numbers (milliseconds) for consistency (in copy only)
|
|
4142
|
-
if (pageVisitCopy.mountTime && typeof pageVisitCopy.mountTime !== 'number') {
|
|
4143
|
-
pageVisitCopy.mountTime = new Date(pageVisitCopy.mountTime).getTime();
|
|
4144
|
-
}
|
|
4145
|
-
if (pageVisitCopy.unmountTime && typeof pageVisitCopy.unmountTime !== 'number') {
|
|
4146
|
-
pageVisitCopy.unmountTime = new Date(pageVisitCopy.unmountTime).getTime();
|
|
4147
|
-
}
|
|
4148
|
-
|
|
4149
|
-
// Initialize interactions object for this page if it doesn't exist (in copy)
|
|
4150
|
-
if (!pageVisitCopy.interactions) {
|
|
4151
|
-
pageVisitCopy.interactions = {
|
|
4152
|
-
totalInteractions: 0,
|
|
4153
|
-
clicks: [],
|
|
4154
|
-
mediaInteractions: [],
|
|
4155
|
-
contextMenuEvents: [],
|
|
4156
|
-
windowEvents: [],
|
|
4157
|
-
formInteractions: [],
|
|
4158
|
-
scrollEvents: [],
|
|
4159
|
-
focusEvents: [],
|
|
4160
|
-
hoverEvents: [],
|
|
4161
|
-
formSubmissions: [],
|
|
4162
|
-
fieldChanges: [],
|
|
4163
|
-
validationErrors: [],
|
|
4164
|
-
keyboardEvents: [],
|
|
4165
|
-
copyPasteEvents: [],
|
|
4166
|
-
dragDropEvents: [],
|
|
4167
|
-
touchEvents: [],
|
|
4168
|
-
performanceEvents: [],
|
|
4169
|
-
errorEvents: [],
|
|
4170
|
-
networkEvents: [],
|
|
4171
|
-
formAnalytics: []
|
|
4172
|
-
};
|
|
4173
|
-
}
|
|
4174
|
-
|
|
4175
|
-
// Distribute interactions from session-level to this page based on path matching (in copy)
|
|
4176
|
-
interactionTypes.forEach(type => {
|
|
4177
|
-
if (Array.isArray(sessionInteractions[type])) {
|
|
4178
|
-
sessionInteractions[type].forEach(interaction => {
|
|
4179
|
-
const interactionPath = normalizePathForMatching(interaction.path);
|
|
4180
|
-
if (interactionPath === pagePath) {
|
|
4181
|
-
// Add interaction to this page (avoid duplicates)
|
|
4182
|
-
const exists = pageVisitCopy.interactions[type].some(existing => {
|
|
4183
|
-
// Check for duplicates based on timestamp and key properties
|
|
4184
|
-
if (existing.timestamp && interaction.timestamp) {
|
|
4185
|
-
return existing.timestamp === interaction.timestamp;
|
|
4186
|
-
}
|
|
4187
|
-
// For clicks, check element properties
|
|
4188
|
-
if (type === 'clicks' && existing.element && interaction.element) {
|
|
4189
|
-
return existing.element.id === interaction.element.id &&
|
|
4190
|
-
existing.element.tagName === interaction.element.tagName;
|
|
4191
|
-
}
|
|
4192
|
-
return false;
|
|
4193
|
-
});
|
|
4194
|
-
|
|
4195
|
-
if (!exists) {
|
|
4196
|
-
pageVisitCopy.interactions[type].push(interaction);
|
|
4197
|
-
}
|
|
4198
|
-
}
|
|
4199
|
-
});
|
|
4200
|
-
}
|
|
4201
|
-
});
|
|
4202
|
-
|
|
4203
|
-
// Calculate totalInteractions for this page (in copy)
|
|
4204
|
-
const total = interactionTypes.reduce((sum, type) => {
|
|
4205
|
-
return sum + (pageVisitCopy.interactions[type]?.length || 0);
|
|
4206
|
-
}, 0);
|
|
4207
|
-
pageVisitCopy.interactions.totalInteractions = total;
|
|
4208
|
-
|
|
4209
|
-
return pageVisitCopy;
|
|
4210
|
-
});
|
|
4211
3739
|
|
|
4212
3740
|
// Create transformed object - PostgreSQL snake_case format only
|
|
4213
3741
|
const transformed = {
|
|
@@ -4272,12 +3800,8 @@
|
|
|
4272
3800
|
utm_content: sourceData.utm_content || sourceData.utmData?.content || null,
|
|
4273
3801
|
utm_id: sourceData.utm_id || sourceData.utmData?.utm_id || null,
|
|
4274
3802
|
|
|
4275
|
-
// JSONB fields
|
|
4276
|
-
visited_pages:
|
|
4277
|
-
interactions: {
|
|
4278
|
-
...sessionInteractions,
|
|
4279
|
-
chronological: chronologicalInteractions.chronological || []
|
|
4280
|
-
},
|
|
3803
|
+
// JSONB fields
|
|
3804
|
+
visited_pages: pageVisits,
|
|
4281
3805
|
|
|
4282
3806
|
// Active tab time in ms (tab foreground time only, for active_time_seconds in DB)
|
|
4283
3807
|
active_time_ms: sourceData.active_time_ms || null
|
|
@@ -4945,12 +4469,6 @@
|
|
|
4945
4469
|
if (hoveredElements.has(elementId)) {
|
|
4946
4470
|
hoveredElements.delete(elementId);
|
|
4947
4471
|
|
|
4948
|
-
// Update hover duration in the interaction
|
|
4949
|
-
const interactions = InteractionManager.getByCategory('hoverEvents');
|
|
4950
|
-
const hoverEvent = interactions.find(h => h.elementId === elementId);
|
|
4951
|
-
if (hoverEvent) {
|
|
4952
|
-
hoverEvent.hoverDuration = Date.now() - new Date(hoverEvent.timestamp).getTime();
|
|
4953
|
-
}
|
|
4954
4472
|
}
|
|
4955
4473
|
}
|
|
4956
4474
|
});
|
|
@@ -5292,12 +4810,33 @@
|
|
|
5292
4810
|
: null;
|
|
5293
4811
|
} catch (e) {}
|
|
5294
4812
|
|
|
4813
|
+
// Collect top-3 slowest resources to pinpoint what is causing slow LCP/FCP.
|
|
4814
|
+
// Each entry: { name (URL), initiator_type, duration_ms, transfer_size_kb }
|
|
4815
|
+
// Only includes same-origin resources or entries where the timing is exposed
|
|
4816
|
+
// (cross-origin resources without Timing-Allow-Origin return duration = 0
|
|
4817
|
+
// and are excluded as they carry no actionable signal).
|
|
4818
|
+
let slowestResources = [];
|
|
4819
|
+
try {
|
|
4820
|
+
const resources = performance.getEntriesByType('resource');
|
|
4821
|
+
slowestResources = resources
|
|
4822
|
+
.filter(r => r.duration > 0) // exclude zero-duration cross-origin entries
|
|
4823
|
+
.sort((a, b) => b.duration - a.duration)
|
|
4824
|
+
.slice(0, 3)
|
|
4825
|
+
.map(r => ({
|
|
4826
|
+
name: r.name.split('?')[0].slice(-120), // trim query string + cap length
|
|
4827
|
+
initiator_type: r.initiatorType || 'other', // script, css, img, fetch, xmlhttprequest…
|
|
4828
|
+
duration_ms: Math.round(r.duration),
|
|
4829
|
+
transfer_size_kb: r.transferSize ? Math.round(r.transferSize / 1024) : null
|
|
4830
|
+
}));
|
|
4831
|
+
} catch (_) {}
|
|
4832
|
+
|
|
5295
4833
|
EventsManager.trackAutoEvent('page_performance', {
|
|
5296
|
-
lcp:
|
|
5297
|
-
cls:
|
|
5298
|
-
inp:
|
|
5299
|
-
fcp:
|
|
5300
|
-
ttfb:
|
|
4834
|
+
lcp: vitals.lcp,
|
|
4835
|
+
cls: vitals.cls,
|
|
4836
|
+
inp: vitals.inp,
|
|
4837
|
+
fcp: vitals.fcp,
|
|
4838
|
+
ttfb: vitals.ttfb,
|
|
4839
|
+
slowest_resources: slowestResources // [] when none available
|
|
5301
4840
|
});
|
|
5302
4841
|
}, 1500);
|
|
5303
4842
|
});
|
|
@@ -5307,54 +4846,108 @@
|
|
|
5307
4846
|
|
|
5308
4847
|
/**
|
|
5309
4848
|
* ErrorTracker - Tracks JavaScript errors
|
|
5310
|
-
* Emits as queryable auto events (js_error) instead of session-level JSONB
|
|
4849
|
+
* Emits as queryable auto events (js_error) instead of session-level JSONB.
|
|
4850
|
+
*
|
|
4851
|
+
* Improvements over original:
|
|
4852
|
+
* - error_class: extracts the constructor name (TypeError, ReferenceError, etc.)
|
|
4853
|
+
* so the AI can categorise errors without parsing message strings.
|
|
4854
|
+
* - Session-level deduplication: same error (same message + source + line)
|
|
4855
|
+
* fires at most once per 10 s to prevent event floods from looping errors.
|
|
4856
|
+
* - last_event_name: name of the most-recently tracked auto event, giving a
|
|
4857
|
+
* lightweight "what was the user doing just before the crash" signal.
|
|
5311
4858
|
*/
|
|
5312
4859
|
startErrorTracking() {
|
|
5313
4860
|
try {
|
|
4861
|
+
// Deduplication: hash → last-fired timestamp (ms)
|
|
4862
|
+
const recentErrors = new Map();
|
|
4863
|
+
const ERROR_DEDUPE_MS = 10000; // suppress repeats within 10 s
|
|
4864
|
+
|
|
4865
|
+
// Lightweight "preceding action" tracker — updated by trackAutoEvent callers
|
|
4866
|
+
// via a module-level variable so it's always current.
|
|
4867
|
+
if (!EventsManager._lastAutoEventName) {
|
|
4868
|
+
EventsManager._lastAutoEventName = '';
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
const dedupeKey = (msg, src, line) =>
|
|
4872
|
+
`${msg}|${src}|${line}`.slice(0, 200);
|
|
4873
|
+
|
|
4874
|
+
const shouldSuppress = (key) => {
|
|
4875
|
+
const last = recentErrors.get(key);
|
|
4876
|
+
if (!last) return false;
|
|
4877
|
+
return (Date.now() - last) < ERROR_DEDUPE_MS;
|
|
4878
|
+
};
|
|
4879
|
+
|
|
4880
|
+
const markSeen = (key) => recentErrors.set(key, Date.now());
|
|
4881
|
+
|
|
4882
|
+
// Extract class name from the error object's constructor, e.g. "TypeError"
|
|
4883
|
+
const getErrorClass = (errObj) => {
|
|
4884
|
+
if (!errObj) return 'Error';
|
|
4885
|
+
try { return errObj.constructor?.name || 'Error'; } catch (_) { return 'Error'; }
|
|
4886
|
+
};
|
|
4887
|
+
|
|
5314
4888
|
window.addEventListener('error', (event) => {
|
|
4889
|
+
const msg = event.message || '';
|
|
4890
|
+
const src = event.filename || '';
|
|
4891
|
+
const line = event.lineno || 0;
|
|
4892
|
+
const key = dedupeKey(msg, src, line);
|
|
4893
|
+
if (shouldSuppress(key)) return;
|
|
4894
|
+
markSeen(key);
|
|
4895
|
+
|
|
5315
4896
|
EventsManager.trackAutoEvent('js_error', {
|
|
5316
|
-
error_type:
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
4897
|
+
error_type: 'javascript_error',
|
|
4898
|
+
error_class: getErrorClass(event.error),
|
|
4899
|
+
message: msg,
|
|
4900
|
+
source: src,
|
|
4901
|
+
line: line,
|
|
4902
|
+
column: event.colno || 0,
|
|
4903
|
+
stack: event.error?.stack || '',
|
|
4904
|
+
last_event_name: EventsManager._lastAutoEventName || ''
|
|
5322
4905
|
});
|
|
5323
4906
|
});
|
|
5324
4907
|
|
|
5325
|
-
//
|
|
4908
|
+
// Unhandled promise rejections (fetch failures that aren't caught, async throws, etc.)
|
|
5326
4909
|
window.addEventListener('unhandledrejection', (event) => {
|
|
4910
|
+
const msg = event.reason?.message || String(event.reason || 'Unknown error');
|
|
4911
|
+
const key = dedupeKey(msg, '', 0);
|
|
4912
|
+
if (shouldSuppress(key)) return;
|
|
4913
|
+
markSeen(key);
|
|
4914
|
+
|
|
5327
4915
|
EventsManager.trackAutoEvent('js_error', {
|
|
5328
|
-
error_type:
|
|
5329
|
-
|
|
5330
|
-
|
|
4916
|
+
error_type: 'unhandled_promise_rejection',
|
|
4917
|
+
error_class: getErrorClass(event.reason),
|
|
4918
|
+
message: msg,
|
|
4919
|
+
stack: event.reason?.stack || String(event.reason || ''),
|
|
4920
|
+
last_event_name: EventsManager._lastAutoEventName || ''
|
|
5331
4921
|
});
|
|
5332
4922
|
});
|
|
5333
4923
|
} catch (error) {
|
|
5334
|
-
|
|
4924
|
+
}
|
|
5335
4925
|
},
|
|
5336
4926
|
|
|
5337
4927
|
/**
|
|
5338
|
-
* NetworkTracker - Tracks network errors (XHR
|
|
4928
|
+
* NetworkTracker - Tracks network errors (XHR + fetch)
|
|
5339
4929
|
*
|
|
5340
|
-
*
|
|
5341
|
-
*
|
|
5342
|
-
*
|
|
5343
|
-
*
|
|
5344
|
-
*
|
|
4930
|
+
* XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
|
|
4931
|
+
*
|
|
4932
|
+
* fetch: wraps window.fetch to track HTTP ≥ 400 responses and network
|
|
4933
|
+
* failures for same-origin requests made by the host application.
|
|
4934
|
+
* Third-party (cross-origin) fetches are passed through untouched to
|
|
4935
|
+
* minimise DevTools attribution noise. The SDK's own fetch calls via
|
|
4936
|
+
* APIClient.send() are already error-handled and are excluded by the
|
|
4937
|
+
* same-origin guard (they go to sdkapi.cryptique.io, not the app origin).
|
|
5345
4938
|
*/
|
|
5346
4939
|
startNetworkTracking() {
|
|
5347
4940
|
try {
|
|
5348
|
-
//
|
|
4941
|
+
// ── XHR: unchanged — only track SDK backend calls ──────────────────
|
|
5349
4942
|
const originalXHROpen = XMLHttpRequest.prototype.open;
|
|
5350
4943
|
const originalXHRSend = XMLHttpRequest.prototype.send;
|
|
5351
|
-
|
|
4944
|
+
|
|
5352
4945
|
XMLHttpRequest.prototype.open = function(method, url, ...args) {
|
|
5353
4946
|
this._cryptiqueMethod = method;
|
|
5354
4947
|
this._cryptiqueUrl = url;
|
|
5355
4948
|
return originalXHROpen.apply(this, [method, url, ...args]);
|
|
5356
4949
|
};
|
|
5357
|
-
|
|
4950
|
+
|
|
5358
4951
|
XMLHttpRequest.prototype.send = function(...args) {
|
|
5359
4952
|
const startTime = Date.now();
|
|
5360
4953
|
const xhr = this;
|
|
@@ -5363,7 +4956,7 @@
|
|
|
5363
4956
|
if (!xhr._cryptiqueUrl || !xhr._cryptiqueUrl.includes('sdkapi.cryptique.io')) {
|
|
5364
4957
|
return originalXHRSend.apply(this, args);
|
|
5365
4958
|
}
|
|
5366
|
-
|
|
4959
|
+
|
|
5367
4960
|
xhr.addEventListener('load', function() {
|
|
5368
4961
|
if (xhr.status >= 400) {
|
|
5369
4962
|
EventsManager.trackAutoEvent('network_error', {
|
|
@@ -5385,11 +4978,77 @@
|
|
|
5385
4978
|
duration_ms: Date.now() - startTime
|
|
5386
4979
|
});
|
|
5387
4980
|
});
|
|
5388
|
-
|
|
4981
|
+
|
|
5389
4982
|
return originalXHRSend.apply(this, args);
|
|
5390
4983
|
};
|
|
5391
|
-
|
|
4984
|
+
|
|
4985
|
+
// ── fetch: track same-origin HTTP errors from the host application ──
|
|
4986
|
+
// We intercept only same-origin (or explicitly relative) URLs so that
|
|
4987
|
+
// third-party CDN / analytics / auth calls are never touched.
|
|
4988
|
+
const originalFetch = window.fetch;
|
|
4989
|
+
if (typeof originalFetch === 'function') {
|
|
4990
|
+
window.fetch = function(input, init) {
|
|
4991
|
+
const startTime = Date.now();
|
|
4992
|
+
|
|
4993
|
+
// Resolve the URL string regardless of whether input is a string,
|
|
4994
|
+
// URL object, or Request object.
|
|
4995
|
+
let urlStr = '';
|
|
4996
|
+
try {
|
|
4997
|
+
urlStr = (input instanceof Request ? input.url : String(input)) || '';
|
|
4998
|
+
} catch (_) {}
|
|
4999
|
+
|
|
5000
|
+
// Determine if same-origin (relative URLs are always same-origin)
|
|
5001
|
+
let isSameOrigin = false;
|
|
5002
|
+
try {
|
|
5003
|
+
if (urlStr.startsWith('/') || urlStr.startsWith('./') || urlStr.startsWith('../')) {
|
|
5004
|
+
isSameOrigin = true;
|
|
5005
|
+
} else {
|
|
5006
|
+
const parsed = new URL(urlStr, window.location.href);
|
|
5007
|
+
isSameOrigin = parsed.origin === window.location.origin;
|
|
5008
|
+
}
|
|
5009
|
+
} catch (_) {}
|
|
5010
|
+
|
|
5011
|
+
// Skip SDK's own backend calls — already handled by APIClient.send()
|
|
5012
|
+
if (urlStr.includes('sdkapi.cryptique.io')) {
|
|
5013
|
+
return originalFetch.apply(this, arguments);
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
// Pass cross-origin calls through untouched
|
|
5017
|
+
if (!isSameOrigin) {
|
|
5018
|
+
return originalFetch.apply(this, arguments);
|
|
5019
|
+
}
|
|
5020
|
+
|
|
5021
|
+
const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
|
|
5022
|
+
|
|
5023
|
+
return originalFetch.apply(this, arguments).then(
|
|
5024
|
+
(response) => {
|
|
5025
|
+
if (response.status >= 400) {
|
|
5026
|
+
EventsManager.trackAutoEvent('network_error', {
|
|
5027
|
+
url: urlStr,
|
|
5028
|
+
method: method,
|
|
5029
|
+
status: response.status,
|
|
5030
|
+
status_text: `HTTP ${response.status} Error`,
|
|
5031
|
+
duration_ms: Date.now() - startTime
|
|
5032
|
+
});
|
|
5033
|
+
}
|
|
5034
|
+
return response;
|
|
5035
|
+
},
|
|
5036
|
+
(err) => {
|
|
5037
|
+
// Network-level failure (offline, DNS, CORS preflight, etc.)
|
|
5038
|
+
EventsManager.trackAutoEvent('network_error', {
|
|
5039
|
+
url: urlStr,
|
|
5040
|
+
method: method,
|
|
5041
|
+
status: 0,
|
|
5042
|
+
status_text: 'Fetch Network Error',
|
|
5043
|
+
duration_ms: Date.now() - startTime
|
|
5044
|
+
});
|
|
5045
|
+
throw err; // re-throw so the calling code still gets the error
|
|
5046
|
+
}
|
|
5047
|
+
);
|
|
5048
|
+
};
|
|
5392
5049
|
}
|
|
5050
|
+
} catch (error) {
|
|
5051
|
+
}
|
|
5393
5052
|
},
|
|
5394
5053
|
|
|
5395
5054
|
/**
|
|
@@ -5662,8 +5321,54 @@
|
|
|
5662
5321
|
let clsScore = 0;
|
|
5663
5322
|
let longTasksCount = 0;
|
|
5664
5323
|
let maxLongTaskMs = 0;
|
|
5324
|
+
let longTaskTimestamps = []; // [{start_ms, duration_ms}] capped at 20
|
|
5665
5325
|
let inpMs = 0;
|
|
5666
5326
|
|
|
5327
|
+
// ── Exit type detection ─────────────────────────────────────────────
|
|
5328
|
+
// Classifies HOW the user left this page so the AI can distinguish
|
|
5329
|
+
// explicit rejection (back-button) from fatigue (tab-close) from
|
|
5330
|
+
// internal navigation (SPA route change or page refresh).
|
|
5331
|
+
//
|
|
5332
|
+
// Values:
|
|
5333
|
+
// 'back_forward' – browser back/forward button (popstate)
|
|
5334
|
+
// 'spa_navigation' – SPA pushState / replaceState route change
|
|
5335
|
+
// 'reload' – page refresh (F5 / Ctrl+R / location.reload)
|
|
5336
|
+
// 'tab_close' – tab or window closed (beforeunload, no subsequent nav)
|
|
5337
|
+
// 'unknown' – couldn't be determined (visibilitychange, iOS Safari, etc.)
|
|
5338
|
+
let pageExitType = 'unknown';
|
|
5339
|
+
|
|
5340
|
+
window.addEventListener('popstate', () => {
|
|
5341
|
+
pageExitType = 'back_forward';
|
|
5342
|
+
}, { once: true });
|
|
5343
|
+
|
|
5344
|
+
// Intercept SPA navigation — hook into the already-overridden pushState /
|
|
5345
|
+
// replaceState (the overrides were applied in Section 11: SessionTracker).
|
|
5346
|
+
// We listen to the 'cq:spa-navigation' custom event that we dispatch below,
|
|
5347
|
+
// rather than overriding pushState a second time.
|
|
5348
|
+
// Detect by watching for our own navigation event (fired by SessionTracker).
|
|
5349
|
+
window.addEventListener('cq:spa-navigation', () => {
|
|
5350
|
+
if (pageExitType === 'unknown') pageExitType = 'spa_navigation';
|
|
5351
|
+
}, { once: true });
|
|
5352
|
+
|
|
5353
|
+
// Detect reload via PerformanceNavigationTiming.type === 'reload'
|
|
5354
|
+
try {
|
|
5355
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
5356
|
+
if (navEntry && navEntry.type === 'reload') {
|
|
5357
|
+
// The CURRENT page was reached by reload; if user reloads again
|
|
5358
|
+
// we'll see another 'reload' on the next load — set a provisional flag.
|
|
5359
|
+
pageExitType = 'reload_arrived'; // overridden by popstate/spa if applicable
|
|
5360
|
+
}
|
|
5361
|
+
} catch (_) {}
|
|
5362
|
+
|
|
5363
|
+
window.addEventListener('beforeunload', () => {
|
|
5364
|
+
// beforeunload fires for tab-close AND for hard navigations (link clicks,
|
|
5365
|
+
// address-bar entry). SPA navigations do NOT fire it. popstate fires
|
|
5366
|
+
// before pagehide for back/forward so the flag is already set.
|
|
5367
|
+
if (pageExitType === 'unknown' || pageExitType === 'reload_arrived') {
|
|
5368
|
+
pageExitType = 'tab_close';
|
|
5369
|
+
}
|
|
5370
|
+
});
|
|
5371
|
+
|
|
5667
5372
|
const pageLoadTime = Date.now();
|
|
5668
5373
|
let lastSigScrollY = window.scrollY;
|
|
5669
5374
|
let lastSigDir = 0; // 1=down, -1=up
|
|
@@ -5695,6 +5400,16 @@
|
|
|
5695
5400
|
for (const e of list.getEntries()) {
|
|
5696
5401
|
longTasksCount++;
|
|
5697
5402
|
if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
|
|
5403
|
+
// Record when each long task started (ms since page load, same reference
|
|
5404
|
+
// frame as first_interaction_ms) so the AI can correlate main-thread
|
|
5405
|
+
// blocking with the moment a user first tried to interact.
|
|
5406
|
+
// Cap at 20 — pathological pages sample the earliest 20 tasks.
|
|
5407
|
+
if (longTaskTimestamps.length < 20) {
|
|
5408
|
+
longTaskTimestamps.push({
|
|
5409
|
+
start_ms: Math.round(e.startTime),
|
|
5410
|
+
duration_ms: Math.round(e.duration)
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5698
5413
|
}
|
|
5699
5414
|
}).observe({ type: 'longtask', buffered: true });
|
|
5700
5415
|
} catch (_) {}
|
|
@@ -5935,6 +5650,11 @@
|
|
|
5935
5650
|
.sort((a, b) => b.dwell_ms - a.dwell_ms)
|
|
5936
5651
|
.slice(0, 20);
|
|
5937
5652
|
|
|
5653
|
+
// Finalise exit_type: if it's still the provisional reload_arrived value
|
|
5654
|
+
// (user reloaded this page and didn't navigate away via back/spa), keep it
|
|
5655
|
+
// as 'reload' since the beforeunload didn't fire (e.g. visibilitychange path).
|
|
5656
|
+
const finalExitType = pageExitType === 'reload_arrived' ? 'reload' : pageExitType;
|
|
5657
|
+
|
|
5938
5658
|
EventsManager.trackAutoEvent('page_summary', {
|
|
5939
5659
|
scroll_reversals: scrollReversals,
|
|
5940
5660
|
scroll_reversal_depths: scrollReversalDepths,
|
|
@@ -5951,7 +5671,9 @@
|
|
|
5951
5671
|
cls_score: clsScore,
|
|
5952
5672
|
long_tasks_count: longTasksCount,
|
|
5953
5673
|
max_long_task_ms: maxLongTaskMs,
|
|
5674
|
+
long_task_timestamps: longTaskTimestamps,
|
|
5954
5675
|
inp_ms: inpMs,
|
|
5676
|
+
exit_type: finalExitType,
|
|
5955
5677
|
document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
|
|
5956
5678
|
document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
|
|
5957
5679
|
viewport_width: window.innerWidth,
|
|
@@ -6065,9 +5787,6 @@
|
|
|
6065
5787
|
// Initialize session data structure
|
|
6066
5788
|
SessionDataManager.initialize(sessionId, userId);
|
|
6067
5789
|
|
|
6068
|
-
// Initialize interactions
|
|
6069
|
-
InteractionManager.initialize();
|
|
6070
|
-
|
|
6071
5790
|
// Initialize page visits
|
|
6072
5791
|
PageVisitManager.initialize();
|
|
6073
5792
|
} catch (error) {
|
|
@@ -6210,15 +5929,6 @@
|
|
|
6210
5929
|
sessionData.pagesViewed = sessionData.visited_pages.length;
|
|
6211
5930
|
}
|
|
6212
5931
|
|
|
6213
|
-
// Sync interactions
|
|
6214
|
-
if (storedSession.sessionData.interactions) {
|
|
6215
|
-
const storedInteractions = storedSession.sessionData.interactions;
|
|
6216
|
-
if (storedInteractions.totalInteractions >
|
|
6217
|
-
(sessionData.interactions?.totalInteractions || 0)) {
|
|
6218
|
-
// Update interactions from storage
|
|
6219
|
-
sessionData.interactions = storedInteractions;
|
|
6220
|
-
}
|
|
6221
|
-
}
|
|
6222
5932
|
}
|
|
6223
5933
|
|
|
6224
5934
|
// Update endTime
|
|
@@ -6281,9 +5991,6 @@
|
|
|
6281
5991
|
}));
|
|
6282
5992
|
sessionData.pagesViewed = sessionData.visited_pages.length;
|
|
6283
5993
|
}
|
|
6284
|
-
if (storedSession.sessionData.interactions) {
|
|
6285
|
-
sessionData.interactions = storedSession.sessionData.interactions;
|
|
6286
|
-
}
|
|
6287
5994
|
}
|
|
6288
5995
|
|
|
6289
5996
|
// Track browser close (last page unmount)
|
|
@@ -6658,6 +6365,13 @@
|
|
|
6658
6365
|
*/
|
|
6659
6366
|
async trackAutoEvent(eventName, autoEventData = {}, elementData = {}) {
|
|
6660
6367
|
try {
|
|
6368
|
+
// Keep a running record of the last auto-event name so that js_error
|
|
6369
|
+
// events can include a "last_event_name" field for context.
|
|
6370
|
+
// Skip error-category events themselves to avoid circular noise.
|
|
6371
|
+
if (eventName && eventName !== 'js_error' && eventName !== 'network_error') {
|
|
6372
|
+
EventsManager._lastAutoEventName = eventName;
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6661
6375
|
// Check if auto events should be tracked (silently skip if disabled)
|
|
6662
6376
|
if (!shouldTrackAutoEvents()) {
|
|
6663
6377
|
return; // Silently skip - don't log to avoid console spam
|
|
@@ -7336,8 +7050,23 @@
|
|
|
7336
7050
|
let clickCount = 0;
|
|
7337
7051
|
let lastClickTime = 0;
|
|
7338
7052
|
let lastClickElement = null;
|
|
7053
|
+
let rageStartTime = 0; // Timestamp of the first click in the current rage sequence
|
|
7054
|
+
let rageTimer = null; // Debounce timer — fires ONE rage_click after sequence ends
|
|
7339
7055
|
const pendingDeadClicks = new Map(); // Track clicks that might be dead clicks
|
|
7340
7056
|
|
|
7057
|
+
// MutationObserver counter — incremented on ANY DOM change.
|
|
7058
|
+
// Dead-click detection compares the count at click-time vs 800 ms later:
|
|
7059
|
+
// if nothing changed (URL stable + zero new mutations) it's a true dead click.
|
|
7060
|
+
// This prevents false positives in SPAs where interactions update the DOM
|
|
7061
|
+
// without changing the URL (modals, dropdowns, cart updates, tab panels, etc.).
|
|
7062
|
+
let domMutationCount = 0;
|
|
7063
|
+
try {
|
|
7064
|
+
new MutationObserver(() => { domMutationCount++; })
|
|
7065
|
+
.observe(document.documentElement, {
|
|
7066
|
+
childList: true, subtree: true, attributes: true, characterData: false
|
|
7067
|
+
});
|
|
7068
|
+
} catch (_) {}
|
|
7069
|
+
|
|
7341
7070
|
// Helper function to check if element is interactive
|
|
7342
7071
|
const isInteractiveElement = (el) => {
|
|
7343
7072
|
if (!el) return false;
|
|
@@ -7427,31 +7156,60 @@
|
|
|
7427
7156
|
elementData.image_height = element.naturalHeight || null;
|
|
7428
7157
|
}
|
|
7429
7158
|
|
|
7430
|
-
// Detect rage clicks
|
|
7159
|
+
// Detect rage clicks — fire exactly ONE event per sequence when the burst ends.
|
|
7160
|
+
// A "sequence" is N rapid clicks on the same element within 1 s of each other.
|
|
7161
|
+
// We debounce 800 ms after the last click so the event carries the FINAL count
|
|
7162
|
+
// and the total time-span from first to last click (not just the last interval).
|
|
7431
7163
|
if (element === lastClickElement && now - lastClickTime < 1000) {
|
|
7432
7164
|
clickCount++;
|
|
7165
|
+
|
|
7433
7166
|
if (clickCount >= 3) {
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7167
|
+
// Cancel any previously scheduled fire — sequence is still ongoing
|
|
7168
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7169
|
+
|
|
7170
|
+
// Snapshot mutable values at the time of THIS click
|
|
7171
|
+
const snapScrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
|
|
7172
|
+
const snapScrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
|
|
7173
|
+
const snapDocHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
|
|
7174
|
+
const snapDocWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
|
|
7175
|
+
const snapClientX = event.clientX;
|
|
7176
|
+
const snapClientY = event.clientY;
|
|
7177
|
+
const snapPageX = event.pageX;
|
|
7178
|
+
const snapPageY = event.pageY;
|
|
7179
|
+
const snapElemData = Object.assign({}, elementData);
|
|
7180
|
+
const snapCategory = elementCategory;
|
|
7181
|
+
const snapArea = element.offsetWidth * element.offsetHeight;
|
|
7182
|
+
const snapAriaLabel = element.getAttribute('aria-label') || null;
|
|
7183
|
+
const snapStartTime = rageStartTime;
|
|
7184
|
+
const snapNow = now;
|
|
7185
|
+
|
|
7186
|
+
// Fire after 800 ms of inactivity — reads the FINAL clickCount from closure
|
|
7187
|
+
rageTimer = setTimeout(() => {
|
|
7188
|
+
rageTimer = null;
|
|
7189
|
+
EventsManager.trackAutoEvent('rage_click', {
|
|
7190
|
+
click_coordinates: { x: snapClientX, y: snapClientY },
|
|
7191
|
+
page_x: snapPageX,
|
|
7192
|
+
page_y: snapPageY,
|
|
7193
|
+
scroll_x: snapScrollX,
|
|
7194
|
+
scroll_y: snapScrollY,
|
|
7195
|
+
document_height: snapDocHeight,
|
|
7196
|
+
document_width: snapDocWidth,
|
|
7197
|
+
click_count: clickCount, // final count for the whole sequence
|
|
7198
|
+
time_span: snapNow - snapStartTime, // first→last click duration
|
|
7199
|
+
element_area: snapArea,
|
|
7200
|
+
element_category: snapCategory,
|
|
7201
|
+
aria_label: snapAriaLabel
|
|
7202
|
+
}, snapElemData).catch(() => {});
|
|
7203
|
+
// Reset sequence state after the event is dispatched
|
|
7204
|
+
clickCount = 0;
|
|
7205
|
+
lastClickElement = null;
|
|
7206
|
+
}, 800);
|
|
7452
7207
|
}
|
|
7453
7208
|
} else {
|
|
7454
|
-
|
|
7209
|
+
// New element or gap > 1 s — start a fresh sequence
|
|
7210
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7211
|
+
clickCount = 1;
|
|
7212
|
+
rageStartTime = now;
|
|
7455
7213
|
}
|
|
7456
7214
|
|
|
7457
7215
|
// Check if this might be a dead click (non-interactive element)
|
|
@@ -7475,6 +7233,7 @@
|
|
|
7475
7233
|
elementCategory,
|
|
7476
7234
|
timestamp: now,
|
|
7477
7235
|
url: window.location.href,
|
|
7236
|
+
snapshotMutationCount: domMutationCount, // DOM state at click time
|
|
7478
7237
|
clickX,
|
|
7479
7238
|
clickY,
|
|
7480
7239
|
page_x: event.pageX,
|
|
@@ -7485,13 +7244,17 @@
|
|
|
7485
7244
|
document_width: docWidth
|
|
7486
7245
|
});
|
|
7487
7246
|
|
|
7488
|
-
// Check after
|
|
7247
|
+
// Check after 800 ms: if URL unchanged AND DOM unchanged → true dead click.
|
|
7248
|
+
// Using 800 ms (down from 1 s) tightens the window while still allowing
|
|
7249
|
+
// async renders (React setState, animations) to complete before we decide.
|
|
7489
7250
|
setTimeout(() => {
|
|
7490
7251
|
checkNavigation();
|
|
7491
|
-
|
|
7252
|
+
|
|
7492
7253
|
const pendingClick = pendingDeadClicks.get(clickId);
|
|
7493
|
-
if (pendingClick &&
|
|
7494
|
-
|
|
7254
|
+
if (pendingClick &&
|
|
7255
|
+
window.location.href === pendingClick.url &&
|
|
7256
|
+
domMutationCount === pendingClick.snapshotMutationCount) {
|
|
7257
|
+
// No URL change AND no DOM mutations → confirmed dead click
|
|
7495
7258
|
pendingDeadClicks.delete(clickId);
|
|
7496
7259
|
|
|
7497
7260
|
EventsManager.trackAutoEvent('dead_click', {
|
|
@@ -7511,10 +7274,10 @@
|
|
|
7511
7274
|
console.error('❌ [AutoEvents] Failed to track dead_click:', err);
|
|
7512
7275
|
});
|
|
7513
7276
|
} else if (pendingClick) {
|
|
7514
|
-
//
|
|
7277
|
+
// URL changed or DOM mutated — interaction was real, not a dead click
|
|
7515
7278
|
pendingDeadClicks.delete(clickId);
|
|
7516
7279
|
}
|
|
7517
|
-
},
|
|
7280
|
+
}, 800);
|
|
7518
7281
|
}
|
|
7519
7282
|
|
|
7520
7283
|
// Track regular click with enhanced data (viewport + page-relative for heatmaps)
|
|
@@ -8410,10 +8173,6 @@
|
|
|
8410
8173
|
return sessionData;
|
|
8411
8174
|
},
|
|
8412
8175
|
|
|
8413
|
-
// Interaction Functions
|
|
8414
|
-
getChronologicalInteractions: InteractionManager.getChronological.bind(InteractionManager),
|
|
8415
|
-
sortInteractionsChronologically: InteractionManager.getChronological.bind(InteractionManager),
|
|
8416
|
-
|
|
8417
8176
|
// Auto Events Configuration
|
|
8418
8177
|
enableAutoEvents: function() {
|
|
8419
8178
|
CONFIG.AUTO_EVENTS.enabled = true;
|