cryptique-sdk 1.2.19 → 1.2.20

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
@@ -3914,6 +3914,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
3914
3914
 
3915
3915
  history.pushState = function(...args) {
3916
3916
  originalPushState.apply(history, args);
3917
+ // Notify page_summary exit-type tracker that an SPA navigation occurred
3918
+ try { window.dispatchEvent(new CustomEvent('cq:spa-navigation')); } catch (_) {}
3917
3919
  // Use requestAnimationFrame for better timing with React Router
3918
3920
  requestAnimationFrame(() => {
3919
3921
  self.track();
@@ -3922,6 +3924,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
3922
3924
 
3923
3925
  history.replaceState = function(...args) {
3924
3926
  originalReplaceState.apply(history, args);
3927
+ // Notify page_summary exit-type tracker (replaceState = SPA redirect)
3928
+ try { window.dispatchEvent(new CustomEvent('cq:spa-navigation')); } catch (_) {}
3925
3929
  // Use requestAnimationFrame for better timing with React Router
3926
3930
  requestAnimationFrame(() => {
3927
3931
  self.track();
@@ -5288,12 +5292,33 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5288
5292
  : null;
5289
5293
  } catch (e) {}
5290
5294
 
5295
+ // Collect top-3 slowest resources to pinpoint what is causing slow LCP/FCP.
5296
+ // Each entry: { name (URL), initiator_type, duration_ms, transfer_size_kb }
5297
+ // Only includes same-origin resources or entries where the timing is exposed
5298
+ // (cross-origin resources without Timing-Allow-Origin return duration = 0
5299
+ // and are excluded as they carry no actionable signal).
5300
+ let slowestResources = [];
5301
+ try {
5302
+ const resources = performance.getEntriesByType('resource');
5303
+ slowestResources = resources
5304
+ .filter(r => r.duration > 0) // exclude zero-duration cross-origin entries
5305
+ .sort((a, b) => b.duration - a.duration)
5306
+ .slice(0, 3)
5307
+ .map(r => ({
5308
+ name: r.name.split('?')[0].slice(-120), // trim query string + cap length
5309
+ initiator_type: r.initiatorType || 'other', // script, css, img, fetch, xmlhttprequest…
5310
+ duration_ms: Math.round(r.duration),
5311
+ transfer_size_kb: r.transferSize ? Math.round(r.transferSize / 1024) : null
5312
+ }));
5313
+ } catch (_) {}
5314
+
5291
5315
  EventsManager.trackAutoEvent('page_performance', {
5292
- lcp: vitals.lcp,
5293
- cls: vitals.cls,
5294
- inp: vitals.inp,
5295
- fcp: vitals.fcp,
5296
- ttfb: vitals.ttfb
5316
+ lcp: vitals.lcp,
5317
+ cls: vitals.cls,
5318
+ inp: vitals.inp,
5319
+ fcp: vitals.fcp,
5320
+ ttfb: vitals.ttfb,
5321
+ slowest_resources: slowestResources // [] when none available
5297
5322
  });
5298
5323
  }, 1500);
5299
5324
  });
@@ -5303,54 +5328,108 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5303
5328
 
5304
5329
  /**
5305
5330
  * ErrorTracker - Tracks JavaScript errors
5306
- * Emits as queryable auto events (js_error) instead of session-level JSONB
5331
+ * Emits as queryable auto events (js_error) instead of session-level JSONB.
5332
+ *
5333
+ * Improvements over original:
5334
+ * - error_class: extracts the constructor name (TypeError, ReferenceError, etc.)
5335
+ * so the AI can categorise errors without parsing message strings.
5336
+ * - Session-level deduplication: same error (same message + source + line)
5337
+ * fires at most once per 10 s to prevent event floods from looping errors.
5338
+ * - last_event_name: name of the most-recently tracked auto event, giving a
5339
+ * lightweight "what was the user doing just before the crash" signal.
5307
5340
  */
5308
5341
  startErrorTracking() {
5309
5342
  try {
5343
+ // Deduplication: hash → last-fired timestamp (ms)
5344
+ const recentErrors = new Map();
5345
+ const ERROR_DEDUPE_MS = 10000; // suppress repeats within 10 s
5346
+
5347
+ // Lightweight "preceding action" tracker — updated by trackAutoEvent callers
5348
+ // via a module-level variable so it's always current.
5349
+ if (!EventsManager._lastAutoEventName) {
5350
+ EventsManager._lastAutoEventName = '';
5351
+ }
5352
+
5353
+ const dedupeKey = (msg, src, line) =>
5354
+ `${msg}|${src}|${line}`.slice(0, 200);
5355
+
5356
+ const shouldSuppress = (key) => {
5357
+ const last = recentErrors.get(key);
5358
+ if (!last) return false;
5359
+ return (Date.now() - last) < ERROR_DEDUPE_MS;
5360
+ };
5361
+
5362
+ const markSeen = (key) => recentErrors.set(key, Date.now());
5363
+
5364
+ // Extract class name from the error object's constructor, e.g. "TypeError"
5365
+ const getErrorClass = (errObj) => {
5366
+ if (!errObj) return 'Error';
5367
+ try { return errObj.constructor?.name || 'Error'; } catch (_) { return 'Error'; }
5368
+ };
5369
+
5310
5370
  window.addEventListener('error', (event) => {
5371
+ const msg = event.message || '';
5372
+ const src = event.filename || '';
5373
+ const line = event.lineno || 0;
5374
+ const key = dedupeKey(msg, src, line);
5375
+ if (shouldSuppress(key)) return;
5376
+ markSeen(key);
5377
+
5311
5378
  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 || ''
5379
+ error_type: 'javascript_error',
5380
+ error_class: getErrorClass(event.error),
5381
+ message: msg,
5382
+ source: src,
5383
+ line: line,
5384
+ column: event.colno || 0,
5385
+ stack: event.error?.stack || '',
5386
+ last_event_name: EventsManager._lastAutoEventName || ''
5318
5387
  });
5319
5388
  });
5320
5389
 
5321
- // Track unhandled promise rejections
5390
+ // Unhandled promise rejections (fetch failures that aren't caught, async throws, etc.)
5322
5391
  window.addEventListener('unhandledrejection', (event) => {
5392
+ const msg = event.reason?.message || String(event.reason || 'Unknown error');
5393
+ const key = dedupeKey(msg, '', 0);
5394
+ if (shouldSuppress(key)) return;
5395
+ markSeen(key);
5396
+
5323
5397
  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 || '')
5398
+ error_type: 'unhandled_promise_rejection',
5399
+ error_class: getErrorClass(event.reason),
5400
+ message: msg,
5401
+ stack: event.reason?.stack || String(event.reason || ''),
5402
+ last_event_name: EventsManager._lastAutoEventName || ''
5327
5403
  });
