cryptique-sdk 1.2.18 → 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)
5335
5411
  *
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.
5412
+ * XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
5413
+ *
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
  /**
@@ -5639,7 +5784,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5639
5784
  */
5640
5785
  startPageSummaryTracking() {
5641
5786
  try {
5642
- const GRID_SIZE = 40;
5787
+ const GRID_SIZE = 60;
5643
5788
 
5644
5789
  // In-memory accumulators — never sent to the server individually
5645
5790
  const moveGrid = new Map(); // cellKey → move count
@@ -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 (_) {}
@@ -5760,6 +5961,62 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5760
5961
  } catch (_) {}
5761
5962
  }, { passive: true });
5762
5963
 
5964
+ // ── Viewport visibility → viewport_element_dwells (replaces individual element_view events)
5965
+ const viewportDwellsMap = new Map(); // elemKey → {tag,id,cls,text,dwell_ms,x,y,w,h}
5966
+ const vpEnterTimes = new Map(); // elemKey → {enterTime,x,y,w,h}
5967
+ try {
5968
+ if (typeof IntersectionObserver !== 'undefined') {
5969
+ const VP_SELECTOR = [
5970
+ '[id]:not([id=""])', '[data-cq-track]',
5971
+ 'button', 'a[href]', 'input:not([type=hidden])', 'select', 'textarea',
5972
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img[alt]',
5973
+ '[role="button"]', '[role="tab"]', '[role="menuitem"]', '[role="link"]'
5974
+ ].join(', ');
5975
+ const vpKey = el => `${el.tagName}|${el.id || ''}|${(el.textContent || '').trim().slice(0, 30)}`;
5976
+ const vpObserver = new IntersectionObserver(entries => {
5977
+ entries.forEach(entry => {
5978
+ const el = entry.target;
5979
+ const key = vpKey(el);
5980
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5981
+ if (!vpEnterTimes.has(key)) {
5982
+ const r = el.getBoundingClientRect();
5983
+ vpEnterTimes.set(key, {
5984
+ enterTime: Date.now(),
5985
+ x: Math.round(r.left + window.scrollX),
5986
+ y: Math.round(r.top + window.scrollY),
5987
+ w: Math.round(r.width),
5988
+ h: Math.round(r.height),
5989
+ });
5990
+ }
5991
+ } else {
5992
+ const rec = vpEnterTimes.get(key);
5993
+ if (rec) {
5994
+ const dwell = Date.now() - rec.enterTime;
5995
+ vpEnterTimes.delete(key);
5996
+ if (dwell >= 1000) {
5997
+ const ex = viewportDwellsMap.get(key);
5998
+ if (ex) { ex.dwell_ms += dwell; }
5999
+ else {
6000
+ viewportDwellsMap.set(key, {
6001
+ tag: el.tagName.toLowerCase(),
6002
+ id: el.id || '',
6003
+ cls: el.className ? String(el.className).trim().slice(0, 60) : '',
6004
+ text: (el.textContent || '').trim().slice(0, 60),
6005
+ dwell_ms: dwell,
6006
+ x: rec.x, y: rec.y, w: rec.w, h: rec.h,
6007
+ });
6008
+ }
6009
+ }
6010
+ }
6011
+ }
6012
+ });
6013
+ }, { threshold: [0.5] });
6014
+ document.querySelectorAll(VP_SELECTOR).forEach(el => {
6015
+ try { if (el.getBoundingClientRect().height >= 20) vpObserver.observe(el); } catch (_) {}
6016
+ });
6017
+ }
6018
+ } catch (_) {}
6019
+
5763
6020
  // ── Tab visibility → tab_switches + total_tab_hidden_ms
