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/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:
|
|
5293
|
-
cls:
|
|
5294
|
-
inp:
|
|
5295
|
-
fcp:
|
|
5296
|
-
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:
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
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
|
-
//
|
|
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:
|
|
5325
|
-
|
|
5326
|
-
|
|
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
|
|
5410
|
+
* NetworkTracker - Tracks network errors (XHR + fetch)
|
|
5411
|
+
*
|
|
5412
|
+
* XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
|
|
5335
5413
|
*
|
|
5336
|
-
*
|
|
5337
|
-
*
|
|
5338
|
-
*
|
|
5339
|
-
*
|
|
5340
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
5466
|
+
|
|
5467
|
+
// ── fetch: track same-origin HTTP errors from the host application ──
|
|
5468
|
+
// We intercept only same-origin (or explicitly relative) URLs so that
|
|
5469
|
+
// third-party CDN / analytics / auth calls are never touched.
|
|
5470
|
+
const originalFetch = window.fetch;
|
|
5471
|
+
if (typeof originalFetch === 'function') {
|
|
5472
|
+
window.fetch = function(input, init) {
|
|
5473
|
+
const startTime = Date.now();
|
|
5474
|
+
|
|
5475
|
+
// Resolve the URL string regardless of whether input is a string,
|
|
5476
|
+
// URL object, or Request object.
|
|
5477
|
+
let urlStr = '';
|
|
5478
|
+
try {
|
|
5479
|
+
urlStr = (input instanceof Request ? input.url : String(input)) || '';
|
|
5480
|
+
} catch (_) {}
|
|
5481
|
+
|
|
5482
|
+
// Determine if same-origin (relative URLs are always same-origin)
|
|
5483
|
+
let isSameOrigin = false;
|
|
5484
|
+
try {
|
|
5485
|
+
if (urlStr.startsWith('/') || urlStr.startsWith('./') || urlStr.startsWith('../')) {
|
|
5486
|
+
isSameOrigin = true;
|
|
5487
|
+
} else {
|
|
5488
|
+
const parsed = new URL(urlStr, window.location.href);
|
|
5489
|
+
isSameOrigin = parsed.origin === window.location.origin;
|
|
5490
|
+
}
|
|
5491
|
+
} catch (_) {}
|
|
5492
|
+
|
|
5493
|
+
// Skip SDK's own backend calls — already handled by APIClient.send()
|
|
5494
|
+
if (urlStr.includes('sdkapi.cryptique.io')) {
|
|
5495
|
+
return originalFetch.apply(this, arguments);
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5498
|
+
// Pass cross-origin calls through untouched
|
|
5499
|
+
if (!isSameOrigin) {
|
|
5500
|
+
return originalFetch.apply(this, arguments);
|
|
5501
|
+
}
|
|
5502
|
+
|
|
5503
|
+
const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
|
|
5504
|
+
|
|
5505
|
+
return originalFetch.apply(this, arguments).then(
|
|
5506
|
+
(response) => {
|
|
5507
|
+
if (response.status >= 400) {
|
|
5508
|
+
EventsManager.trackAutoEvent('network_error', {
|
|
5509
|
+
url: urlStr,
|
|
5510
|
+
method: method,
|
|
5511
|
+
status: response.status,
|
|
5512
|
+
status_text: `HTTP ${response.status} Error`,
|
|
5513
|
+
duration_ms: Date.now() - startTime
|
|
5514
|
+
});
|
|
5515
|
+
}
|
|
5516
|
+
return response;
|
|
5517
|
+
},
|
|
5518
|
+
(err) => {
|
|
5519
|
+
// Network-level failure (offline, DNS, CORS preflight, etc.)
|
|
5520
|
+
EventsManager.trackAutoEvent('network_error', {
|
|
5521
|
+
url: urlStr,
|
|
5522
|
+
method: method,
|
|
5523
|
+
status: 0,
|
|
5524
|
+
status_text: 'Fetch Network Error',
|
|
5525
|
+
duration_ms: Date.now() - startTime
|
|
5526
|
+
});
|
|
5527
|
+
throw err; // re-throw so the calling code still gets the error
|
|
5528
|
+
}
|
|
5529
|
+
);
|
|
5530
|
+
};
|
|
5388
5531
|
}
|
|
5532
|
+
} catch (error) {
|
|
5533
|
+
}
|
|
5389
5534
|
},
|
|
5390
5535
|
|
|
5391
5536
|
/**
|
|
@@ -5658,8 +5803,54 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5658
5803
|
let clsScore = 0;
|
|
5659
5804
|
let longTasksCount = 0;
|
|
5660
5805
|
let maxLongTaskMs = 0;
|
|
5806
|
+
let longTaskTimestamps = []; // [{start_ms, duration_ms}] capped at 20
|
|
5661
5807
|
let inpMs = 0;
|
|
5662
5808
|
|
|
5809
|
+
// ── Exit type detection ─────────────────────────────────────────────
|
|
5810
|
+
// Classifies HOW the user left this page so the AI can distinguish
|
|
5811
|
+
// explicit rejection (back-button) from fatigue (tab-close) from
|
|
5812
|
+
// internal navigation (SPA route change or page refresh).
|
|
5813
|
+
//
|
|
5814
|
+
// Values:
|
|
5815
|
+
// 'back_forward' – browser back/forward button (popstate)
|
|
5816
|
+
// 'spa_navigation' – SPA pushState / replaceState route change
|
|
5817
|
+
// 'reload' – page refresh (F5 / Ctrl+R / location.reload)
|
|
5818
|
+
// 'tab_close' – tab or window closed (beforeunload, no subsequent nav)
|
|
5819
|
+
// 'unknown' – couldn't be determined (visibilitychange, iOS Safari, etc.)
|
|
5820
|
+
let pageExitType = 'unknown';
|
|
5821
|
+
|
|
5822
|
+
window.addEventListener('popstate', () => {
|
|
5823
|
+
pageExitType = 'back_forward';
|
|
5824
|
+
}, { once: true });
|
|
5825
|
+
|
|
5826
|
+
// Intercept SPA navigation — hook into the already-overridden pushState /
|
|
5827
|
+
// replaceState (the overrides were applied in Section 11: SessionTracker).
|
|
5828
|
+
// We listen to the 'cq:spa-navigation' custom event that we dispatch below,
|
|
5829
|
+
// rather than overriding pushState a second time.
|
|
5830
|
+
// Detect by watching for our own navigation event (fired by SessionTracker).
|
|
5831
|
+
window.addEventListener('cq:spa-navigation', () => {
|
|
5832
|
+
if (pageExitType === 'unknown') pageExitType = 'spa_navigation';
|
|
5833
|
+
}, { once: true });
|
|
5834
|
+
|
|
5835
|
+
// Detect reload via PerformanceNavigationTiming.type === 'reload'
|
|
5836
|
+
try {
|
|
5837
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
5838
|
+
if (navEntry && navEntry.type === 'reload') {
|
|
5839
|
+
// The CURRENT page was reached by reload; if user reloads again
|
|
5840
|
+
// we'll see another 'reload' on the next load — set a provisional flag.
|
|
5841
|
+
pageExitType = 'reload_arrived'; // overridden by popstate/spa if applicable
|
|
5842
|
+
}
|
|
5843
|
+
} catch (_) {}
|
|
5844
|
+
|
|
5845
|
+
window.addEventListener('beforeunload', () => {
|
|
5846
|
+
// beforeunload fires for tab-close AND for hard navigations (link clicks,
|
|
5847
|
+
// address-bar entry). SPA navigations do NOT fire it. popstate fires
|
|
5848
|
+
// before pagehide for back/forward so the flag is already set.
|
|
5849
|
+
if (pageExitType === 'unknown' || pageExitType === 'reload_arrived') {
|
|
5850
|
+
pageExitType = 'tab_close';
|
|
5851
|
+
}
|
|
5852
|
+
});
|
|
5853
|
+
|
|
5663
5854
|
const pageLoadTime = Date.now();
|
|
5664
5855
|
let lastSigScrollY = window.scrollY;
|
|
5665
5856
|
let lastSigDir = 0; // 1=down, -1=up
|
|
@@ -5691,6 +5882,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5691
5882
|
for (const e of list.getEntries()) {
|
|
5692
5883
|
longTasksCount++;
|
|
5693
5884
|
if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
|
|
5885
|
+
// Record when each long task started (ms since page load, same reference
|
|
5886
|
+
// frame as first_interaction_ms) so the AI can correlate main-thread
|
|
5887
|
+
// blocking with the moment a user first tried to interact.
|
|
5888
|
+
// Cap at 20 — pathological pages sample the earliest 20 tasks.
|
|
5889
|
+
if (longTaskTimestamps.length < 20) {
|
|
5890
|
+
longTaskTimestamps.push({
|
|
5891
|
+
start_ms: Math.round(e.startTime),
|
|
5892
|
+
duration_ms: Math.round(e.duration)
|
|
5893
|
+
});
|
|
5894
|
+
}
|
|
5694
5895
|
}
|
|
5695
5896
|
}).observe({ type: 'longtask', buffered: true });
|
|
5696
5897
|
} catch (_) {}
|
|
@@ -5931,6 +6132,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5931
6132
|
.sort((a, b) => b.dwell_ms - a.dwell_ms)
|
|
5932
6133
|
.slice(0, 20);
|
|
5933
6134
|
|
|
6135
|
+
// Finalise exit_type: if it's still the provisional reload_arrived value
|
|
6136
|
+
// (user reloaded this page and didn't navigate away via back/spa), keep it
|
|
6137
|
+
// as 'reload' since the beforeunload didn't fire (e.g. visibilitychange path).
|
|
6138
|
+
const finalExitType = pageExitType === 'reload_arrived' ? 'reload' : pageExitType;
|
|
6139
|
+
|
|
5934
6140
|
EventsManager.trackAutoEvent('page_summary', {
|
|
5935
6141
|
scroll_reversals: scrollReversals,
|
|
5936
6142
|
scroll_reversal_depths: scrollReversalDepths,
|
|
@@ -5947,7 +6153,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5947
6153
|
cls_score: clsScore,
|
|
5948
6154
|
long_tasks_count: longTasksCount,
|
|
5949
6155
|
max_long_task_ms: maxLongTaskMs,
|
|
6156
|
+
long_task_timestamps: longTaskTimestamps,
|
|
5950
6157
|
inp_ms: inpMs,
|
|
6158
|
+
exit_type: finalExitType,
|
|
5951
6159
|
document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
|
|
5952
6160
|
document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
|
|
5953
6161
|
viewport_width: window.innerWidth,
|
|
@@ -6654,6 +6862,13 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
6654
6862
|
*/
|
|
6655
6863
|
async trackAutoEvent(eventName, autoEventData = {}, elementData = {}) {
|
|
6656
6864
|
try {
|
|
6865
|
+
// Keep a running record of the last auto-event name so that js_error
|
|
6866
|
+
// events can include a "last_event_name" field for context.
|
|
6867
|
+
// Skip error-category events themselves to avoid circular noise.
|
|
6868
|
+
if (eventName && eventName !== 'js_error' && eventName !== 'network_error') {
|
|
6869
|
+
EventsManager._lastAutoEventName = eventName;
|
|
6870
|
+
}
|
|
6871
|
+
|
|
6657
6872
|
// Check if auto events should be tracked (silently skip if disabled)
|
|
6658
6873
|
if (!shouldTrackAutoEvents()) {
|
|
6659
6874
|
return; // Silently skip - don't log to avoid console spam
|
|
@@ -7332,8 +7547,23 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7332
7547
|
let clickCount = 0;
|
|
7333
7548
|
let lastClickTime = 0;
|
|
7334
7549
|
let lastClickElement = null;
|
|
7550
|
+
let rageStartTime = 0; // Timestamp of the first click in the current rage sequence
|
|
7551
|
+
let rageTimer = null; // Debounce timer — fires ONE rage_click after sequence ends
|
|
7335
7552
|
const pendingDeadClicks = new Map(); // Track clicks that might be dead clicks
|
|
7336
7553
|
|
|
7554
|
+
// MutationObserver counter — incremented on ANY DOM change.
|
|
7555
|
+
// Dead-click detection compares the count at click-time vs 800 ms later:
|
|
7556
|
+
// if nothing changed (URL stable + zero new mutations) it's a true dead click.
|
|
7557
|
+
// This prevents false positives in SPAs where interactions update the DOM
|
|
7558
|
+
// without changing the URL (modals, dropdowns, cart updates, tab panels, etc.).
|
|
7559
|
+
let domMutationCount = 0;
|
|
7560
|
+
try {
|
|
7561
|
+
new MutationObserver(() => { domMutationCount++; })
|
|
7562
|
+
.observe(document.documentElement, {
|
|
7563
|
+
childList: true, subtree: true, attributes: true, characterData: false
|
|
7564
|
+
});
|
|
7565
|
+
} catch (_) {}
|
|
7566
|
+
|
|
7337
7567
|
// Helper function to check if element is interactive
|
|
7338
7568
|
const isInteractiveElement = (el) => {
|
|
7339
7569
|
if (!el) return false;
|
|
@@ -7423,31 +7653,60 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7423
7653
|
elementData.image_height = element.naturalHeight || null;
|
|
7424
7654
|
}
|
|
7425
7655
|
|
|
7426
|
-
// Detect rage clicks
|
|
7656
|
+
// Detect rage clicks — fire exactly ONE event per sequence when the burst ends.
|
|
7657
|
+
// A "sequence" is N rapid clicks on the same element within 1 s of each other.
|
|
7658
|
+
// We debounce 800 ms after the last click so the event carries the FINAL count
|
|
7659
|
+
// and the total time-span from first to last click (not just the last interval).
|
|
7427
7660
|
if (element === lastClickElement && now - lastClickTime < 1000) {
|
|
7428
7661
|
clickCount++;
|
|
7662
|
+
|
|
7429
7663
|
if (clickCount >= 3) {
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7664
|
+
// Cancel any previously scheduled fire — sequence is still ongoing
|
|
7665
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7666
|
+
|
|
7667
|
+
// Snapshot mutable values at the time of THIS click
|
|
7668
|
+
const snapScrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
|
|
7669
|
+
const snapScrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
|
|
7670
|
+
const snapDocHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
|
|
7671
|
+
const snapDocWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
|
|
7672
|
+
const snapClientX = event.clientX;
|
|
7673
|
+
const snapClientY = event.clientY;
|
|
7674
|
+
const snapPageX = event.pageX;
|
|
7675
|
+
const snapPageY = event.pageY;
|
|
7676
|
+
const snapElemData = Object.assign({}, elementData);
|
|
7677
|
+
const snapCategory = elementCategory;
|
|
7678
|
+
const snapArea = element.offsetWidth * element.offsetHeight;
|
|
7679
|
+
const snapAriaLabel = element.getAttribute('aria-label') || null;
|
|
7680
|
+
const snapStartTime = rageStartTime;
|
|
7681
|
+
const snapNow = now;
|
|
7682
|
+
|
|
7683
|
+
// Fire after 800 ms of inactivity — reads the FINAL clickCount from closure
|
|
7684
|
+
rageTimer = setTimeout(() => {
|
|
7685
|
+
rageTimer = null;
|
|
7686
|
+
EventsManager.trackAutoEvent('rage_click', {
|
|
7687
|
+
click_coordinates: { x: snapClientX, y: snapClientY },
|
|
7688
|
+
page_x: snapPageX,
|
|
7689
|
+
page_y: snapPageY,
|
|
7690
|
+
scroll_x: snapScrollX,
|
|
7691
|
+
scroll_y: snapScrollY,
|
|
7692
|
+
document_height: snapDocHeight,
|
|
7693
|
+
document_width: snapDocWidth,
|
|
7694
|
+
click_count: clickCount, // final count for the whole sequence
|
|
7695
|
+
time_span: snapNow - snapStartTime, // first→last click duration
|
|
7696
|
+
element_area: snapArea,
|
|
7697
|
+
element_category: snapCategory,
|
|
7698
|
+
aria_label: snapAriaLabel
|
|
7699
|
+
}, snapElemData).catch(() => {});
|
|
7700
|
+
// Reset sequence state after the event is dispatched
|
|
7701
|
+
clickCount = 0;
|
|
7702
|
+
lastClickElement = null;
|
|
7703
|
+
}, 800);
|
|
7448
7704
|
}
|
|
7449
7705
|
} else {
|
|
7450
|
-
|
|
7706
|
+
// New element or gap > 1 s — start a fresh sequence
|
|
7707
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7708
|
+
clickCount = 1;
|
|
7709
|
+
rageStartTime = now;
|
|
7451
7710
|
}
|
|
7452
7711
|
|
|
7453
7712
|
// Check if this might be a dead click (non-interactive element)
|
|
@@ -7471,6 +7730,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7471
7730
|
elementCategory,
|
|
7472
7731
|
timestamp: now,
|
|
7473
7732
|
url: window.location.href,
|
|
7733
|
+
snapshotMutationCount: domMutationCount, // DOM state at click time
|
|
7474
7734
|
clickX,
|
|
7475
7735
|
clickY,
|
|
7476
7736
|
page_x: event.pageX,
|
|
@@ -7481,13 +7741,17 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7481
7741
|
document_width: docWidth
|
|
7482
7742
|
});
|
|
7483
7743
|
|
|
7484
|
-
// Check after
|
|
7744
|
+
// Check after 800 ms: if URL unchanged AND DOM unchanged → true dead click.
|
|
7745
|
+
// Using 800 ms (down from 1 s) tightens the window while still allowing
|
|
7746
|
+
// async renders (React setState, animations) to complete before we decide.
|
|
7485
7747
|
setTimeout(() => {
|
|
7486
7748
|
checkNavigation();
|
|
7487
|
-
|
|
7749
|
+
|
|
7488
7750
|
const pendingClick = pendingDeadClicks.get(clickId);
|
|
7489
|
-
if (pendingClick &&
|
|
7490
|
-
|
|
7751
|
+
if (pendingClick &&
|
|
7752
|
+
window.location.href === pendingClick.url &&
|
|
7753
|
+
domMutationCount === pendingClick.snapshotMutationCount) {
|
|
7754
|
+
// No URL change AND no DOM mutations → confirmed dead click
|
|
7491
7755
|
pendingDeadClicks.delete(clickId);
|
|
7492
7756
|
|
|
7493
7757
|
EventsManager.trackAutoEvent('dead_click', {
|
|
@@ -7507,10 +7771,10 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7507
7771
|
console.error('❌ [AutoEvents] Failed to track dead_click:', err);
|
|
7508
7772
|
});
|
|
7509
7773
|
} else if (pendingClick) {
|
|
7510
|
-
//
|
|
7774
|
+
// URL changed or DOM mutated — interaction was real, not a dead click
|
|
7511
7775
|
pendingDeadClicks.delete(clickId);
|
|
7512
7776
|
}
|
|
7513
|
-
},
|
|
7777
|
+
}, 800);
|
|
7514
7778
|
}
|
|
7515
7779
|
|
|
7516
7780
|
// Track regular click with enhanced data (viewport + page-relative for heatmaps)
|