cryptique-sdk 1.2.14 → 1.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/esm/index.js CHANGED
@@ -143,7 +143,16 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
143
143
  }
144
144
 
145
145
  // Parse disabled events (comma-separated string to array)
146
- // Available events: page_view, element_click, rage_click, page_scroll, form_submit, form_focus, media_play, media_pause, text_selection
146
+ // Disabling works per-event-name. All auto event names that can be individually disabled:
147
+ // Interaction: element_click, element_hover, rage_click, dead_click, page_scroll, text_selection
148
+ // Navigation: page_view
149
+ // Form: form_submit, form_validation_error, form_abandoned
150
+ // Media: media_play, media_pause, media_ended
151
+ // Visibility: element_view
152
+ // Error: js_error, network_error
153
+ // Performance: page_performance
154
+ // Session: session_start, session_end
155
+ // Example: auto-events-disabled-events="js_error,network_error,page_performance"
147
156
  if (disabledEventsAttr && disabledEventsAttr.trim()) {
148
157
  CONFIG.AUTO_EVENTS.disabledEvents = disabledEventsAttr
149
158
  .split(',')
@@ -158,7 +167,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
158
167
  isInitialized: false, // Prevents API calls during initialization
159
168
  sessionConfirmed: false, // True after first successful POST /api/sdk/track (session exists on server)
160
169
  eip6963Providers: [], // EIP-6963 discovered wallet providers
161
- reportedWalletAddress: null // Last wallet address reported to backend (prevents duplicate calls)
170
+ reportedWalletAddress: null, // Last wallet address reported to backend (prevents duplicate calls)
171
+ activeTimeMs: 0, // Cumulative time tab was in foreground (for active_time_seconds)
172
+ tabFocusTimestamp: null // When the tab most recently became visible
162
173
  };
163
174
 
164
175
  // Ready promise - resolves when SDK is fully initialized (allows consumers to await init)
@@ -4037,10 +4048,12 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
4037
4048
  const interactionTypes = [
4038
4049
  'clicks', 'mediaInteractions', 'contextMenuEvents', 'windowEvents',
4039
4050
  'formInteractions', 'scrollEvents', 'focusEvents', 'hoverEvents',
4040
- 'formSubmissions', 'fieldChanges', 'validationErrors',
4051
+ 'formSubmissions', 'fieldChanges',
4041
4052
  'keyboardEvents', 'copyPasteEvents', 'dragDropEvents',
4042
- 'touchEvents', 'performanceEvents', 'errorEvents',
4043
- 'networkEvents', 'formAnalytics'
4053
+ 'touchEvents', 'formAnalytics'
4054
+ // Note: validationErrors, performanceEvents, errorEvents, networkEvents are no longer
4055
+ // stored in session interactions — they are emitted as individual queryable auto events
4056
+ // (form_validation_error, page_performance, js_error, network_error)
4044
4057
  ];
4045
4058
 
4046
4059
  // Helper to normalize path for matching
@@ -4258,7 +4271,10 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
4258
4271
  interactions: {
4259
4272
  ...sessionInteractions,
4260
4273
  chronological: chronologicalInteractions.chronological || []
4261
- }
4274
+ },
4275
+
4276
+ // Active tab time in ms (tab foreground time only, for active_time_seconds in DB)
4277
+ active_time_ms: sourceData.active_time_ms || null
4262
4278
  };
4263
4279
 
4264
4280
  // Remove null/undefined values for cleaner payload (optional - can be kept for explicit nulls)
@@ -4741,17 +4757,20 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
4741
4757
  if (event.target &&
4742
4758
  (event.target.tagName === 'INPUT' ||
4743
4759
  event.target.tagName === 'TEXTAREA')) {
4744
- const validationData = {
4745
- type: 'validation_error',
4746
- fieldType: event.target.type || '',
4747
- fieldName: event.target.name || '',
4748
- fieldId: event.target.id || '',
4749
- formId: event.target.form ? event.target.form.id : '',
4750
- validationMessage: event.target.validationMessage || '',
4751
- path: window.location.pathname
4752
- };
4753
-
4754
- InteractionManager.add('validationErrors', validationData);
4760
+ EventsManager.trackAutoEvent('form_validation_error', {
4761
+ field_id: event.target.id || '',
4762
+ field_name: event.target.name || '',
4763
+ field_type: event.target.type || event.target.tagName.toLowerCase(),
4764
+ form_id: event.target.form ? (event.target.form.id || '') : '',
4765
+ validation_message: event.target.validationMessage || ''
4766
+ }, {
4767
+ element_id: event.target.id || '',
4768
+ element_name: event.target.name || '',
4769
+ element_tag_name: event.target.tagName || '',
4770
+ element_type: event.target.type || '',
4771
+ element_class: event.target.className || '',
4772
+ element_text: (event.target.placeholder || event.target.getAttribute('aria-label') || '').trim().slice(0, 100)
4773
+ });
4755
4774
  }
