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