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 +319 -55
- package/lib/esm/index.js +319 -55
- package/lib/umd/index.js +319 -55
- package/package.json +1 -1
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:
|
|
5291
|
-
cls:
|
|
5292
|
-
inp:
|
|
5293
|
-
fcp:
|
|
5294
|
-
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:
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
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
|
-
//
|
|
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:
|
|
5323
|
-
|
|
5324
|
-
|
|
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
|
|
5408
|
+
* NetworkTracker - Tracks network errors (XHR + fetch)
|
|
5409
|
+
*
|
|
5410
|
+
* XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
|
|
5333
5411
|
*
|
|
5334
|
-
*
|
|
5335
|
-
*
|
|
5336
|
-
*
|
|
5337
|
-
*
|
|
5338
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
7488
|
-
|
|
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
|
-
//
|
|
7772
|
+
// URL changed or DOM mutated — interaction was real, not a dead click
|
|
7509
7773
|
pendingDeadClicks.delete(clickId);
|
|
7510
7774
|
}
|
|
7511
|
-
},
|
|
7775
|
+
}, 800);
|
|
7512
7776
|
}
|
|
7513
7777
|
|
|
7514
7778
|
// Track regular click with enhanced data (viewport + page-relative for heatmaps)
|