5764
6021
  document.addEventListener('visibilitychange', () => {
5765
6022
  if (document.hidden) {
@@ -5852,12 +6109,34 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5852
6109
  if (!hasData) return;
5853
6110
  summarySent = true;
5854
6111
  try {
6112
+ // Flush still-visible elements into viewportDwellsMap before summarising
6113
+ vpEnterTimes.forEach((rec, key) => {
6114
+ const dwell = Date.now() - rec.enterTime;
6115
+ if (dwell < 1000) return;
6116
+ const ex = viewportDwellsMap.get(key);
6117
+ if (ex) { ex.dwell_ms += dwell; }
6118
+ else {
6119
+ const parts = key.split('|');
6120
+ viewportDwellsMap.set(key, { tag: (parts[0] || '').toLowerCase(), id: parts[1] || '', cls: '', text: parts[2] || '', dwell_ms: dwell, x: rec.x, y: rec.y, w: rec.w, h: rec.h });
6121
+ }
6122
+ });
6123
+ // Serialize viewport_element_dwells: filter ≥ 1000ms, sort desc, cap at 25
6124
+ const viewportElementDwells = Array.from(viewportDwellsMap.values())
6125
+ .filter(e => e.dwell_ms >= 1000)
6126
+ .sort((a, b) => b.dwell_ms - a.dwell_ms)
6127
+ .slice(0, 25);
6128
+
5855
6129
  // Serialize element_dwells: filter ≥ 300ms, sort desc, cap at 20
5856
6130
  const elementDwells = Array.from(elementDwellsMap.values())
5857
6131
  .filter(e => e.dwell_ms >= 300)
5858
6132
  .sort((a, b) => b.dwell_ms - a.dwell_ms)
5859
6133
  .slice(0, 20);
5860
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
+
5861
6140
  EventsManager.trackAutoEvent('page_summary', {
5862
6141
  scroll_reversals: scrollReversals,
5863
6142
  scroll_reversal_depths: scrollReversalDepths,
@@ -5867,13 +6146,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5867
6146
  hover_grid: serializeGrid(hoverGrid),
5868
6147
  field_dwells: fieldDwells,
5869
6148
  element_dwells: elementDwells,
6149
+ viewport_element_dwells: viewportElementDwells,
5870
6150
  first_interaction_ms: firstInteractionMs,
5871
6151
  exit_intent_count: exitIntentCount,
5872
6152
  exit_intent_last_scroll_depth: exitIntentLastScrollDepth,
5873
6153
  cls_score: clsScore,
5874
6154
  long_tasks_count: longTasksCount,
5875
6155
  max_long_task_ms: maxLongTaskMs,
6156
+ long_task_timestamps: longTaskTimestamps,
5876
6157
  inp_ms: inpMs,
6158
+ exit_type: finalExitType,
5877
6159
  document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5878
6160
  document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5879
6161
  viewport_width: window.innerWidth,
@@ -5913,7 +6195,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5913
6195
  this.startNetworkTracking();
5914
6196
  this.startAdvancedFormTracking();
5915
6197
  this.startFormAbandonmentTracking();
5916
- this.startElementVisibilityTracking();
6198
+ // element_view events replaced by viewport_element_dwells inside page_summary
6199
+ // this.startElementVisibilityTracking();
5917
6200
  this.startPageSummaryTracking();
5918
6201
  }
5919
6202
  };
@@ -6579,6 +6862,13 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6579
6862
  */
6580
6863
  async trackAutoEvent(eventName, autoEventData = {}, elementData = {}) {
6581
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
+
6582
6872
  // Check if auto events should be tracked (silently skip if disabled)
6583
6873
  if (!shouldTrackAutoEvents()) {
6584
6874
  return; // Silently skip - don't log to avoid console spam
@@ -7257,8 +7547,23 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7257
7547
  let clickCount = 0;
7258
7548
  let lastClickTime = 0;
7259
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
7260
7552
  const pendingDeadClicks = new Map(); // Track clicks that might be dead clicks
7261
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
+
7262
7567
  // Helper function to check if element is interactive
7263
7568
  const isInteractiveElement = (el) => {
7264
7569
  if (!el) return false;
@@ -7348,31 +7653,60 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7348
7653
  elementData.image_height = element.naturalHeight || null;
7349
7654
  }
7350
7655
 
7351
- // 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).
7352
7660
  if (element === lastClickElement && now - lastClickTime < 1000) {
7353
7661
  clickCount++;
7662
+
7354
7663
  if (clickCount >= 3) {
7355
- const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
7356
- const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
7357
- const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7358
- const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
7359
- EventsManager.trackAutoEvent('rage_click', {
7360
- click_coordinates: { x: event.clientX, y: event.clientY },
7361
- page_x: event.pageX,
7362
- page_y: event.pageY,
7363
- scroll_x: scrollX,
7364
- scroll_y: scrollY,
7365
- document_height: docHeight,
7366
- document_width: docWidth,
7367
- click_count: clickCount,
7368
- time_span: now - lastClickTime,
7369
- element_area: element.offsetWidth * element.offsetHeight,
7370
- element_category: elementCategory
7371
- }, elementData).catch(err => {
7372
- });
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);
7373
7704
  }
7374
7705
  } else {
7375
- 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;
7376
7710
  }
7377
7711
 
7378
7712
  // Check if this might be a dead click (non-interactive element)
@@ -7396,6 +7730,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7396
7730
  elementCategory,
7397
7731
  timestamp: now,
7398
7732
  url: window.location.href,
7733
+ snapshotMutationCount: domMutationCount, // DOM state at click time
7399
7734
  clickX,
7400
7735
  clickY,
7401
7736
  page_x: event.pageX,
@@ -7406,13 +7741,17 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7406
7741
  document_width: docWidth
7407
7742
  });
7408
7743
 
7409
- // 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.
7410
7747
  setTimeout(() => {
7411
7748
  checkNavigation();
7412
-
7749
+
7413
7750
  const pendingClick = pendingDeadClicks.get(clickId);
7414
- if (pendingClick && window.location.href === pendingClick.url) {
7415
- // 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
7416
7755
  pendingDeadClicks.delete(clickId);
7417
7756
 
7418
7757
  EventsManager.trackAutoEvent('dead_click', {
@@ -7432,10 +7771,10 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7432
7771
  console.error('❌ [AutoEvents] Failed to track dead_click:', err);
7433
7772
  });
7434
7773
  } else if (pendingClick) {
7435
- // Navigation occurred, not a dead click
7774
+ // URL changed or DOM mutated — interaction was real, not a dead click
7436
7775
  pendingDeadClicks.delete(clickId);
7437
7776
  }
7438
- }, 1000);
7777
+ }, 800);
7439
7778
  }
7440
7779
 
7441
7780
  // Track regular click with enhanced data (viewport + page-relative for heatmaps)