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 +396 -57
- package/lib/esm/index.js +396 -57
- package/lib/umd/index.js +396 -57
- package/package.json +2 -2
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)
|
|
5333
5409
|
*
|
|
5334
|
-
*
|
|
5335
|
-
*
|
|
5336
|
-
*
|
|
5337
|
-
*
|
|
5338
|
-
*
|
|
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
|
-
//
|
|
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
|
/**
|
|
@@ -5637,7 +5782,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5637
5782
|
*/
|
|
5638
5783
|
startPageSummaryTracking() {
|
|
5639
5784
|
try {
|
|
5640
|
-
const GRID_SIZE =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
7413
|
-
|
|
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
|
-
//
|
|
7772
|
+
// URL changed or DOM mutated — interaction was real, not a dead click
|
|
7434
7773
|
pendingDeadClicks.delete(clickId);
|
|
7435
7774
|
}
|
|
7436
|
-
},
|
|
7775
|
+
}, 800);
|
|
7437
7776
|
}
|
|
7438
7777
|
|
|
7439
7778
|
// Track regular click with enhanced data (viewport + page-relative for heatmaps)
|