5328
5404
  });
5329
5405
  } catch (error) {
5330
- }
5406
+ }
5331
5407
  },
5332
5408
 
5333
5409
  /**
5334
- * NetworkTracker - Tracks network errors (XHR only)
5410
+ * NetworkTracker - Tracks network errors (XHR + fetch)
5411
+ *
5412
+ * XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
5335
5413
  *
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.
5414
+ * fetch: wraps window.fetch to track HTTP ≥ 400 responses and network
5415
+ * failures for same-origin requests made by the host application.
5416
+ * Third-party (cross-origin) fetches are passed through untouched to
5417
+ * minimise DevTools attribution noise. The SDK's own fetch calls via
5418
+ * APIClient.send() are already error-handled and are excluded by the
5419
+ * same-origin guard (they go to sdkapi.cryptique.io, not the app origin).
5341
5420
  */
5342
5421
  startNetworkTracking() {
5343
5422
  try {
5344
- // Track XMLHttpRequest errors - only intercept calls to our own backend
5423
+ // ── XHR: unchanged only track SDK backend calls ──────────────────
5345
5424
  const originalXHROpen = XMLHttpRequest.prototype.open;
5346
5425
  const originalXHRSend = XMLHttpRequest.prototype.send;
5347
-
5426
+
5348
5427
  XMLHttpRequest.prototype.open = function(method, url, ...args) {
5349
5428
  this._cryptiqueMethod = method;
5350
5429
  this._cryptiqueUrl = url;
5351
5430
  return originalXHROpen.apply(this, [method, url, ...args]);
5352
5431
  };
5353
-
5432
+
5354
5433
  XMLHttpRequest.prototype.send = function(...args) {
5355
5434
  const startTime = Date.now();
5356
5435
  const xhr = this;
@@ -5359,7 +5438,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5359
5438
  if (!xhr._cryptiqueUrl || !xhr._cryptiqueUrl.includes('sdkapi.cryptique.io')) {
5360
5439
  return originalXHRSend.apply(this, args);
5361
5440
  }
5362
-
5441
+
5363
5442
  xhr.addEventListener('load', function() {
5364
5443
  if (xhr.status >= 400) {
5365
5444
  EventsManager.trackAutoEvent('network_error', {
@@ -5381,11 +5460,77 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5381
5460
  duration_ms: Date.now() - startTime
5382
5461
  });
5383
5462
  });
5384
-
5463
+
5385
5464
  return originalXHRSend.apply(this, args);
5386
5465
  };
5387
- } catch (error) {
5466
+
5467
+ // ── fetch: track same-origin HTTP errors from the host application ──
5468
+ // We intercept only same-origin (or explicitly relative) URLs so that
5469
+ // third-party CDN / analytics / auth calls are never touched.
5470
+ const originalFetch = window.fetch;
5471
+ if (typeof originalFetch === 'function') {
5472
+ window.fetch = function(input, init) {
5473
+ const startTime = Date.now();
5474
+
5475
+ // Resolve the URL string regardless of whether input is a string,
5476
+ // URL object, or Request object.
5477
+ let urlStr = '';
5478
+ try {
5479
+ urlStr = (input instanceof Request ? input.url : String(input)) || '';
5480
+ } catch (_) {}
5481
+
5482
+ // Determine if same-origin (relative URLs are always same-origin)
5483
+ let isSameOrigin = false;
5484
+ try {
5485
+ if (urlStr.startsWith('/') || urlStr.startsWith('./') || urlStr.startsWith('../')) {
5486
+ isSameOrigin = true;
5487
+ } else {
5488
+ const parsed = new URL(urlStr, window.location.href);
5489
+ isSameOrigin = parsed.origin === window.location.origin;
5490
+ }
5491
+ } catch (_) {}
5492
+
5493
+ // Skip SDK's own backend calls — already handled by APIClient.send()
5494
+ if (urlStr.includes('sdkapi.cryptique.io')) {
5495
+ return originalFetch.apply(this, arguments);
5496
+ }
5497
+
5498
+ // Pass cross-origin calls through untouched
5499
+ if (!isSameOrigin) {
5500
+ return originalFetch.apply(this, arguments);
5501
+ }
5502
+
5503
+ const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
5504
+
5505
+ return originalFetch.apply(this, arguments).then(
5506
+ (response) => {
5507
+ if (response.status >= 400) {
5508
+ EventsManager.trackAutoEvent('network_error', {
5509
+ url: urlStr,
5510
+ method: method,
5511
+ status: response.status,
5512
+ status_text: `HTTP ${response.status} Error`,
5513
+ duration_ms: Date.now() - startTime
5514
+ });
5515
+ }
5516
+ return response;
5517
+ },
5518
+ (err) => {
5519
+ // Network-level failure (offline, DNS, CORS preflight, etc.)
5520
+ EventsManager.trackAutoEvent('network_error', {
5521
+ url: urlStr,
5522
+ method: method,
5523
+ status: 0,
5524
+ status_text: 'Fetch Network Error',
5525
+ duration_ms: Date.now() - startTime
5526
+ });
5527
+ throw err; // re-throw so the calling code still gets the error
5528
+ }
5529
+ );
5530
+ };
5388
5531
  }
5532
+ } catch (error) {
5533
+ }
5389
5534
  },
5390
5535
 
5391
5536
  /**
@@ -5658,8 +5803,54 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5658
5803
  let clsScore = 0;
5659
5804
  let longTasksCount = 0;
5660
5805
  let maxLongTaskMs = 0;
5806
+ let longTaskTimestamps = []; // [{start_ms, duration_ms}] capped at 20
5661
5807
  let inpMs = 0;
5662
5808
 
5809
+ // ── Exit type detection ─────────────────────────────────────────────
5810
+ // Classifies HOW the user left this page so the AI can distinguish
5811
+ // explicit rejection (back-button) from fatigue (tab-close) from
5812
+ // internal navigation (SPA route change or page refresh).
5813
+ //
5814
+ // Values:
5815
+ // 'back_forward' – browser back/forward button (popstate)
5816
+ // 'spa_navigation' – SPA pushState / replaceState route change
5817
+ // 'reload' – page refresh (F5 / Ctrl+R / location.reload)
5818
+ // 'tab_close' – tab or window closed (beforeunload, no subsequent nav)
5819
+ // 'unknown' – couldn't be determined (visibilitychange, iOS Safari, etc.)
5820
+ let pageExitType = 'unknown';
5821
+
5822
+ window.addEventListener('popstate', () => {
5823
+ pageExitType = 'back_forward';
5824
+ }, { once: true });
5825
+
5826
+ // Intercept SPA navigation — hook into the already-overridden pushState /
5827
+ // replaceState (the overrides were applied in Section 11: SessionTracker).
5828
+ // We listen to the 'cq:spa-navigation' custom event that we dispatch below,
5829
+ // rather than overriding pushState a second time.
5830
+ // Detect by watching for our own navigation event (fired by SessionTracker).
5831
+ window.addEventListener('cq:spa-navigation', () => {
5832
+ if (pageExitType === 'unknown') pageExitType = 'spa_navigation';
5833
+ }, { once: true });
5834
+
5835
+ // Detect reload via PerformanceNavigationTiming.type === 'reload'
5836
+ try {
5837
+ const navEntry = performance.getEntriesByType('navigation')[0];
5838
+ if (navEntry && navEntry.type === 'reload') {
5839
+ // The CURRENT page was reached by reload; if user reloads again
5840
+ // we'll see another 'reload' on the next load — set a provisional flag.
5841
+ pageExitType = 'reload_arrived'; // overridden by popstate/spa if applicable
5842
+ }
5843
+ } catch (_) {}
5844
+
5845
+ window.addEventListener('beforeunload', () => {
5846
+ // beforeunload fires for tab-close AND for hard navigations (link clicks,
5847
+ // address-bar entry). SPA navigations do NOT fire it. popstate fires
5848
+ // before pagehide for back/forward so the flag is already set.
5849
+ if (pageExitType === 'unknown' || pageExitType === 'reload_arrived') {
5850
+ pageExitType = 'tab_close';
5851
+ }
5852
+ });
5853
+
5663
5854
  const pageLoadTime = Date.now();
5664
5855
  let lastSigScrollY = window.scrollY;
5665
5856
  let lastSigDir = 0; // 1=down, -1=up
@@ -5691,6 +5882,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5691
5882
  for (const e of list.getEntries()) {
5692
5883
  longTasksCount++;
5693
5884
  if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
5885
+ // Record when each long task started (ms since page load, same reference
5886
+ // frame as first_interaction_ms) so the AI can correlate main-thread
5887
+ // blocking with the moment a user first tried to interact.
5888
+ // Cap at 20 — pathological pages sample the earliest 20 tasks.
5889
+ if (longTaskTimestamps.length < 20) {
5890
+ longTaskTimestamps.push({
5891
+ start_ms: Math.round(e.startTime),
5892
+ duration_ms: Math.round(e.duration)
5893
+ });
5894
+ }
5694
5895
  }
5695
5896
  }).observe({ type: 'longtask', buffered: true });
5696
5897
  } catch (_) {}
@@ -5931,6 +6132,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5931
6132
  .sort((a, b) => b.dwell_ms - a.dwell_ms)
5932
6133
  .slice(0, 20);
5933
6134
 
6135
+ // Finalise exit_type: if it's still the provisional reload_arrived value
6136
+ // (user reloaded this page and didn't navigate away via back/spa), keep it
6137
+ // as 'reload' since the beforeunload didn't fire (e.g. visibilitychange path).
6138
+ const finalExitType = pageExitType === 'reload_arrived' ? 'reload' : pageExitType;
6139
+
5934
6140
  EventsManager.trackAutoEvent('page_summary', {
5935
6141
  scroll_reversals: scrollReversals,
5936
6142
  scroll_reversal_depths: scrollReversalDepths,
@@ -5947,7 +6153,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5947
6153
  cls_score: clsScore,
5948
6154
  long_tasks_count: longTasksCount,
5949
6155
  max_long_task_ms: maxLongTaskMs,
6156
+ long_task_timestamps: longTaskTimestamps,
5950
6157
  inp_ms: inpMs,
6158
+ exit_type: finalExitType,
5951
6159
  document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5952
6160
  document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5953
6161
  viewport_width: window.innerWidth,
@@ -6654,6 +6862,13 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6654
6862
  */
6655
6863
  async trackAutoEvent(eventName, autoEventData = {}, elementData = {}) {
6656
6864
  try {
6865
+ // Keep a running record of the last auto-event name so that js_error
6866
+ // events can include a "last_event_name" field for context.
6867
+ // Skip error-category events themselves to avoid circular noise.
6868
+ if (eventName && eventName !== 'js_error' && eventName !== 'network_error') {
6869
+ EventsManager._lastAutoEventName = eventName;
6870
+ }
6871
+
6657
6872
  // Check if auto events should be tracked (silently skip if disabled)
6658
6873
  if (!shouldTrackAutoEvents()) {
6659
6874
  return; // Silently skip - don't log to avoid console spam
@@ -7332,8 +7547,23 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7332
7547
  let clickCount = 0;
7333
7548
  let lastClickTime = 0;
7334
7549
  let lastClickElement = null;
7550
+ let rageStartTime = 0; // Timestamp of the first click in the current rage sequence
7551
+ let rageTimer = null; // Debounce timer — fires ONE rage_click after sequence ends
7335
7552
  const pendingDeadClicks = new Map(); // Track clicks that might be dead clicks
7336
7553
 
7554
+ // MutationObserver counter — incremented on ANY DOM change.
7555
+ // Dead-click detection compares the count at click-time vs 800 ms later:
7556
+ // if nothing changed (URL stable + zero new mutations) it's a true dead click.
7557
+ // This prevents false positives in SPAs where interactions update the DOM
7558
+ // without changing the URL (modals, dropdowns, cart updates, tab panels, etc.).
7559
+ let domMutationCount = 0;
7560
+ try {
7561
+ new MutationObserver(() => { domMutationCount++; })
7562
+ .observe(document.documentElement, {
7563
+ childList: true, subtree: true, attributes: true, characterData: false
7564
+ });
7565
+ } catch (_) {}
7566
+
7337
7567
  // Helper function to check if element is interactive
7338
7568
  const isInteractiveElement = (el) => {
7339
7569
  if (!el) return false;
@@ -7423,31 +7653,60 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7423
7653
  elementData.image_height = element.naturalHeight || null;
7424
7654
  }
7425
7655
 
7426
- // Detect rage clicks (multiple clicks on same element quickly)
7656
+ // Detect rage clicks fire exactly ONE event per sequence when the burst ends.
7657
+ // A "sequence" is N rapid clicks on the same element within 1 s of each other.
7658
+ // We debounce 800 ms after the last click so the event carries the FINAL count
7659
+ // and the total time-span from first to last click (not just the last interval).
7427
7660
  if (element === lastClickElement && now - lastClickTime < 1000) {
7428
7661
  clickCount++;
7662
+
7429
7663
  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
- });
7664
+ // Cancel any previously scheduled fire sequence is still ongoing
7665
+ if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
7666
+
7667
+ // Snapshot mutable values at the time of THIS click
7668
+ const snapScrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
7669
+ const snapScrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
7670
+ const snapDocHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7671
+ const snapDocWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
7672
+ const snapClientX = event.clientX;
7673
+ const snapClientY = event.clientY;
7674
+ const snapPageX = event.pageX;
7675
+ const snapPageY = event.pageY;
7676
+ const snapElemData = Object.assign({}, elementData);
7677
+ const snapCategory = elementCategory;
7678
+ const snapArea = element.offsetWidth * element.offsetHeight;
7679
+ const snapAriaLabel = element.getAttribute('aria-label') || null;
7680
+ const snapStartTime = rageStartTime;
7681
+ const snapNow = now;
7682
+
7683
+ // Fire after 800 ms of inactivity — reads the FINAL clickCount from closure
7684
+ rageTimer = setTimeout(() => {
7685
+ rageTimer = null;
7686
+ EventsManager.trackAutoEvent('rage_click', {
7687
+ click_coordinates: { x: snapClientX, y: snapClientY },
7688
+ page_x: snapPageX,
7689
+ page_y: snapPageY,
7690
+ scroll_x: snapScrollX,
7691
+ scroll_y: snapScrollY,
7692
+ document_height: snapDocHeight,
7693
+ document_width: snapDocWidth,
7694
+ click_count: clickCount, // final count for the whole sequence
7695
+ time_span: snapNow - snapStartTime, // first→last click duration
7696
+ element_area: snapArea,
7697
+ element_category: snapCategory,
7698
+ aria_label: snapAriaLabel
7699
+ }, snapElemData).catch(() => {});
7700
+ // Reset sequence state after the event is dispatched
7701
+ clickCount = 0;
7702
+ lastClickElement = null;
7703
+ }, 800);
7448
7704
  }
7449
7705
  } else {
7450
- clickCount = 1;
7706
+ // New element or gap > 1 s — start a fresh sequence
7707
+ if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
7708
+ clickCount = 1;
7709
+ rageStartTime = now;
7451
7710
  }
7452
7711
 
7453
7712
  // Check if this might be a dead click (non-interactive element)
@@ -7471,6 +7730,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7471
7730
  elementCategory,
7472
7731
  timestamp: now,
7473
7732
  url: window.location.href,
7733
+ snapshotMutationCount: domMutationCount, // DOM state at click time
7474
7734
  clickX,
7475
7735
  clickY,
7476
7736
  page_x: event.pageX,
@@ -7481,13 +7741,17 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7481
7741
  document_width: docWidth
7482
7742
  });
