cryptique-sdk 1.2.15 → 1.2.17

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
@@ -92,8 +92,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
92
92
 
93
93
  // Geolocation API
94
94
  GEOLOCATION: {
95
- PRIMARY_URL: "https://ipinfo.io/json?token=73937f74acc045",
96
- BACKUP_URL: "https://ipinfo.io/json?token=73937f74acc045&http=1.1",
95
+ PRIMARY_URL: "https://ipinfo.io/json?token=8fc6409059aa39",
96
+ BACKUP_URL: "https://ipinfo.io/json?token=8fc6409059aa39&http=1.1",
97
97
  TIMEOUT_MS: 5000 // 5 second timeout
98
98
  },
99
99
 
@@ -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) {
@@ -4980,19 +4999,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
4980
4999
  startCopyPasteTracking() {
4981
5000
  try {
4982
5001
  document.addEventListener('copy', (event) => {
5002
+ const sel = window.getSelection();
4983
5003
  const copyData = {
4984
5004
  type: 'copy_action',
4985
- selectedText: window.getSelection().toString().substring(0, 100),
5005
+ selectedText: sel ? sel.toString().substring(0, 100) : '',
4986
5006
  target: {
4987
5007
  tagName: event.target?.tagName || '',
4988
5008
  id: event.target?.id || ''
4989
5009
  },
4990
5010
  path: window.location.pathname
4991
5011
  };
4992
-
4993
5012
  InteractionManager.add('copyPasteEvents', copyData);
5013
+ // Fire structured auto-event (low volume — only when user explicitly copies)
5014
+ EventsManager.trackAutoEvent('copy_action', {
5015
+ text_length: sel ? sel.toString().length : 0,
5016
+ element_id: event.target?.id || '',
5017
+ element_tag: event.target?.tagName?.toLowerCase() || '',
5018
+ element_class: (event.target?.className || '').split(' ').filter(Boolean).slice(0, 3).join(' '),
5019
+ });
4994
5020
  });
4995
-
5021
+
4996
5022
  document.addEventListener('paste', (event) => {
4997
5023
  const pasteData = {
4998
5024
  type: 'paste_action',
@@ -5002,7 +5028,6 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5002
5028
  },
5003
5029
  path: window.location.pathname
5004
5030
  };
5005
-
5006
5031
  InteractionManager.add('copyPasteEvents', pasteData);
5007
5032
  });
5008
5033
  } catch (error) {
@@ -5028,8 +5053,15 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5028
5053
  },
5029
5054
  path: window.location.pathname
5030
5055
  };
5031
-
5032
5056
  InteractionManager.add('contextMenuEvents', contextData);
5057
+ // Fire structured auto-event (low volume — only when user right-clicks)
5058
+ EventsManager.trackAutoEvent('context_menu', {
5059
+ element_tag: event.target?.tagName?.toLowerCase() || '',
5060
+ element_id: event.target?.id || '',
5061
+ element_class: (event.target?.className || '').split(' ').filter(Boolean).slice(0, 3).join(' '),
5062
+ page_x: event.pageX,
5063
+ page_y: event.pageY,
5064
+ });
5033
5065
  });
5034
5066
  } catch (error) {
5035
5067
  }
@@ -5208,23 +5240,62 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5208
5240
  */