4756
4775
  }, true);
4757
4776
  } catch (error) {
@@ -5206,23 +5225,62 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5206
5225
  */
5207
5226
  startPerformanceTracking() {
5208
5227
  try {
5228
+ // Collect Core Web Vitals via PerformanceObserver + Navigation Timing
5229
+ // Emits a single queryable page_performance auto event per page load
5230
+ const vitals = { lcp: null, cls: 0, inp: null, fcp: null, ttfb: null };
5231
+
5232
+ // LCP
5233
+ try {
5234
+ new PerformanceObserver((list) => {
5235
+ const entries = list.getEntries();
5236
+ if (entries.length > 0) {
5237
+ vitals.lcp = Math.round(entries[entries.length - 1].startTime);
5238
+ }
5239
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
5240
+ } catch (e) {}
5241
+
5242
+ // CLS
5243
+ try {
5244
+ new PerformanceObserver((list) => {
5245
+ for (const entry of list.getEntries()) {
5246
+ if (!entry.hadRecentInput) {
5247
+ vitals.cls = Math.round((vitals.cls + entry.value) * 10000) / 10000;
5248
+ }
5249
+ }
5250
+ }).observe({ type: 'layout-shift', buffered: true });
5251
+ } catch (e) {}
5252
+
5253
+ // INP (interaction to next paint)
5254
+ try {
5255
+ new PerformanceObserver((list) => {
5256
+ for (const entry of list.getEntries()) {
5257
+ if (entry.duration && (vitals.inp === null || entry.duration > vitals.inp)) {
5258
+ vitals.inp = Math.round(entry.duration);
5259
+ }
5260
+ }
5261
+ }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
5262
+ } catch (e) {}
5263
+
5264
+ // FCP + TTFB from paint + navigation entries (available after load)
5209
5265
  window.addEventListener('load', () => {
5210
- // Wait a bit for all resources to load
5211
5266
  setTimeout(() => {
5212
- const navigation = performance.getEntriesByType('navigation')[0];
5213
- const paint = performance.getEntriesByType('paint');
5214
-
5215
- const performanceData = {
5216
- type: 'page_load_complete',
5217
- loadTime: navigation ? (navigation.loadEventEnd - navigation.navigationStart) : 0,
5218
- domContentLoaded: navigation ? (navigation.domContentLoadedEventEnd - navigation.navigationStart) : 0,
5219
- firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
5220
- firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
5221
- path: window.location.pathname
5222
- };
5223
-
5224
- InteractionManager.add('performanceEvents', performanceData);
5225
- }, 1000);
5267
+ try {
5268
+ const nav = performance.getEntriesByType('navigation')[0];
5269
+ const paint = performance.getEntriesByType('paint');
5270
+ vitals.ttfb = nav ? Math.round(nav.responseStart - nav.requestStart) : null;
5271
+ vitals.fcp = paint.find(p => p.name === 'first-contentful-paint')
5272
+ ? Math.round(paint.find(p => p.name === 'first-contentful-paint').startTime)
5273
+ : null;
5274
+ } catch (e) {}
5275
+
5276
+ EventsManager.trackAutoEvent('page_performance', {
5277
+ lcp: vitals.lcp,
5278
+ cls: vitals.cls,
5279
+ inp: vitals.inp,
5280
+ fcp: vitals.fcp,
5281
+ ttfb: vitals.ttfb
5282
+ });
5283
+ }, 1500);
5226
5284
  });
5227
5285
  } catch (error) {
5228
5286
  }
@@ -5230,40 +5288,28 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5230
5288
 
5231
5289
  /**
5232
5290
  * ErrorTracker - Tracks JavaScript errors
5291
+ * Emits as queryable auto events (js_error) instead of session-level JSONB
5233
5292
  */
5234
5293
  startErrorTracking() {
5235
5294
  try {
5236
5295
  window.addEventListener('error', (event) => {
5237
- const errorData = {
5238
- type: 'javascript_error',
5296
+ EventsManager.trackAutoEvent('js_error', {
5297
+ error_type: 'javascript_error',
5239
5298
  message: event.message || '',
5240
- filename: event.filename || '',
5241
- lineno: event.lineno || 0,
5242
- colno: event.colno || 0,
5243
- error: event.error?.stack || '',
5244
- errorElement: {
5245
- tagName: event.target?.tagName || '',
5246
- id: event.target?.id || '',
5247
- className: event.target?.className || '',
5248
- src: event.target?.src || '',
5249
- href: event.target?.href || ''
5250
- },
5251
- path: window.location.pathname
5252
- };
5253
-
5254
- InteractionManager.add('errorEvents', errorData);
5299
+ source: event.filename || '',
5300
+ line: event.lineno || 0,
5301
+ column: event.colno || 0,
5302
+ stack: event.error?.stack || ''
5303
+ });
5255
5304
  });
5256
-
5305
+
5257
5306
  // Track unhandled promise rejections
5258
5307
  window.addEventListener('unhandledrejection', (event) => {
5259
- const errorData = {
5260
- type: 'unhandled_promise_rejection',
5308
+ EventsManager.trackAutoEvent('js_error', {
5309
+ error_type: 'unhandled_promise_rejection',
5261
5310
  message: event.reason?.message || String(event.reason || 'Unknown error'),
5262
- error: event.reason?.stack || String(event.reason || ''),
5263
- path: window.location.pathname
5264
- };
5265
-
5266
- InteractionManager.add('errorEvents', errorData);
5311
+ stack: event.reason?.stack || String(event.reason || '')
5312
+ });
5267
5313
  });
5268
5314
  } catch (error) {
5269
5315
  }
@@ -5301,32 +5347,24 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5301
5347
 
5302
5348
  xhr.addEventListener('load', function() {
5303
5349
  if (xhr.status >= 400) {
5304
- const networkErrorData = {
5305
- type: 'api_error',
5350
+ EventsManager.trackAutoEvent('network_error', {
5306
5351
  url: xhr._cryptiqueUrl,
5307
5352
  method: xhr._cryptiqueMethod || 'GET',
5308
5353
  status: xhr.status,
5309
- error: `HTTP ${xhr.status} Error`,
5310
- duration: Date.now() - startTime,
5311
- path: window.location.pathname
5312
- };
5313
-
5314
- InteractionManager.add('networkEvents', networkErrorData);
5354
+ status_text: `HTTP ${xhr.status} Error`,
5355
+ duration_ms: Date.now() - startTime
5356
+ });
5315
5357
  }
5316
5358
  });
5317
-
5359
+
5318
5360
  xhr.addEventListener('error', function() {
5319
- const networkErrorData = {
5320
- type: 'api_error',
5361
+ EventsManager.trackAutoEvent('network_error', {
5321
5362
  url: xhr._cryptiqueUrl,
5322
5363
  method: xhr._cryptiqueMethod || 'GET',
5323
5364
  status: 0,
5324
- error: 'XHR Network Error',
5325
- duration: Date.now() - startTime,
5326
- path: window.location.pathname
5327
- };
5328
-
5329
- InteractionManager.add('networkEvents', networkErrorData);
5365
+ status_text: 'XHR Network Error',
5366
+ duration_ms: Date.now() - startTime
5367
+ });
5330
5368
  });
5331
5369
 
5332
5370
  return originalXHRSend.apply(this, args);
@@ -5341,9 +5379,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5341
5379
  startAdvancedFormTracking() {
5342
5380
  try {
5343
5381
  const formStartTimes = new Map();
5344
-
5382
+
5345
5383
  document.addEventListener('focus', (event) => {
5346
- if (event.target &&
5384
+ if (event.target &&
5347
5385
  (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA')) {
5348
5386
  const formId = event.target.form?.id || 'unknown';
5349
5387
  if (!formStartTimes.has(formId)) {
@@ -5351,11 +5389,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5351
5389
  }
5352
5390
  }
5353
5391
  });
5354
-
5392
+
5355
5393
  document.addEventListener('submit', (event) => {
5356
5394
  const formId = event.target.id || 'unknown';
5357
5395
  const startTime = formStartTimes.get(formId);
5358
-
5396
+
5359
5397
  if (startTime) {
5360
5398
  const formCompletionData = {
5361
5399
  type: 'form_completion',
@@ -5364,7 +5402,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5364
5402
  fieldCount: event.target.elements.length,
5365
5403
  path: window.location.pathname
5366
5404
  };
5367
-
5405
+
5368
5406
  InteractionManager.add('formAnalytics', formCompletionData);
5369
5407
  formStartTimes.delete(formId);
5370
5408
  }
@@ -5373,9 +5411,172 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5373
5411
  }
5374
5412
  },
5375
5413
 
5414
+ /**
5415
+ * FormAbandonmentTracker - Emits form_abandoned auto event when a user
5416
+ * interacts with a form but navigates away without submitting.
5417
+ * Tracks which field they stopped at — high-value PM signal.
5418
+ */
5419
+ startFormAbandonmentTracking() {
5420
+ try {
5421
+ // Per-form state: formId → { startTime, lastFieldId, lastFieldType, filledFields, totalFields, submitted }
5422
+ const formState = new Map();
5423
+
5424
+ const getOrInitForm = (formEl) => {
5425
+ const formId = formEl?.id || formEl?.name || 'form_' + (formEl ? [...document.forms].indexOf(formEl) : 0);
5426
+ if (!formState.has(formId)) {
5427
+ const totalFields = formEl
5428
+ ? formEl.querySelectorAll('input:not([type=hidden]), select, textarea').length
5429
+ : 0;
5430
+ formState.set(formId, {
5431
+ formId,
5432
+ startTime: Date.now(),
5433
+ lastFieldId: '',
5434
+ lastFieldType: '',
5435
+ filledFields: new Set(),
5436
+ totalFields,
5437
+ submitted: false
5438
+ });
5439
+ }
5440
+ return formState.get(formId);
5441
+ };
5442
+
5443
+ // Track field focus to know where user is
5444
+ document.addEventListener('focus', (event) => {
5445
+ const target = event.target;
5446
+ if (!target || !['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
5447
+ if (target.type === 'hidden') return;
5448
+ const state = getOrInitForm(target.form);
5449
+ state.lastFieldId = target.id || target.name || '';
5450
+ state.lastFieldType = target.type || target.tagName.toLowerCase();
5451
+ }, true);
5452
+
5453
+ // Track field blur to record which fields have been filled
5454
+ document.addEventListener('blur', (event) => {
5455
+ const target = event.target;
5456
+ if (!target || !['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
5457
+ if (target.type === 'hidden') return;
5458
+ const state = getOrInitForm(target.form);
5459
+ const fieldKey = target.id || target.name || target.type;
5460
+ if (target.value && target.value.length > 0 && fieldKey) {
5461
+ state.filledFields.add(fieldKey);
5462
+ }
5463
+ }, true);
5464
+
5465
+ // Mark form as submitted so we don't fire form_abandoned
5466
+ document.addEventListener('submit', (event) => {
5467
+ const formId = event.target?.id || event.target?.name || 'form_' + [...document.forms].indexOf(event.target);
5468
+ if (formState.has(formId)) {
5469
+ formState.get(formId).submitted = true;
5470
+ }
5471
+ }, true);
5472
+
5473
+ // On page unload, fire form_abandoned for any touched, unsubmitted forms
5474
+ window.addEventListener('beforeunload', () => {
5475
+ formState.forEach((state) => {
5476
+ if (!state.submitted && state.filledFields.size > 0) {
5477
+ // Fire synchronously via sendBeacon path — trackAutoEvent is async
5478
+ // but beforeunload already handles this via sendBeacon in _sendEvent
5479
+ // Resolve the actual form DOM element (if still in DOM)
5480
+ const formEl = state.formId && state.formId !== 'unknown'
5481
+ ? document.getElementById(state.formId) || document.querySelector(`form[name="${state.formId}"]`)
5482
+ : null;
5483
+ EventsManager.trackAutoEvent('form_abandoned', {
5484
+ form_id: state.formId,
5485
+ last_active_field_id: state.lastFieldId,
5486
+ last_active_field_type: state.lastFieldType,
5487
+ fields_filled_count: state.filledFields.size,
5488
+ total_fields_count: state.totalFields,
5489
+ time_on_form_ms: Date.now() - state.startTime
5490
+ }, {
5491
+ element_id: formEl?.id || state.formId || '',
5492
+ element_name: formEl?.getAttribute('name') || '',
5493
+ element_tag_name: 'FORM',
5494
+ element_type: '',
5495
+ element_class: formEl?.className || '',
5496
+ element_text: (formEl?.getAttribute('aria-label') || '').slice(0, 100)
5497
+ });
5498
+ }
5499
+ });
5500
+ });
5501
+ } catch (error) {
5502
+ }
5503
+ },
5504
+
5505
+ /**
5506
+ * ElementVisibilityTracker - Emits element_view auto events when elements
5507
+ * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5508
+ * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5509
+ * vs just didn't click them — completely different problems, opposite fixes.
5510
+ */
5511
+ startElementVisibilityTracking() {
5512
+ try {
5513
+ if (typeof IntersectionObserver === 'undefined') return;
5514
+
5515
+ // Only observe elements with an id or data-cq-track attribute
5516
+ const getTrackableElements = () =>
5517
+ document.querySelectorAll('[id]:not([id=""]), [data-cq-track]');
5518
+
5519
+ const viewTimers = new Map(); // elementKey → setTimeout handle
5520
+ const reported = new Set(); // elementKey → already fired once per page load
5521
+
5522
+ const observer = new IntersectionObserver((entries) => {
5523
+ entries.forEach((entry) => {
5524
+ const el = entry.target;
5525
+ const key = el.id || el.getAttribute('data-cq-track') || el.className;
5526
+ if (!key || reported.has(key)) return;
5527
+
5528
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5529
+ // Element entered viewport — start 1s timer
5530
+ if (!viewTimers.has(key)) {
5531
+ const enterTime = Date.now();
5532
+ const timer = setTimeout(() => {
5533
+ if (reported.has(key)) return;
5534
+ reported.add(key);
5535
+ EventsManager.trackAutoEvent('element_view', {
5536
+ time_in_viewport_ms: Date.now() - enterTime,
5537
+ viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5538
+ }, {
5539
+ element_id: el.id || '',
5540
+ element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5541
+ element_tag_name: el.tagName || '',
5542
+ element_type: el.type || el.getAttribute('type') || '',
5543
+ element_class: el.className || '',
5544
+ element_text: (el.textContent || '').trim().slice(0, 100)
5545
+ });
5546
+ viewTimers.delete(key);
5547
+ }, 1000);
5548
+ viewTimers.set(key, timer);
5549
+ }
5550
+ } else {
5551
+ // Element left viewport before 1s — cancel timer
5552
+ if (viewTimers.has(key)) {
5553
+ clearTimeout(viewTimers.get(key));
5554
+ viewTimers.delete(key);
5555
+ }
5556
+ }
5557
+ });
5558
+ }, { threshold: 0.5 });
5559
+
5560
+ // Observe existing elements
5561
+ getTrackableElements().forEach(el => observer.observe(el));
5562
+
5563
+ // Observe elements added to DOM later (SPAs)
5564
+ if (typeof MutationObserver !== 'undefined') {
5565
+ new MutationObserver(() => {
5566
+ getTrackableElements().forEach(el => {
5567
+ if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5568
+ observer.observe(el);
5569
+ }
5570
+ });
5571
+ }).observe(document.body, { childList: true, subtree: true });
5572
+ }
5573
+ } catch (error) {
5574
+ }
5575
+ },
5576
+
5376
5577
  /**
5377
5578
  * Initialize all event trackers
5378
- *
5579
+ *
5379
5580
  * Starts all tracking modules
5380
5581
  */
5381
5582
  initialize() {
@@ -5394,6 +5595,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5394
5595
  this.startErrorTracking();
5395
5596
  this.startNetworkTracking();
5396
5597
  this.startAdvancedFormTracking();
5598
+ this.startFormAbandonmentTracking();
5599
+ this.startElementVisibilityTracking();
5397
5600
  }
5398
5601
  };
5399
5602
 
@@ -5529,9 +5732,36 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5529
5732
  }
5530
5733
  },
5531
5734
 
5735
+ /**
5736
+ * Tab Visibility Accumulator
5737
+ * Tracks how much time the tab was actually in the foreground.
5738
+ * Writes only to runtimeState — never triggers session end or timing bugs.
5739
+ * active_time_ms is included in every session heartbeat for backend persistence.
5740
+ */
5741
+ startTabVisibilityTracking() {
5742
+ try {
5743
+ // Initialise: tab starts visible
5744
+ runtimeState.tabFocusTimestamp = document.hidden ? null : Date.now();
5745
+
5746
+ document.addEventListener('visibilitychange', () => {
5747
+ if (document.hidden) {
5748
+ // Tab went to background — accumulate time since last focus
5749
+ if (runtimeState.tabFocusTimestamp !== null) {
5750
+ runtimeState.activeTimeMs += Date.now() - runtimeState.tabFocusTimestamp;
5751
+ runtimeState.tabFocusTimestamp = null;
5752
+ }
5753
+ } else {
5754
+ // Tab came back to foreground
5755
+ runtimeState.tabFocusTimestamp = Date.now();
5756
+ }
5757
+ });
5758
+ } catch (error) {
5759
+ }
5760
+ },
5761
+
5532
5762
  /**
5533
5763
  * Start session tracking interval
5534
- *
5764
+ *
5535
5765
  * Periodically sends session data and updates session metrics
5536
5766
  */
5537
5767
  startSessionTracking() {
@@ -5609,6 +5839,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5609
5839
  // Always sync distinctId from storage before sending to ensure consistency
5610
5840
  sessionData.distinctId = StorageManager.getDistinctId();
5611
5841
 
5842
+ // Include accumulated active time (tab foreground time only)
5843
+ const currentActiveMs = runtimeState.activeTimeMs +
5844
+ (runtimeState.tabFocusTimestamp !== null ? Date.now() - runtimeState.tabFocusTimestamp : 0);
5845
+ sessionData.active_time_ms = currentActiveMs;
5846
+
5612
5847
  // Send session data
5613
5848
  await APIClient.sendSessionData();
5614
5849
  } catch (error) {
@@ -5776,6 +6011,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5776
6011
  // Start event trackers
5777
6012
  EventTrackers.initialize();
5778
6013
 
6014
+ // Start tab visibility accumulator (active_time_ms)
6015
+ this.startTabVisibilityTracking();
6016
+
5779
6017
  // Start session tracking interval
5780
6018
  this.startSessionTracking();
5781
6019
 
@@ -6134,6 +6372,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6134
6372
  'Content-Type': 'application/json',
6135
6373
  'X-Cryptique-Site-Id': getCurrentSiteId()
6136
6374
  },
6375
+ // keepalive: true allows this request to outlive the page (critical for
6376
+ // form_abandoned events fired in beforeunload handlers)
6377
+ keepalive: true,
6137
6378
  body: JSON.stringify(eventData)
6138
6379
  });
6139
6380
 
@@ -6158,22 +6399,33 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6158
6399
  */
6159
6400
  _getAutoEventCategory(eventName) {
6160
6401
  const categoryMap = {
6402
+ // Navigation
6161
6403
  'page_view': 'navigation',
6404
+ // Interaction
6162
6405
  'page_scroll': 'interaction',
6163
6406
  'element_click': 'interaction',
6164
6407
  'element_hover': 'interaction',
6408
+ 'dead_click': 'interaction',
6409
+ 'rage_click': 'interaction',
6410
+ 'text_selection': 'interaction',
6411
+ // Form
6165
6412
  'form_submit': 'form',
6166
- 'form_focus': 'form',
6167
- 'form_blur': 'form',
6168
- 'form_change': 'form',
6413
+ 'form_validation_error': 'form',
6414
+ 'form_abandoned': 'form',
6415
+ // Media
6169
6416
  'media_play': 'media',
6170
6417
  'media_pause': 'media',
6171
6418
  'media_ended': 'media',
6172
- 'dead_click': 'interaction',
6173
- 'rage_click': 'interaction',
6174
- 'text_selection': 'interaction',
6419
+ // Session
6175
6420
  'session_start': 'session',
6176
- 'session_end': 'session'
6421
+ 'session_end': 'session',
6422
+ // Error
6423
+ 'js_error': 'error',
6424
+ 'network_error': 'error',
6425
+ // Performance
6426
+ 'page_performance': 'performance',
6427
+ // Visibility
6428
+ 'element_view': 'visibility'
6177
6429
  };
6178
6430
 
6179
6431
  return categoryMap[eventName] || 'other';
@@ -6779,8 +7031,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6779
7031
  if (clickCount >= 3) {
6780
7032
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6781
7033
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
6782
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6783
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7034
+ const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7035
+ const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
6784
7036
  EventsManager.trackAutoEvent('rage_click', {
6785
7037
  click_coordinates: { x: event.clientX, y: event.clientY },
6786
7038
  page_x: event.pageX,
@@ -6810,8 +7062,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6810
7062
  const clickElement = element;
6811
7063
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6812
7064
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
6813
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6814
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7065
+ const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7066
+ const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
6815
7067
 
6816
7068
  // Mark this click as potentially dead
6817
7069
  const clickId = `${now}_${Math.random().toString(36).substr(2, 9)}`;
@@ -6866,8 +7118,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6866
7118
  // Track regular click with enhanced data (viewport + page-relative for heatmaps)
6867
7119
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6868
7120
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
6869
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6870
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7121
+ const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7122
+ const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
6871
7123
  EventsManager.trackAutoEvent('element_click', {
6872
7124
  click_coordinates: { x: event.clientX, y: event.clientY },
6873
7125
  page_x: event.pageX,
@@ -6905,8 +7157,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6905
7157
  if (scrollDepth > maxScrollDepth) {
6906
7158
  maxScrollDepth = scrollDepth;
6907
7159
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6908
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6909
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7160
+ const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7161
+ const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
6910
7162
  EventsManager.trackAutoEvent('page_scroll', {
6911
7163
  scroll_depth: scrollDepth,
6912
7164
  max_scroll_reached: maxScrollDepth,
@@ -6933,8 +7185,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6933
7185
  if (form.tagName === 'FORM') {
6934
7186
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6935
7187
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
6936
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6937
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7188
+ const docHeight = Math.min(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), 25000);
7189
+ const docWidth = Math.min(Math.max(document.body.scrollWidth, document.documentElement.scrollWidth), 25000);
6938
7190
  const rect = form.getBoundingClientRect();
6939
7191
  const pageX = rect.left + scrollX;
6940
7192
  const pageY = rect.top + scrollY;
@@ -6966,50 +7218,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6966
7218
  }
6967
7219
  });
6968
7220
 
6969
- // Track form field interactions
6970
- document.addEventListener('focus', (event) => {
6971
- const element = event.target;
6972
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
6973
- const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
6974
- const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
6975
- const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
6976
- const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
6977
- const rect = element.getBoundingClientRect();
6978
- const pageX = rect.left + scrollX;
6979
- const pageY = rect.top + scrollY;
6980
- EventsManager.trackAutoEvent('form_focus', {
6981
- field_name: element.name || null,
6982
- field_type: element.type || null,
6983
- field_id: element.id || null,
6984
- scroll_x: scrollX,
6985
- scroll_y: scrollY,
6986
- document_height: docHeight,
6987
- document_width: docWidth,
6988
- page_x: pageX,
6989
- page_y: pageY,
6990
- click_coordinates: { x: event.clientX != null ? event.clientX : rect.left + rect.width / 2, y: event.clientY != null ? event.clientY : rect.top + rect.height / 2 }
6991
- }, {
6992
- element_tag_name: element.tagName.toLowerCase(),
6993
- element_id: element.id || null,
6994
- element_name: element.name || element.getAttribute('name') || null, // FIX: Added element_name
6995
- element_class: element.className || null, // FIX: Added element_class
6996
- element_type: element.type || null,
6997
- element_text: element.value ? element.value.toString().trim().substring(0, 100) : null, // FIX: Added element_text (value for form fields)
6998
- element_position: { // FIX: Added element_position
6999
- x: event.clientX != null ? event.clientX : rect.left,
7000
- y: event.clientY != null ? event.clientY : rect.top,
7001
- width: element.offsetWidth || 0,
7002
- height: element.offsetHeight || 0
7003
- }
7004
- });
7005
- }
7006
- }, true);
7221
+ // form_focus, form_blur, form_change are intentionally NOT fired as auto events.
7222
+ // form_abandoned already captures the meaningful signal (which field, how many filled,
7223
+ // time spent) without the per-keystroke noise these would generate.
7007
7224
  },
7008
7225
 
7009
7226
  /**
7010
7227
  * Setup media tracking
7228
+ * play/pause events are throttled per element (2s cooldown) to filter out
7229
+ * scrubbing noise — a user seeking through a video fires rapid play/pause
7230
+ * pairs that have no PM value.
7011
7231
  */
7012
7232
  setupMediaTracking() {
7233
+ // Per-element last-event timestamps — keyed by src or generated index
7234
+ const lastMediaEventTime = new Map();
7235
+ const MEDIA_THROTTLE_MS = 2000;
7236
+
7237
+ function getMediaKey(el) {
7238
+ return el.src || el.id || el.currentSrc || [...document.querySelectorAll('video,audio')].indexOf(el).toString();
7239
+ }
7240
+
7013
7241
  function getPageContext() {
7014
7242
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
7015
7243
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
@@ -7017,49 +7245,56 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7017
7245
  const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7018
7246
  return { scrollX, scrollY, docHeight, docWidth };
7019
7247
  }
7248
+
7020
7249
  ['video', 'audio'].forEach(mediaType => {
7021
7250
  document.addEventListener('play', (event) => {
7022
- if (event.target.tagName.toLowerCase() === mediaType) {
7023
- const el = event.target;
7024
- const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7025
- const rect = el.getBoundingClientRect();
7026
- const pageX = rect.left + scrollX;
7027
- const pageY = rect.top + scrollY;
7028
- EventsManager.trackAutoEvent('media_play', {
7029
- media_type: mediaType,
7030
- media_src: el.src || null,
7031
- media_duration: el.duration || null,
7032
- scroll_x: scrollX,
7033
- scroll_y: scrollY,
7034
- document_height: docHeight,
7035
- document_width: docWidth,
7036
- page_x: pageX,
7037
- page_y: pageY,
7038
- click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7039
- });
7040
- }
7251
+ if (event.target.tagName.toLowerCase() !== mediaType) return;
7252
+ const el = event.target;
7253
+ const key = `play:${getMediaKey(el)}`;
7254
+ const now = Date.now();
7255
+ if (now - (lastMediaEventTime.get(key) || 0) < MEDIA_THROTTLE_MS) return;
7256
+ lastMediaEventTime.set(key, now);
7257
+
7258
+ const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7259
+ const rect = el.getBoundingClientRect();
7260
+ EventsManager.trackAutoEvent('media_play', {
7261
+ media_type: mediaType,
7262
+ media_src: el.src || null,
7263
+ media_duration: el.duration || null,
7264
+ media_current_time: el.currentTime || null,
7265
+ scroll_x: scrollX,
7266
+ scroll_y: scrollY,
7267
+ document_height: docHeight,
7268
+ document_width: docWidth,
7269
+ page_x: rect.left + scrollX,
7270
+ page_y: rect.top + scrollY,
7271
+ click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7272
+ });
7041
7273
  }, true);
7042
7274
 
7043
7275
  document.addEventListener('pause', (event) => {
7044
- if (event.target.tagName.toLowerCase() === mediaType) {
7045
- const el = event.target;
7046
- const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7047
- const rect = el.getBoundingClientRect();
7048
- const pageX = rect.left + scrollX;
7049
- const pageY = rect.top + scrollY;
7050
- EventsManager.trackAutoEvent('media_pause', {
7051
- media_type: mediaType,
7052
- media_current_time: el.currentTime || null,
7053
- media_duration: el.duration || null,
7054
- scroll_x: scrollX,
7055
- scroll_y: scrollY,
7056
- document_height: docHeight,
7057
- document_width: docWidth,
7058
- page_x: pageX,
7059
- page_y: pageY,
7060
- click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7061
- });
7062
- }
7276
+ if (event.target.tagName.toLowerCase() !== mediaType) return;
7277
+ const el = event.target;
7278
+ const key = `pause:${getMediaKey(el)}`;
7279
+ const now = Date.now();
7280
+ if (now - (lastMediaEventTime.get(key) || 0) < MEDIA_THROTTLE_MS) return;
7281
+ lastMediaEventTime.set(key, now);
7282
+
7283
+ const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7284
+ const rect = el.getBoundingClientRect();
7285
+ EventsManager.trackAutoEvent('media_pause', {
7286
+ media_type: mediaType,
7287
+ media_src: el.src || null,
7288
+ media_current_time: el.currentTime || null,
7289
+ media_duration: el.duration || null,
7290
+ scroll_x: scrollX,
7291
+ scroll_y: scrollY,
7292
+ document_height: docHeight,
7293
+ document_width: docWidth,
7294
+ page_x: rect.left + scrollX,
7295
+ page_y: rect.top + scrollY,
7296
+ click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7297
+ });
7063
7298
  }, true);
7064
7299
  });
7065
7300
  },
@@ -7086,12 +7321,14 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7086
7321
 
7087
7322
  const selectedText = selection.toString().trim();
7088
7323
 
7089
- // Only track if there's actual text selected and it's different from last selection
7090
- if (selectedText && selectedText.length > 0 && selectedText !== lastSelectionText) {
7324
+ // Only track if selection is meaningful (≥3 chars) and different from last selection.
7325
+ // <3 chars filters out accidental double-clicks and single-word mis-selections.
7326
+ if (selectedText && selectedText.length >= 3 && selectedText !== lastSelectionText) {
7091
7327
  const now = Date.now();
7092
-
7093
- // Don't track if selection happened too quickly (likely accidental)
7094
- if (now - lastSelectionTime < 200) {
7328
+
7329
+ // 1s cooldown between events prevents rapid re-selection noise
7330
+ // (e.g. user adjusting selection handles fires many selectionchange events)
7331
+ if (now - lastSelectionTime < 1000) {
7095
7332
  return;
7096
7333
  }
7097
7334
 
@@ -7933,9 +8170,13 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7933
8170
  if (window.self !== window.top) {
7934
8171
  const _sendHeatmapDimensions = () => {
7935
8172
  try {
7936
- const h = Math.max(
7937
- document.body ? document.body.scrollHeight : 0,
7938
- document.documentElement ? document.documentElement.scrollHeight : 0
8173
+ // Cap at 25 000 px — browser scroll-animation libraries can report 16 777 216 (2^24).
8174
+ const h = Math.min(
8175
+ Math.max(
8176
+ document.body ? document.body.scrollHeight : 0,
8177
+ document.documentElement ? document.documentElement.scrollHeight : 0
8178
+ ),
8179
+ 25000
7939
8180
  );
7940
8181
  if (h > 0) {
7941
8182
  window.parent.postMessage({ type: 'cryptique_heatmap_dimensions', document_height: h }, '*');
@@ -7943,16 +8184,23 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7943
8184
  } catch (_) {}
7944
8185
  };
7945
8186
 
7946
- // Send once the DOM is fully painted
8187
+ // Send once the DOM is fully painted (immediate — most accurate final height).
7947
8188
  if (document.readyState === 'complete') {
7948
8189
  _sendHeatmapDimensions();
7949
8190
  } else {
7950
8191
  window.addEventListener('load', _sendHeatmapDimensions);
7951
8192
  }
7952
8193
 
7953
- // Re-send whenever the page grows (lazy-loaded content, SPAs, accordions, etc.)
8194
+ // Re-send whenever the page grows (lazy-loaded content, SPAs, accordions, etc.).
8195
+ // Debounced to 1 s so rapid DOM mutations during React hydration don't trigger
8196
+ // dozens of postMessages and repeated canvas re-renders.
7954
8197
  try {
7955
- const _heatmapRO = new ResizeObserver(_sendHeatmapDimensions);
8198
+ let _heatmapROTimer;
8199
+ const _debouncedSend = () => {
8200
+ clearTimeout(_heatmapROTimer);
8201
+ _heatmapROTimer = setTimeout(_sendHeatmapDimensions, 1000);
8202
+ };
8203
+ const _heatmapRO = new ResizeObserver(_debouncedSend);
7956
8204
  _heatmapRO.observe(document.documentElement);
7957
8205
  } catch (_) {}
7958
8206
  }