7483
7743
 
7484
- // Check after 1 second if navigation occurred or if it's still a dead click
7744
+ // Check after 800 ms: if URL unchanged AND DOM unchanged true dead click.
7745
+ // Using 800 ms (down from 1 s) tightens the window while still allowing
7746
+ // async renders (React setState, animations) to complete before we decide.
7485
7747
  setTimeout(() => {
7486
7748
  checkNavigation();
7487
-
7749
+
7488
7750
  const pendingClick = pendingDeadClicks.get(clickId);
7489
- if (pendingClick && window.location.href === pendingClick.url) {
7490
- // No navigation occurred, this is a dead click
7751
+ if (pendingClick &&
7752
+ window.location.href === pendingClick.url &&
7753
+ domMutationCount === pendingClick.snapshotMutationCount) {
7754
+ // No URL change AND no DOM mutations → confirmed dead click
7491
7755
  pendingDeadClicks.delete(clickId);
7492
7756
 
7493
7757
  EventsManager.trackAutoEvent('dead_click', {
@@ -7507,10 +7771,10 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7507
7771
  console.error('❌ [AutoEvents] Failed to track dead_click:', err);
7508
7772
  });
7509
7773
  } else if (pendingClick) {
7510
- // Navigation occurred, not a dead click
7774
+ // URL changed or DOM mutated — interaction was real, not a dead click
7511
7775
  pendingDeadClicks.delete(clickId);
7512
7776
  }
7513
- }, 1000);
7777
+ }, 800);
7514
7778
  }
7515
7779
 
7516
7780
  // Track regular click with enhanced data (viewport + page-relative for heatmaps)