5209
5241
  startPerformanceTracking() {
5210
5242
  try {
5243
+ // Collect Core Web Vitals via PerformanceObserver + Navigation Timing
5244
+ // Emits a single queryable page_performance auto event per page load
5245
+ const vitals = { lcp: null, cls: 0, inp: null, fcp: null, ttfb: null };
5246
+
5247
+ // LCP
5248
+ try {
5249
+ new PerformanceObserver((list) => {
5250
+ const entries = list.getEntries();
5251
+ if (entries.length > 0) {
5252
+ vitals.lcp = Math.round(entries[entries.length - 1].startTime);
5253
+ }
5254
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
5255
+ } catch (e) {}
5256
+
5257
+ // CLS
5258
+ try {
5259
+ new PerformanceObserver((list) => {
5260
+ for (const entry of list.getEntries()) {
5261
+ if (!entry.hadRecentInput) {
5262
+ vitals.cls = Math.round((vitals.cls + entry.value) * 10000) / 10000;
5263
+ }
5264
+ }
5265
+ }).observe({ type: 'layout-shift', buffered: true });
5266
+ } catch (e) {}
5267
+
5268
+ // INP (interaction to next paint)
5269
+ try {
5270
+ new PerformanceObserver((list) => {
5271
+ for (const entry of list.getEntries()) {
5272
+ if (entry.duration && (vitals.inp === null || entry.duration > vitals.inp)) {
5273
+ vitals.inp = Math.round(entry.duration);
5274
+ }
5275
+ }
5276
+ }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
5277
+ } catch (e) {}
5278
+
5279
+ // FCP + TTFB from paint + navigation entries (available after load)
5211
5280
  window.addEventListener('load', () => {
5212
- // Wait a bit for all resources to load
5213
5281
  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);
5282
+ try {
5283
+ const nav = performance.getEntriesByType('navigation')[0];
5284
+ const paint = performance.getEntriesByType('paint');
5285
+ vitals.ttfb = nav ? Math.round(nav.responseStart - nav.requestStart) : null;
5286
+ vitals.fcp = paint.find(p => p.name === 'first-contentful-paint')
5287
+ ? Math.round(paint.find(p => p.name === 'first-contentful-paint').startTime)
5288
+ : null;
5289
+ } catch (e) {}
5290
+
5291
+ EventsManager.trackAutoEvent('page_performance', {
5292
+ lcp: vitals.lcp,
5293
+ cls: vitals.cls,
5294
+ inp: vitals.inp,
5295
+ fcp: vitals.fcp,
5296
+ ttfb: vitals.ttfb
5297
+ });
5298
+ }, 1500);
5228
5299
  });
5229
5300
  } catch (error) {
5230
5301
  }
@@ -5232,40 +5303,28 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5232
5303
 
5233
5304
  /**
5234
5305
  * ErrorTracker - Tracks JavaScript errors
5306
+ * Emits as queryable auto events (js_error) instead of session-level JSONB
5235
5307
  */
5236
5308
  startErrorTracking() {
5237
5309
  try {
5238
5310
  window.addEventListener('error', (event) => {
5239
- const errorData = {
5240
- type: 'javascript_error',
5311
+ EventsManager.trackAutoEvent('js_error', {
5312
+ error_type: 'javascript_error',
5241
5313
  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);
5314
+ source: event.filename || '',
5315
+ line: event.lineno || 0,
5316
+ column: event.colno || 0,
5317
+ stack: event.error?.stack || ''
5318
+ });
5257
5319
  });
5258
-
5320
+
5259
5321
  // Track unhandled promise rejections
5260
5322
  window.addEventListener('unhandledrejection', (event) => {
5261
- const errorData = {
5262
- type: 'unhandled_promise_rejection',
5323
+ EventsManager.trackAutoEvent('js_error', {
5324
+ error_type: 'unhandled_promise_rejection',
5263
5325
  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);
5326
+ stack: event.reason?.stack || String(event.reason || '')
5327
+ });
5269
5328
  });
5270
5329
  } catch (error) {
5271
5330
  }
@@ -5303,32 +5362,24 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5303
5362
 
5304
5363
  xhr.addEventListener('load', function() {
5305
5364
  if (xhr.status >= 400) {
5306
- const networkErrorData = {
5307
- type: 'api_error',
5365
+ EventsManager.trackAutoEvent('network_error', {
5308
5366
  url: xhr._cryptiqueUrl,
5309
5367
  method: xhr._cryptiqueMethod || 'GET',
5310
5368
  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);
5369
+ status_text: `HTTP ${xhr.status} Error`,
5370
+ duration_ms: Date.now() - startTime
5371
+ });
5317
5372
  }
5318
5373
  });
5319
-
5374
+
5320
5375
  xhr.addEventListener('error', function() {
5321
- const networkErrorData = {
5322
- type: 'api_error',
5376
+ EventsManager.trackAutoEvent('network_error', {
5323
5377
  url: xhr._cryptiqueUrl,
5324
5378
  method: xhr._cryptiqueMethod || 'GET',
5325
5379
  status: 0,
5326
- error: 'XHR Network Error',
5327
- duration: Date.now() - startTime,
5328
- path: window.location.pathname
5329
- };
5330
-
5331
- InteractionManager.add('networkEvents', networkErrorData);
5380
+ status_text: 'XHR Network Error',
5381
+ duration_ms: Date.now() - startTime
5382
+ });
5332
5383
  });
5333
5384
 
5334
5385
  return originalXHRSend.apply(this, args);
@@ -5343,9 +5394,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5343
5394
  startAdvancedFormTracking() {
5344
5395
  try {
5345
5396
  const formStartTimes = new Map();
5346
-
5397
+
5347
5398
  document.addEventListener('focus', (event) => {
5348
- if (event.target &&
5399
+ if (event.target &&
5349
5400
  (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA')) {
5350
5401
  const formId = event.target.form?.id || 'unknown';
5351
5402
  if (!formStartTimes.has(formId)) {
@@ -5353,11 +5404,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5353
5404
  }
5354
5405
  }
5355
5406
  });
5356
-
5407
+
5357
5408
  document.addEventListener('submit', (event) => {
5358
5409
  const formId = event.target.id || 'unknown';
5359
5410
  const startTime = formStartTimes.get(formId);
5360
-
5411
+
5361
5412
  if (startTime) {
5362
5413
  const formCompletionData = {
5363
5414
  type: 'form_completion',
@@ -5366,7 +5417,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5366
5417
  fieldCount: event.target.elements.length,
5367
5418
  path: window.location.pathname
5368
5419
  };
5369
-
5420
+
5370
5421
  InteractionManager.add('formAnalytics', formCompletionData);
5371
5422
  formStartTimes.delete(formId);
5372
5423
  }
@@ -5375,9 +5426,333 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5375
5426
  }
5376
5427
  },
5377
5428
 
5429
+ /**
5430
+ * FormAbandonmentTracker - Emits form_abandoned auto event when a user
5431
+ * interacts with a form but navigates away without submitting.
5432
+ * Tracks which field they stopped at — high-value PM signal.
5433
+ */
5434
+ startFormAbandonmentTracking() {
5435
+ try {
5436
+ // Per-form state: formId → { startTime, lastFieldId, lastFieldType, filledFields, totalFields, submitted }
5437
+ const formState = new Map();
5438
+
5439
+ const getOrInitForm = (formEl) => {
5440
+ const formId = formEl?.id || formEl?.name || 'form_' + (formEl ? [...document.forms].indexOf(formEl) : 0);
5441
+ if (!formState.has(formId)) {
5442
+ const totalFields = formEl
5443
+ ? formEl.querySelectorAll('input:not([type=hidden]), select, textarea').length
5444
+ : 0;
5445
+ formState.set(formId, {
5446
+ formId,
5447
+ startTime: Date.now(),
5448
+ lastFieldId: '',
5449
+ lastFieldType: '',
5450
+ filledFields: new Set(),
5451
+ totalFields,
5452
+ submitted: false
5453
+ });
5454
+ }
5455
+ return formState.get(formId);
5456
+ };
5457
+
5458
+ // Track field focus to know where user is
5459
+ document.addEventListener('focus', (event) => {
5460
+ const target = event.target;
5461
+ if (!target || !['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
5462
+ if (target.type === 'hidden') return;
5463
+ const state = getOrInitForm(target.form);
5464
+ state.lastFieldId = target.id || target.name || '';
5465
+ state.lastFieldType = target.type || target.tagName.toLowerCase();
5466
+ }, true);
5467
+
5468
+ // Track field blur to record which fields have been filled
5469
+ document.addEventListener('blur', (event) => {
5470
+ const target = event.target;
5471
+ if (!target || !['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
5472
+ if (target.type === 'hidden') return;
5473
+ const state = getOrInitForm(target.form);
5474
+ const fieldKey = target.id || target.name || target.type;
5475
+ if (target.value && target.value.length > 0 && fieldKey) {
5476
+ state.filledFields.add(fieldKey);
5477
+ }
5478
+ }, true);
5479
+
5480
+ // Mark form as submitted so we don't fire form_abandoned
5481
+ document.addEventListener('submit', (event) => {
5482
+ const formId = event.target?.id || event.target?.name || 'form_' + [...document.forms].indexOf(event.target);
5483
+ if (formState.has(formId)) {
5484
+ formState.get(formId).submitted = true;
5485
+ }
5486
+ }, true);
5487
+
5488
+ // On page unload, fire form_abandoned for any touched, unsubmitted forms
5489
+ window.addEventListener('beforeunload', () => {
5490
+ formState.forEach((state) => {
5491
+ if (!state.submitted && state.filledFields.size > 0) {
5492
+ // Fire synchronously via sendBeacon path — trackAutoEvent is async
5493
+ // but beforeunload already handles this via sendBeacon in _sendEvent
5494
+ // Resolve the actual form DOM element (if still in DOM)
5495
+ const formEl = state.formId && state.formId !== 'unknown'
5496
+ ? document.getElementById(state.formId) || document.querySelector(`form[name="${state.formId}"]`)
5497
+ : null;
5498
+ EventsManager.trackAutoEvent('form_abandoned', {
5499
+ form_id: state.formId,
5500
+ last_active_field_id: state.lastFieldId,
5501
+ last_active_field_type: state.lastFieldType,
5502
+ fields_filled_count: state.filledFields.size,
5503
+ total_fields_count: state.totalFields,
5504
+ time_on_form_ms: Date.now() - state.startTime
5505
+ }, {
5506
+ element_id: formEl?.id || state.formId || '',
5507
+ element_name: formEl?.getAttribute('name') || '',
5508
+ element_tag_name: 'FORM',
5509
+ element_type: '',
5510
+ element_class: formEl?.className || '',
5511
+ element_text: (formEl?.getAttribute('aria-label') || '').slice(0, 100)
5512
+ });
5513
+ }
5514
+ });
5515
+ });
5516
+ } catch (error) {
5517
+ }
5518
+ },
5519
+
5520
+ /**
5521
+ * ElementVisibilityTracker - Emits element_view auto events when elements
5522
+ * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5523
+ * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5524
+ * vs just didn't click them — completely different problems, opposite fixes.
5525
+ */
5526
+ startElementVisibilityTracking() {
5527
+ try {
5528
+ if (typeof IntersectionObserver === 'undefined') return;
5529
+
5530
+ // Only observe elements with an id or data-cq-track attribute
5531
+ const getTrackableElements = () =>
5532
+ document.querySelectorAll('[id]:not([id=""]), [data-cq-track]');
5533
+
5534
+ const viewTimers = new Map(); // elementKey → setTimeout handle
5535
+ const reported = new Set(); // elementKey → already fired once per page load
5536
+
5537
+ const observer = new IntersectionObserver((entries) => {
5538
+ entries.forEach((entry) => {
5539
+ const el = entry.target;
5540
+ const key = el.id || el.getAttribute('data-cq-track') || el.className;
5541
+ if (!key || reported.has(key)) return;
5542
+
5543
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5544
+ // Element entered viewport — start 1s timer
5545
+ if (!viewTimers.has(key)) {
5546
+ const enterTime = Date.now();
5547
+ const timer = setTimeout(() => {
5548
+ if (reported.has(key)) return;
5549
+ reported.add(key);
5550
+ EventsManager.trackAutoEvent('element_view', {
5551
+ time_in_viewport_ms: Date.now() - enterTime,
5552
+ viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5553
+ }, {
5554
+ element_id: el.id || '',
5555
+ element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5556
+ element_tag_name: el.tagName || '',
5557
+ element_type: el.type || el.getAttribute('type') || '',
5558
+ element_class: el.className || '',
5559
+ element_text: (el.textContent || '').trim().slice(0, 100)
5560
+ });
5561
+ viewTimers.delete(key);
5562
+ }, 1000);
5563
+ viewTimers.set(key, timer);
5564
+ }
5565
+ } else {
5566
+ // Element left viewport before 1s — cancel timer
5567
+ if (viewTimers.has(key)) {
5568
+ clearTimeout(viewTimers.get(key));
5569
+ viewTimers.delete(key);
5570
+ }
5571
+ }
5572
+ });
5573
+ }, { threshold: 0.5 });
5574
+
5575
+ // Observe existing elements
5576
+ getTrackableElements().forEach(el => observer.observe(el));
5577
+
5578
+ // Observe elements added to DOM later (SPAs)
5579
+ if (typeof MutationObserver !== 'undefined') {
5580
+ new MutationObserver(() => {
5581
+ getTrackableElements().forEach(el => {
5582
+ if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5583
+ observer.observe(el);
5584
+ }
5585
+ });
5586
+ }).observe(document.body, { childList: true, subtree: true });
5587
+ }
5588
+ } catch (error) {
5589
+ }
5590
+ },
5591
+
5592
+ /**
5593
+ * PageSummaryTracker — collects high-frequency signals in-memory and flushes
5594
+ * them as a single 'page_summary' auto-event on page unload.
5595
+ *
5596
+ * This keeps event counts low (1 per page visit) while capturing:
5597
+ * - Mouse movement grid (move_grid)
5598
+ * - Hover dwell grid (hover_grid)
5599
+ * - Tab-switch count and hidden time
5600
+ * - Scroll reversal count
5601
+ * - Per-field focus dwell times
5602
+ */
5603
+ startPageSummaryTracking() {
5604
+ try {
5605
+ const GRID_SIZE = 40;
5606
+ // In-memory accumulators — never sent to the server individually
5607
+ const moveGrid = new Map(); // cellKey → move count
5608
+ const hoverGrid = new Map(); // cellKey → total dwell_ms
5609
+ const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5610
+
5611
+ let tabSwitches = 0;
5612
+ let totalTabHiddenMs = 0;
5613
+ let tabHiddenTime = null;
5614
+ let scrollReversals = 0;
5615
+
5616
+ // Track last significant scroll position for reversal detection
5617
+ let lastSigScrollY = window.scrollY;
5618
+ let lastSigDir = 0; // 1=down, -1=up
5619
+
5620
+ function getGridCell(pageX, pageY) {
5621
+ const docW = Math.max(1, document.documentElement.scrollWidth || document.body.scrollWidth || window.innerWidth);
5622
+ const docH = Math.max(1, document.documentElement.scrollHeight || document.body.scrollHeight || window.innerHeight);
5623
+ const col = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor((pageX / docW) * GRID_SIZE)));
5624
+ const row = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor((pageY / docH) * GRID_SIZE)));
5625
+ return col * GRID_SIZE + row;
5626
+ }
5627
+
5628
+ // ── Mouse movement → move_grid (throttled to max 1 sample / 100 ms)
5629
+ let moveThrottle = false;
5630
+ document.addEventListener('mousemove', (e) => {
5631
+ if (moveThrottle) return;
5632
+ moveThrottle = true;
5633
+ setTimeout(() => { moveThrottle = false; }, 100);
5634
+ const key = getGridCell(e.pageX, e.pageY);
5635
+ moveGrid.set(key, (moveGrid.get(key) || 0) + 1);
5636
+ }, { passive: true });
5637
+
5638
+ // ── Hover dwell → hover_grid (track all elements, filter < 100 ms)
5639
+ const hoverStarts = new Map(); // element → enterTime
5640
+ document.addEventListener('mouseover', (e) => {
5641
+ if (e.target && e.target !== document.documentElement) {
5642
+ hoverStarts.set(e.target, Date.now());
5643
+ }
5644
+ }, { passive: true });
5645
+ document.addEventListener('mouseout', (e) => {
5646
+ const target = e.target;
5647
+ if (!target || !hoverStarts.has(target)) return;
5648
+ const dwell = Date.now() - hoverStarts.get(target);
5649
+ hoverStarts.delete(target);
5650
+ if (dwell < 100) return; // skip accidental hovers
5651
+ try {
5652
+ const rect = target.getBoundingClientRect();
5653
+ const cx = rect.left + rect.width / 2 + window.scrollX;
5654
+ const cy = rect.top + rect.height / 2 + window.scrollY;
5655
+ const key = getGridCell(cx, cy);
5656
+ hoverGrid.set(key, (hoverGrid.get(key) || 0) + dwell);
5657
+ } catch (_) {}
5658
+ }, { passive: true });
5659
+
5660
+ // ── Tab visibility → tab_switches + total_tab_hidden_ms
5661
+ document.addEventListener('visibilitychange', () => {
5662
+ if (document.hidden) {
5663
+ tabHiddenTime = Date.now();
5664
+ tabSwitches++;
5665
+ } else if (tabHiddenTime !== null) {
5666
+ totalTabHiddenMs += Date.now() - tabHiddenTime;
5667
+ tabHiddenTime = null;
5668
+ }
5669
+ });
5670
+
5671
+ // ── Scroll reversal detection (direction change > 200 px)
5672
+ window.addEventListener('scroll', () => {
5673
+ const curr = window.scrollY;
5674
+ const diff = curr - lastSigScrollY;
5675
+ if (Math.abs(diff) < 50) return; // ignore tiny movements
5676
+ const dir = diff > 0 ? 1 : -1;
5677
+ if (lastSigDir !== 0 && dir !== lastSigDir && Math.abs(curr - lastSigScrollY) >= 200) {
5678
+ scrollReversals++;
5679
+ }
5680
+ if (Math.abs(diff) >= 50) { lastSigDir = dir; lastSigScrollY = curr; }
5681
+ }, { passive: true });
5682
+
5683
+ // ── Field dwell → per-field focus duration (no content captured)
5684
+ const fieldFocusTimes = new Map();
5685
+ document.addEventListener('focus', (e) => {
5686
+ const t = e.target;
5687
+ if (!t || !['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName)) return;
5688
+ if (t.type === 'hidden') return;
5689
+ fieldFocusTimes.set(t, Date.now());
5690
+ }, true);
5691
+ document.addEventListener('blur', (e) => {
5692
+ const t = e.target;
5693
+ if (!t || !fieldFocusTimes.has(t)) return;
5694
+ const dwell = Date.now() - fieldFocusTimes.get(t);
5695
+ fieldFocusTimes.delete(t);
5696
+ if (dwell < 100) return; // skip accidental focus
5697
+ const label = t.id
5698
+ ? (document.querySelector(`label[for="${CSS.escape(t.id)}"]`)?.textContent?.trim() || t.placeholder || t.name || t.id)
5699
+ : (t.placeholder || t.name || t.type || '');
5700
+ fieldDwells.push({
5701
+ field_id: (t.id || t.name || t.type || '').substring(0, 100),
5702
+ field_label: (label || '').substring(0, 100),
5703
+ field_type: t.type || t.tagName.toLowerCase(),
5704
+ dwell_ms: dwell,
5705
+ was_filled: !!(t.value && t.value.length > 0),
5706
+ });
5707
+ }, true);
5708
+
5709
+ // ── Serialise grid Map to [[col, row, value], ...] sparse array
5710
+ function serializeGrid(grid) {
5711
+ const out = [];
5712
+ grid.forEach((val, key) => {
5713
+ if (val > 0) {
5714
+ const col = Math.floor(key / GRID_SIZE);
5715
+ const row = key % GRID_SIZE;
5716
+ out.push([col, row, Math.round(val)]);
5717
+ }
5718
+ });
5719
+ return out;
5720
+ }
5721
+
5722
+ let summarySent = false;
5723
+ function firePageSummary() {
5724
+ if (summarySent) return; // only fire once per page lifecycle
5725
+ if (moveGrid.size === 0 && hoverGrid.size === 0 && fieldDwells.length === 0
5726
+ && tabSwitches === 0 && scrollReversals === 0) return;
5727
+ summarySent = true;
5728
+ try {
5729
+ EventsManager.trackAutoEvent('page_summary', {
5730
+ scroll_reversals: scrollReversals,
5731
+ tab_switches: tabSwitches,
5732
+ total_tab_hidden_ms: totalTabHiddenMs,
5733
+ move_grid: serializeGrid(moveGrid),
5734
+ hover_grid: serializeGrid(hoverGrid),
5735
+ field_dwells: fieldDwells,
5736
+ document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5737
+ document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5738
+ viewport_width: window.innerWidth,
5739
+ viewport_height: window.innerHeight,
5740
+ });
5741
+ } catch (_) {}
5742
+ }
5743
+
5744
+ // Fire on hard navigation / tab close (pagehide is more reliable than beforeunload)
5745
+ window.addEventListener('pagehide', firePageSummary);
5746
+ // Also fire when tab becomes hidden (covers SPA navigation that doesn't fire pagehide)
5747
+ document.addEventListener('visibilitychange', () => {
5748
+ if (document.hidden) firePageSummary();
5749
+ });
5750
+ } catch (error) {}
5751
+ },
5752
+
5378
5753
  /**
5379
5754
  * Initialize all event trackers
5380
- *
5755
+ *
5381
5756
  * Starts all tracking modules
5382
5757
  */
5383
5758
  initialize() {
@@ -5396,6 +5771,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5396
5771
  this.startErrorTracking();
5397
5772
  this.startNetworkTracking();
5398
5773
  this.startAdvancedFormTracking();
5774
+ this.startFormAbandonmentTracking();
5775
+ this.startElementVisibilityTracking();
5776
+ this.startPageSummaryTracking();
5399
5777
  }
5400
5778
  };
5401
5779
 
@@ -5531,9 +5909,36 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5531
5909
  }
5532
5910
  },
5533
5911
 
5912
+ /**
5913
+ * Tab Visibility Accumulator
5914
+ * Tracks how much time the tab was actually in the foreground.
5915
+ * Writes only to runtimeState — never triggers session end or timing bugs.
5916
+ * active_time_ms is included in every session heartbeat for backend persistence.
5917
+ */
5918
+ startTabVisibilityTracking() {
5919
+ try {
5920
+ // Initialise: tab starts visible
5921
+ runtimeState.tabFocusTimestamp = document.hidden ? null : Date.now();
5922
+
5923
+ document.addEventListener('visibilitychange', () => {
5924
+ if (document.hidden) {
5925
+ // Tab went to background — accumulate time since last focus
5926
+ if (runtimeState.tabFocusTimestamp !== null) {
5927
+ runtimeState.activeTimeMs += Date.now() - runtimeState.tabFocusTimestamp;
5928
+ runtimeState.tabFocusTimestamp = null;
5929
+ }
5930
+ } else {
5931
+ // Tab came back to foreground
5932
+ runtimeState.tabFocusTimestamp = Date.now();
5933
+ }
5934
+ });
5935
+ } catch (error) {
5936
+ }
5937
+ },
5938
+
5534
5939
  /**
5535
5940
  * Start session tracking interval
5536
- *
5941
+ *
5537
5942
  * Periodically sends session data and updates session metrics
5538
5943
  */
5539
5944
  startSessionTracking() {
@@ -5611,6 +6016,11 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5611
6016
  // Always sync distinctId from storage before sending to ensure consistency
5612
6017
  sessionData.distinctId = StorageManager.getDistinctId();
5613
6018
 
6019
+ // Include accumulated active time (tab foreground time only)
6020
+ const currentActiveMs = runtimeState.activeTimeMs +
6021
+ (runtimeState.tabFocusTimestamp !== null ? Date.now() - runtimeState.tabFocusTimestamp : 0);
6022
+ sessionData.active_time_ms = currentActiveMs;
6023
+
5614
6024
  // Send session data
5615
6025
  await APIClient.sendSessionData();
5616
6026
  } catch (error) {
@@ -5778,6 +6188,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5778
6188
  // Start event trackers
5779
6189
  EventTrackers.initialize();
5780
6190
 
6191
+ // Start tab visibility accumulator (active_time_ms)
6192
+ this.startTabVisibilityTracking();
6193
+
5781
6194
  // Start session tracking interval
5782
6195
  this.startSessionTracking();
5783
6196
 
@@ -6136,6 +6549,9 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6136
6549
  'Content-Type': 'application/json',
6137
6550
  'X-Cryptique-Site-Id': getCurrentSiteId()
6138
6551
  },
6552
+ // keepalive: true allows this request to outlive the page (critical for
6553
+ // form_abandoned events fired in beforeunload handlers)
6554
+ keepalive: true,
6139
6555
  body: JSON.stringify(eventData)
6140
6556
  });
6141
6557
 
@@ -6160,22 +6576,38 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6160
6576
  */
6161
6577
  _getAutoEventCategory(eventName) {
6162
6578
  const categoryMap = {
6579
+ // Navigation
6163
6580
  'page_view': 'navigation',
6581
+ // Interaction
6164
6582
  'page_scroll': 'interaction',
6165
6583
  'element_click': 'interaction',
6166
6584
  'element_hover': 'interaction',
6585
+ 'dead_click': 'interaction',
6586
+ 'rage_click': 'interaction',
6587
+ 'text_selection': 'interaction',
6588
+ // Form
6167
6589
  'form_submit': 'form',
6168
- 'form_focus': 'form',
6169
- 'form_blur': 'form',
6170
- 'form_change': 'form',
6590
+ 'form_validation_error': 'form',
6591
+ 'form_abandoned': 'form',
6592
+ // Media
6171
6593
  'media_play': 'media',
6172
6594
  'media_pause': 'media',
6173
6595
  'media_ended': 'media',
6174
- 'dead_click': 'interaction',
6175
- 'rage_click': 'interaction',
6176
- 'text_selection': 'interaction',
6596
+ // Session
6177
6597
  'session_start': 'session',
6178
- 'session_end': 'session'
6598
+ 'session_end': 'session',
6599
+ // Error
6600
+ 'js_error': 'error',
6601
+ 'network_error': 'error',
6602
+ // Performance
6603
+ 'page_performance': 'performance',
6604
+ // Visibility
6605
+ 'element_view': 'visibility',
6606
+ // Page summary (aggregate)
6607
+ 'page_summary': 'interaction',
6608
+ // Clipboard & context
6609
+ 'copy_action': 'interaction',
6610
+ 'context_menu': 'interaction',
6179
6611
  };
6180
6612
 
6181
6613
  return categoryMap[eventName] || 'other';
@@ -6968,50 +7400,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6968
7400
  }
6969
7401
  });
