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/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)
|
|
5335
5411
|
*
|
|
5336
|
-
*
|
|
5337
|
-
*
|
|
5338
|
-
*
|
|
5339
|
-
*
|
|
5340
|
-
*
|
|
5412
|
+
* XHR: only wraps SDK calls to sdkapi.cryptique.io (unchanged behaviour).
|
|
5413
|
+
*
|
|
5414
|
+
* fetch: wraps window.fetch to track HTTP ≥ 400 responses and network
|
|
5415
|
+
* failures for same-origin requests made by the host application.
|
|
5416
|
+
* Third-party (cross-origin) fetches are passed through untouched to
|
|
5417
|
+
* minimise DevTools attribution noise. The SDK's own fetch calls via
|
|
5418
|
+
* APIClient.send() are already error-handled and are excluded by the
|
|
5419
|
+
* same-origin guard (they go to sdkapi.cryptique.io, not the app origin).
|
|
5341
5420
|
*/
|
|
5342
5421
|
startNetworkTracking() {
|
|
5343
5422
|
try {
|
|
5344
|
-
//
|
|
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
|
/**
|
|
@@ -5639,7 +5784,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5639
5784
|
*/
|
|
5640
5785
|
startPageSummaryTracking() {
|
|
5641
5786
|
try {
|
|
5642
|
-
const GRID_SIZE =
|
|
5787
|
+
const GRID_SIZE = 60;
|
|
5643
5788
|
|
|
5644
5789
|
// In-memory accumulators — never sent to the server individually
|
|
5645
5790
|
const moveGrid = new Map(); // cellKey → move count
|
|
@@ -5658,8 +5803,54 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5658
5803
|
let clsScore = 0;
|
|
5659
5804
|
let longTasksCount = 0;
|
|
5660
5805
|
let maxLongTaskMs = 0;
|
|
5806
|
+
let longTaskTimestamps = []; // [{start_ms, duration_ms}] capped at 20
|
|
5661
5807
|
let inpMs = 0;
|
|
5662
5808
|
|
|
5809
|
+
// ── Exit type detection ─────────────────────────────────────────────
|
|
5810
|
+
// Classifies HOW the user left this page so the AI can distinguish
|
|
5811
|
+
// explicit rejection (back-button) from fatigue (tab-close) from
|
|
5812
|
+
// internal navigation (SPA route change or page refresh).
|
|
5813
|
+
//
|
|
5814
|
+
// Values:
|
|
5815
|
+
// 'back_forward' – browser back/forward button (popstate)
|
|
5816
|
+
// 'spa_navigation' – SPA pushState / replaceState route change
|
|
5817
|
+
// 'reload' – page refresh (F5 / Ctrl+R / location.reload)
|
|
5818
|
+
// 'tab_close' – tab or window closed (beforeunload, no subsequent nav)
|
|
5819
|
+
// 'unknown' – couldn't be determined (visibilitychange, iOS Safari, etc.)
|
|
5820
|
+
let pageExitType = 'unknown';
|
|
5821
|
+
|
|
5822
|
+
window.addEventListener('popstate', () => {
|
|
5823
|
+
pageExitType = 'back_forward';
|
|
5824
|
+
}, { once: true });
|
|
5825
|
+
|
|
5826
|
+
// Intercept SPA navigation — hook into the already-overridden pushState /
|
|
5827
|
+
// replaceState (the overrides were applied in Section 11: SessionTracker).
|
|
5828
|
+
// We listen to the 'cq:spa-navigation' custom event that we dispatch below,
|
|
5829
|
+
// rather than overriding pushState a second time.
|
|
5830
|
+
// Detect by watching for our own navigation event (fired by SessionTracker).
|
|
5831
|
+
window.addEventListener('cq:spa-navigation', () => {
|
|
5832
|
+
if (pageExitType === 'unknown') pageExitType = 'spa_navigation';
|
|
5833
|
+
}, { once: true });
|
|
5834
|
+
|
|
5835
|
+
// Detect reload via PerformanceNavigationTiming.type === 'reload'
|
|
5836
|
+
try {
|
|
5837
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
5838
|
+
if (navEntry && navEntry.type === 'reload') {
|
|
5839
|
+
// The CURRENT page was reached by reload; if user reloads again
|
|
5840
|
+
// we'll see another 'reload' on the next load — set a provisional flag.
|
|
5841
|
+
pageExitType = 'reload_arrived'; // overridden by popstate/spa if applicable
|
|
5842
|
+
}
|
|
5843
|
+
} catch (_) {}
|
|
5844
|
+
|
|
5845
|
+
window.addEventListener('beforeunload', () => {
|
|
5846
|
+
// beforeunload fires for tab-close AND for hard navigations (link clicks,
|
|
5847
|
+
// address-bar entry). SPA navigations do NOT fire it. popstate fires
|
|
5848
|
+
// before pagehide for back/forward so the flag is already set.
|
|
5849
|
+
if (pageExitType === 'unknown' || pageExitType === 'reload_arrived') {
|
|
5850
|
+
pageExitType = 'tab_close';
|
|
5851
|
+
}
|
|
5852
|
+
});
|
|
5853
|
+
|
|
5663
5854
|
const pageLoadTime = Date.now();
|
|
5664
5855
|
let lastSigScrollY = window.scrollY;
|
|
5665
5856
|
let lastSigDir = 0; // 1=down, -1=up
|
|
@@ -5691,6 +5882,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5691
5882
|
for (const e of list.getEntries()) {
|
|
5692
5883
|
longTasksCount++;
|
|
5693
5884
|
if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
|
|
5885
|
+
// Record when each long task started (ms since page load, same reference
|
|
5886
|
+
// frame as first_interaction_ms) so the AI can correlate main-thread
|
|
5887
|
+
// blocking with the moment a user first tried to interact.
|
|
5888
|
+
// Cap at 20 — pathological pages sample the earliest 20 tasks.
|
|
5889
|
+
if (longTaskTimestamps.length < 20) {
|
|
5890
|
+
longTaskTimestamps.push({
|
|
5891
|
+
start_ms: Math.round(e.startTime),
|
|
5892
|
+
duration_ms: Math.round(e.duration)
|
|
5893
|
+
});
|
|
5894
|
+
}
|
|
5694
5895
|
}
|
|
5695
5896
|
}).observe({ type: 'longtask', buffered: true });
|
|
5696
5897
|
} catch (_) {}
|
|
@@ -5760,6 +5961,62 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5760
5961
|
} catch (_) {}
|
|
5761
5962
|
}, { passive: true });
|
|
5762
5963
|
|
|
5964
|
+
// ── Viewport visibility → viewport_element_dwells (replaces individual element_view events)
|
|
5965
|
+
const viewportDwellsMap = new Map(); // elemKey → {tag,id,cls,text,dwell_ms,x,y,w,h}
|
|
5966
|
+
const vpEnterTimes = new Map(); // elemKey → {enterTime,x,y,w,h}
|
|
5967
|
+
try {
|
|
5968
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
5969
|
+
const VP_SELECTOR = [
|
|
5970
|
+
'[id]:not([id=""])', '[data-cq-track]',
|
|
5971
|
+
'button', 'a[href]', 'input:not([type=hidden])', 'select', 'textarea',
|
|
5972
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img[alt]',
|
|
5973
|
+
'[role="button"]', '[role="tab"]', '[role="menuitem"]', '[role="link"]'
|
|
5974
|
+
].join(', ');
|
|
5975
|
+
const vpKey = el => `${el.tagName}|${el.id || ''}|${(el.textContent || '').trim().slice(0, 30)}`;
|
|
5976
|
+
const vpObserver = new IntersectionObserver(entries => {
|
|
5977
|
+
entries.forEach(entry => {
|
|
5978
|
+
const el = entry.target;
|
|
5979
|
+
const key = vpKey(el);
|
|
5980
|
+
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
|
5981
|
+
if (!vpEnterTimes.has(key)) {
|
|
5982
|
+
const r = el.getBoundingClientRect();
|
|
5983
|
+
vpEnterTimes.set(key, {
|
|
5984
|
+
enterTime: Date.now(),
|
|
5985
|
+
x: Math.round(r.left + window.scrollX),
|
|
5986
|
+
y: Math.round(r.top + window.scrollY),
|
|
5987
|
+
w: Math.round(r.width),
|
|
5988
|
+
h: Math.round(r.height),
|
|
5989
|
+
});
|
|
5990
|
+
}
|
|
5991
|
+
} else {
|
|
5992
|
+
const rec = vpEnterTimes.get(key);
|
|
5993
|
+
if (rec) {
|
|
5994
|
+
const dwell = Date.now() - rec.enterTime;
|
|
5995
|
+
vpEnterTimes.delete(key);
|
|
5996
|
+
if (dwell >= 1000) {
|
|
5997
|
+
const ex = viewportDwellsMap.get(key);
|
|
5998
|
+
if (ex) { ex.dwell_ms += dwell; }
|
|
5999
|
+
else {
|
|
6000
|
+
viewportDwellsMap.set(key, {
|
|
6001
|
+
tag: el.tagName.toLowerCase(),
|
|
6002
|
+
id: el.id || '',
|
|
6003
|
+
cls: el.className ? String(el.className).trim().slice(0, 60) : '',
|
|
6004
|
+
text: (el.textContent || '').trim().slice(0, 60),
|
|
6005
|
+
dwell_ms: dwell,
|
|
6006
|
+
x: rec.x, y: rec.y, w: rec.w, h: rec.h,
|
|
6007
|
+
});
|
|
6008
|
+
}
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
}
|
|
6012
|
+
});
|
|
6013
|
+
}, { threshold: [0.5] });
|
|
6014
|
+
document.querySelectorAll(VP_SELECTOR).forEach(el => {
|
|
6015
|
+
try { if (el.getBoundingClientRect().height >= 20) vpObserver.observe(el); } catch (_) {}
|
|
6016
|
+
});
|
|
6017
|
+
}
|
|
6018
|
+
} catch (_) {}
|
|
6019
|
+
|
|
5763
6020
|
// ── Tab visibility → tab_switches + total_tab_hidden_ms
|
|
5764
6021
|
document.addEventListener('visibilitychange', () => {
|
|
5765
6022
|
if (document.hidden) {
|
|
@@ -5852,12 +6109,34 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5852
6109
|
if (!hasData) return;
|
|
5853
6110
|
summarySent = true;
|
|
5854
6111
|
try {
|
|
6112
|
+
// Flush still-visible elements into viewportDwellsMap before summarising
|
|
6113
|
+
vpEnterTimes.forEach((rec, key) => {
|
|
6114
|
+
const dwell = Date.now() - rec.enterTime;
|
|
6115
|
+
if (dwell < 1000) return;
|
|
6116
|
+
const ex = viewportDwellsMap.get(key);
|
|
6117
|
+
if (ex) { ex.dwell_ms += dwell; }
|
|
6118
|
+
else {
|
|
6119
|
+
const parts = key.split('|');
|
|
6120
|
+
viewportDwellsMap.set(key, { tag: (parts[0] || '').toLowerCase(), id: parts[1] || '', cls: '', text: parts[2] || '', dwell_ms: dwell, x: rec.x, y: rec.y, w: rec.w, h: rec.h });
|
|
6121
|
+
}
|
|
6122
|
+
});
|
|
6123
|
+
// Serialize viewport_element_dwells: filter ≥ 1000ms, sort desc, cap at 25
|
|
6124
|
+
const viewportElementDwells = Array.from(viewportDwellsMap.values())
|
|
6125
|
+
.filter(e => e.dwell_ms >= 1000)
|
|
6126
|
+
.sort((a, b) => b.dwell_ms - a.dwell_ms)
|
|
6127
|
+
.slice(0, 25);
|
|
6128
|
+
|
|
5855
6129
|
// Serialize element_dwells: filter ≥ 300ms, sort desc, cap at 20
|
|
5856
6130
|
const elementDwells = Array.from(elementDwellsMap.values())
|
|
5857
6131
|
.filter(e => e.dwell_ms >= 300)
|
|
5858
6132
|
.sort((a, b) => b.dwell_ms - a.dwell_ms)
|
|
5859
6133
|
.slice(0, 20);
|
|
5860
6134
|
|
|
6135
|
+
// Finalise exit_type: if it's still the provisional reload_arrived value
|
|
6136
|
+
// (user reloaded this page and didn't navigate away via back/spa), keep it
|
|
6137
|
+
// as 'reload' since the beforeunload didn't fire (e.g. visibilitychange path).
|
|
6138
|
+
const finalExitType = pageExitType === 'reload_arrived' ? 'reload' : pageExitType;
|
|
6139
|
+
|
|
5861
6140
|
EventsManager.trackAutoEvent('page_summary', {
|
|
5862
6141
|
scroll_reversals: scrollReversals,
|
|
5863
6142
|
scroll_reversal_depths: scrollReversalDepths,
|
|
@@ -5867,13 +6146,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5867
6146
|
hover_grid: serializeGrid(hoverGrid),
|
|
5868
6147
|
field_dwells: fieldDwells,
|
|
5869
6148
|
element_dwells: elementDwells,
|
|
6149
|
+
viewport_element_dwells: viewportElementDwells,
|
|
5870
6150
|
first_interaction_ms: firstInteractionMs,
|
|
5871
6151
|
exit_intent_count: exitIntentCount,
|
|
5872
6152
|
exit_intent_last_scroll_depth: exitIntentLastScrollDepth,
|
|
5873
6153
|
cls_score: clsScore,
|
|
5874
6154
|
long_tasks_count: longTasksCount,
|
|
5875
6155
|
max_long_task_ms: maxLongTaskMs,
|
|
6156
|
+
long_task_timestamps: longTaskTimestamps,
|
|
5876
6157
|
inp_ms: inpMs,
|
|
6158
|
+
exit_type: finalExitType,
|
|
5877
6159
|
document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
|
|
5878
6160
|
document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
|
|
5879
6161
|
viewport_width: window.innerWidth,
|
|
@@ -5913,7 +6195,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
5913
6195
|
this.startNetworkTracking();
|
|
5914
6196
|
this.startAdvancedFormTracking();
|
|
5915
6197
|
this.startFormAbandonmentTracking();
|
|
5916
|
-
|
|
6198
|
+
// element_view events replaced by viewport_element_dwells inside page_summary
|
|
6199
|
+
// this.startElementVisibilityTracking();
|
|
5917
6200
|
this.startPageSummaryTracking();
|
|
5918
6201
|
}
|
|
5919
6202
|
};
|
|
@@ -6579,6 +6862,13 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
6579
6862
|
*/
|
|
6580
6863
|
async trackAutoEvent(eventName, autoEventData = {}, elementData = {}) {
|
|
6581
6864
|
try {
|
|
6865
|
+
// Keep a running record of the last auto-event name so that js_error
|
|
6866
|
+
// events can include a "last_event_name" field for context.
|
|
6867
|
+
// Skip error-category events themselves to avoid circular noise.
|
|
6868
|
+
if (eventName && eventName !== 'js_error' && eventName !== 'network_error') {
|
|
6869
|
+
EventsManager._lastAutoEventName = eventName;
|
|
6870
|
+
}
|
|
6871
|
+
|
|
6582
6872
|
// Check if auto events should be tracked (silently skip if disabled)
|
|
6583
6873
|
if (!shouldTrackAutoEvents()) {
|
|
6584
6874
|
return; // Silently skip - don't log to avoid console spam
|
|
@@ -7257,8 +7547,23 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7257
7547
|
let clickCount = 0;
|
|
7258
7548
|
let lastClickTime = 0;
|
|
7259
7549
|
let lastClickElement = null;
|
|
7550
|
+
let rageStartTime = 0; // Timestamp of the first click in the current rage sequence
|
|
7551
|
+
let rageTimer = null; // Debounce timer — fires ONE rage_click after sequence ends
|
|
7260
7552
|
const pendingDeadClicks = new Map(); // Track clicks that might be dead clicks
|
|
7261
7553
|
|
|
7554
|
+
// MutationObserver counter — incremented on ANY DOM change.
|
|
7555
|
+
// Dead-click detection compares the count at click-time vs 800 ms later:
|
|
7556
|
+
// if nothing changed (URL stable + zero new mutations) it's a true dead click.
|
|
7557
|
+
// This prevents false positives in SPAs where interactions update the DOM
|
|
7558
|
+
// without changing the URL (modals, dropdowns, cart updates, tab panels, etc.).
|
|
7559
|
+
let domMutationCount = 0;
|
|
7560
|
+
try {
|
|
7561
|
+
new MutationObserver(() => { domMutationCount++; })
|
|
7562
|
+
.observe(document.documentElement, {
|
|
7563
|
+
childList: true, subtree: true, attributes: true, characterData: false
|
|
7564
|
+
});
|
|
7565
|
+
} catch (_) {}
|
|
7566
|
+
|
|
7262
7567
|
// Helper function to check if element is interactive
|
|
7263
7568
|
const isInteractiveElement = (el) => {
|
|
7264
7569
|
if (!el) return false;
|
|
@@ -7348,31 +7653,60 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7348
7653
|
elementData.image_height = element.naturalHeight || null;
|
|
7349
7654
|
}
|
|
7350
7655
|
|
|
7351
|
-
// Detect rage clicks
|
|
7656
|
+
// Detect rage clicks — fire exactly ONE event per sequence when the burst ends.
|
|
7657
|
+
// A "sequence" is N rapid clicks on the same element within 1 s of each other.
|
|
7658
|
+
// We debounce 800 ms after the last click so the event carries the FINAL count
|
|
7659
|
+
// and the total time-span from first to last click (not just the last interval).
|
|
7352
7660
|
if (element === lastClickElement && now - lastClickTime < 1000) {
|
|
7353
7661
|
clickCount++;
|
|
7662
|
+
|
|
7354
7663
|
if (clickCount >= 3) {
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7664
|
+
// Cancel any previously scheduled fire — sequence is still ongoing
|
|
7665
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7666
|
+
|
|
7667
|
+
// Snapshot mutable values at the time of THIS click
|
|
7668
|
+
const snapScrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
|
|
7669
|
+
const snapScrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
|
|
7670
|
+
const snapDocHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
|
|
7671
|
+
const snapDocWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
|
|
7672
|
+
const snapClientX = event.clientX;
|
|
7673
|
+
const snapClientY = event.clientY;
|
|
7674
|
+
const snapPageX = event.pageX;
|
|
7675
|
+
const snapPageY = event.pageY;
|
|
7676
|
+
const snapElemData = Object.assign({}, elementData);
|
|
7677
|
+
const snapCategory = elementCategory;
|
|
7678
|
+
const snapArea = element.offsetWidth * element.offsetHeight;
|
|
7679
|
+
const snapAriaLabel = element.getAttribute('aria-label') || null;
|
|
7680
|
+
const snapStartTime = rageStartTime;
|
|
7681
|
+
const snapNow = now;
|
|
7682
|
+
|
|
7683
|
+
// Fire after 800 ms of inactivity — reads the FINAL clickCount from closure
|
|
7684
|
+
rageTimer = setTimeout(() => {
|
|
7685
|
+
rageTimer = null;
|
|
7686
|
+
EventsManager.trackAutoEvent('rage_click', {
|
|
7687
|
+
click_coordinates: { x: snapClientX, y: snapClientY },
|
|
7688
|
+
page_x: snapPageX,
|
|
7689
|
+
page_y: snapPageY,
|
|
7690
|
+
scroll_x: snapScrollX,
|
|
7691
|
+
scroll_y: snapScrollY,
|
|
7692
|
+
document_height: snapDocHeight,
|
|
7693
|
+
document_width: snapDocWidth,
|
|
7694
|
+
click_count: clickCount, // final count for the whole sequence
|
|
7695
|
+
time_span: snapNow - snapStartTime, // first→last click duration
|
|
7696
|
+
element_area: snapArea,
|
|
7697
|
+
element_category: snapCategory,
|
|
7698
|
+
aria_label: snapAriaLabel
|
|
7699
|
+
}, snapElemData).catch(() => {});
|
|
7700
|
+
// Reset sequence state after the event is dispatched
|
|
7701
|
+
clickCount = 0;
|
|
7702
|
+
lastClickElement = null;
|
|
7703
|
+
}, 800);
|
|
7373
7704
|
}
|
|
7374
7705
|
} else {
|
|
7375
|
-
|
|
7706
|
+
// New element or gap > 1 s — start a fresh sequence
|
|
7707
|
+
if (rageTimer) { clearTimeout(rageTimer); rageTimer = null; }
|
|
7708
|
+
clickCount = 1;
|
|
7709
|
+
rageStartTime = now;
|
|
7376
7710
|
}
|
|
7377
7711
|
|
|
7378
7712
|
// Check if this might be a dead click (non-interactive element)
|
|
@@ -7396,6 +7730,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7396
7730
|
elementCategory,
|
|
7397
7731
|
timestamp: now,
|
|
7398
7732
|
url: window.location.href,
|
|
7733
|
+
snapshotMutationCount: domMutationCount, // DOM state at click time
|
|
7399
7734
|
clickX,
|
|
7400
7735
|
clickY,
|
|
7401
7736
|
page_x: event.pageX,
|
|
@@ -7406,13 +7741,17 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7406
7741
|
document_width: docWidth
|
|
7407
7742
|
});
|
|
7408
7743
|
|
|
7409
|
-
// Check after
|
|
7744
|
+
// Check after 800 ms: if URL unchanged AND DOM unchanged → true dead click.
|
|
7745
|
+
// Using 800 ms (down from 1 s) tightens the window while still allowing
|
|
7746
|
+
// async renders (React setState, animations) to complete before we decide.
|
|
7410
7747
|
setTimeout(() => {
|
|
7411
7748
|
checkNavigation();
|
|
7412
|
-
|
|
7749
|
+
|
|
7413
7750
|
const pendingClick = pendingDeadClicks.get(clickId);
|
|
7414
|
-
if (pendingClick &&
|
|
7415
|
-
|
|
7751
|
+
if (pendingClick &&
|
|
7752
|
+
window.location.href === pendingClick.url &&
|
|
7753
|
+
domMutationCount === pendingClick.snapshotMutationCount) {
|
|
7754
|
+
// No URL change AND no DOM mutations → confirmed dead click
|
|
7416
7755
|
pendingDeadClicks.delete(clickId);
|
|
7417
7756
|
|
|
7418
7757
|
EventsManager.trackAutoEvent('dead_click', {
|
|
@@ -7432,10 +7771,10 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
|
|
|
7432
7771
|
console.error('❌ [AutoEvents] Failed to track dead_click:', err);
|
|
7433
7772
|
});
|
|
7434
7773
|
} else if (pendingClick) {
|
|
7435
|
-
//
|
|
7774
|
+
// URL changed or DOM mutated — interaction was real, not a dead click
|
|
7436
7775
|
pendingDeadClicks.delete(clickId);
|
|
7437
7776
|
}
|
|
7438
|
-
},
|
|
7777
|
+
}, 800);
|
|
7439
7778
|
}
|
|
7440
7779
|
|
|
7441
7780
|
// Track regular click with enhanced data (viewport + page-relative for heatmaps)
|