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