6970
7402
 
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);
7403
+ // form_focus, form_blur, form_change are intentionally NOT fired as auto events.
7404
+ // form_abandoned already captures the meaningful signal (which field, how many filled,
7405
+ // time spent) without the per-keystroke noise these would generate.
7009
7406
  },
7010
7407
 
7011
7408
  /**
7012
7409
  * Setup media tracking
7410
+ * play/pause events are throttled per element (2s cooldown) to filter out
7411
+ * scrubbing noise — a user seeking through a video fires rapid play/pause
7412
+ * pairs that have no PM value.
7013
7413
  */
7014
7414
  setupMediaTracking() {
7415
+ // Per-element last-event timestamps — keyed by src or generated index
7416
+ const lastMediaEventTime = new Map();
7417
+ const MEDIA_THROTTLE_MS = 2000;
7418
+
7419
+ function getMediaKey(el) {
7420
+ return el.src || el.id || el.currentSrc || [...document.querySelectorAll('video,audio')].indexOf(el).toString();
7421
+ }
7422
+
7015
7423
  function getPageContext() {
7016
7424
  const scrollX = window.scrollX != null ? window.scrollX : window.pageXOffset;
7017
7425
  const scrollY = window.scrollY != null ? window.scrollY : window.pageYOffset;
@@ -7019,49 +7427,56 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7019
7427
  const docWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
7020
7428
  return { scrollX, scrollY, docHeight, docWidth };
7021
7429
  }
7430
+
7022
7431
  ['video', 'audio'].forEach(mediaType => {
7023
7432
  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
- }
7433
+ if (event.target.tagName.toLowerCase() !== mediaType) return;
7434
+ const el = event.target;
7435
+ const key = `play:${getMediaKey(el)}`;
7436
+ const now = Date.now();
7437
+ if (now - (lastMediaEventTime.get(key) || 0) < MEDIA_THROTTLE_MS) return;
7438
+ lastMediaEventTime.set(key, now);
7439
+
7440
+ const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7441
+ const rect = el.getBoundingClientRect();
7442
+ EventsManager.trackAutoEvent('media_play', {
7443
+ media_type: mediaType,
7444
+ media_src: el.src || null,
7445
+ media_duration: el.duration || null,
7446
+ media_current_time: el.currentTime || null,
7447
+ scroll_x: scrollX,
7448
+ scroll_y: scrollY,
7449
+ document_height: docHeight,
7450
+ document_width: docWidth,
7451
+ page_x: rect.left + scrollX,
7452
+ page_y: rect.top + scrollY,
7453
+ click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7454
+ });
7043
7455
  }, true);
