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/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=8fc6409059aa39",
100
- BACKUP_URL: "https://ipinfo.io/json?token=8fc6409059aa39&http=1.1",
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', // Already snake_case
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
- // SECTION 10: INTERACTION MANAGEMENT
3203
- // ============================================================================
3204
- // PURPOSE: Single source of truth for interaction tracking and management.
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
- * Interaction categories
3230
- *
3231
- * All supported interaction categories
3232
- */
3233
- CATEGORIES: [
3234
- 'clicks',
3235
- 'formInteractions',
3236
- 'mediaInteractions',
3237
- 'scrollEvents',
3238
- 'focusEvents',
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 chronological interactions
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 - NOW WITH INTERACTIONS DISTRIBUTED TO PAGES
4276
- visited_pages: pagesWithInteractions,
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: vitals.lcp,
5297
- cls: vitals.cls,
5298
- inp: vitals.inp,
5299
- fcp: vitals.fcp,
5300
- ttfb: vitals.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: 'javascript_error',
5317
- message: event.message || '',
5318
- source: event.filename || '',
5319
- line: event.lineno || 0,
5320
- column: event.colno || 0,
5321
- stack: event.error?.stack || ''
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
- // Track unhandled promise rejections
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: 'unhandled_promise_rejection',
5329
- message: event.reason?.message || String(event.reason || 'Unknown error'),
5330
- stack: event.reason?.stack || String(event.reason || '')
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 only)
4928
+ * NetworkTracker - Tracks network errors (XHR + fetch)
5339
4929
  *
5340
- * NOTE:
5341
- * We intentionally do NOT wrap window.fetch here, to avoid confusing
5342
- * browser DevTools attribution for third‑party requests. All SDK
5343
- * fetch-based calls to the SDK API backend (sdkapi.cryptique.io) are already
5344
- * error-handled inside APIClient.send(), so fetch wrapping is redundant.
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
- // Track XMLHttpRequest errors - only intercept calls to our own backend
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
- } catch (error) {
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 (multiple clicks on same element quickly)
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
- const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
7435
- const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
7436
- const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7437
- const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
7438
- EventsManager.trackAutoEvent('rage_click', {
7439
- click_coordinates: { x: event.clientX, y: event.clientY },
7440
- page_x: event.pageX,
7441
- page_y: event.pageY,
7442
- scroll_x: scrollX,
7443
- scroll_y: scrollY,
7444
- document_height: docHeight,
7445
- document_width: docWidth,
7446
- click_count: clickCount,
7447
- time_span: now - lastClickTime,
7448
- element_area: element.offsetWidth * element.offsetHeight,
7449
- element_category: elementCategory
7450
- }, elementData).catch(err => {
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
- clickCount = 1;
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 1 second if navigation occurred or if it's still a dead click
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 && window.location.href === pendingClick.url) {
7494
- // No navigation occurred, this is a dead click
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
- // Navigation occurred, not a dead click
7277
+ // URL changed or DOM mutated — interaction was real, not a dead click
7515
7278
  pendingDeadClicks.delete(clickId);
7516
7279
  }
7517
- }, 1000);
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;