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