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