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