7044
7456
 
7045
7457
  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
- }
7458
+ if (event.target.tagName.toLowerCase() !== mediaType) return;
7459
+ const el = event.target;
7460
+ const key = `pause:${getMediaKey(el)}`;
7461
+ const now = Date.now();
7462
+ if (now - (lastMediaEventTime.get(key) || 0) < MEDIA_THROTTLE_MS) return;
7463
+ lastMediaEventTime.set(key, now);
7464
+
7465
+ const { scrollX, scrollY, docHeight, docWidth } = getPageContext();
7466
+ const rect = el.getBoundingClientRect();
7467
+ EventsManager.trackAutoEvent('media_pause', {
7468
+ media_type: mediaType,
7469
+ media_src: el.src || null,
7470
+ media_current_time: el.currentTime || null,
7471
+ media_duration: el.duration || null,
7472
+ scroll_x: scrollX,
7473
+ scroll_y: scrollY,
7474
+ document_height: docHeight,
7475
+ document_width: docWidth,
7476
+ page_x: rect.left + scrollX,
7477
+ page_y: rect.top + scrollY,
7478
+ click_coordinates: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
7479
+ });
7065
7480
  }, true);
7066
7481
  });
7067
7482
  },
@@ -7088,12 +7503,14 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7088
7503
 
7089
7504
  const selectedText = selection.toString().trim();
7090
7505
 
7091
- // Only track if there's actual text selected and it's different from last selection
7092
- if (selectedText && selectedText.length > 0 && selectedText !== lastSelectionText) {
7506
+ // Only track if selection is meaningful (≥3 chars) and different from last selection.
7507
+ // <3 chars filters out accidental double-clicks and single-word mis-selections.
7508
+ if (selectedText && selectedText.length >= 3 && selectedText !== lastSelectionText) {
7093
7509
  const now = Date.now();
7094
-
7095
- // Don't track if selection happened too quickly (likely accidental)
7096
- if (now - lastSelectionTime < 200) {
7510
+
7511
+ // 1s cooldown between events prevents rapid re-selection noise
7512
+ // (e.g. user adjusting selection handles fires many selectionchange events)
7513
+ if (now - lastSelectionTime < 1000) {
7097
7514
  return;
7098
7515
  }
7099
7516
 
@@ -7987,6 +8404,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
7987
8404
  * The script.js file is inlined during the build process by Rollup.
7988
8405
  */
7989
8406
 
8407
+
7990
8408
  // Create a wrapper that provides programmatic initialization
7991
8409
  const CryptiqueSDK = {
7992
8410
  /**