@sessionvision/core 0.2.0 → 0.4.0

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.
Files changed (50) hide show
  1. package/README.md +14 -11
  2. package/dist/react/capture/autocapture.d.ts +2 -1
  3. package/dist/react/capture/deadclick.d.ts +19 -0
  4. package/dist/react/capture/formfields.d.ts +53 -0
  5. package/dist/react/capture/rageclick.d.ts +25 -0
  6. package/dist/react/core/config.d.ts +14 -1
  7. package/dist/react/core/init.d.ts +25 -0
  8. package/dist/react/recorder/chunk.d.ts +59 -0
  9. package/dist/react/recorder/index.d.ts +46 -0
  10. package/dist/react/recorder/mask.d.ts +13 -0
  11. package/dist/react/recorder/rrweb.d.ts +19 -0
  12. package/dist/react/recorder/upload.d.ts +20 -0
  13. package/dist/react/types.d.ts +119 -4
  14. package/dist/sessionvision-recorder.js +13041 -0
  15. package/dist/sessionvision-recorder.js.map +1 -0
  16. package/dist/sessionvision-recorder.min.js +7 -0
  17. package/dist/sessionvision-recorder.min.js.map +1 -0
  18. package/dist/sessionvision.cjs.js +1084 -262
  19. package/dist/sessionvision.cjs.js.map +1 -1
  20. package/dist/sessionvision.esm.js +1084 -262
  21. package/dist/sessionvision.esm.js.map +1 -1
  22. package/dist/sessionvision.js +1084 -262
  23. package/dist/sessionvision.js.map +1 -1
  24. package/dist/sessionvision.min.js +2 -2
  25. package/dist/sessionvision.min.js.map +1 -1
  26. package/dist/types/capture/autocapture.d.ts +2 -1
  27. package/dist/types/capture/deadclick.d.ts +19 -0
  28. package/dist/types/capture/formfields.d.ts +53 -0
  29. package/dist/types/capture/rageclick.d.ts +25 -0
  30. package/dist/types/core/config.d.ts +14 -1
  31. package/dist/types/core/init.d.ts +25 -0
  32. package/dist/types/recorder/chunk.d.ts +59 -0
  33. package/dist/types/recorder/index.d.ts +46 -0
  34. package/dist/types/recorder/mask.d.ts +13 -0
  35. package/dist/types/recorder/rrweb.d.ts +19 -0
  36. package/dist/types/recorder/upload.d.ts +20 -0
  37. package/dist/types/types.d.ts +119 -4
  38. package/dist/vue/capture/autocapture.d.ts +2 -1
  39. package/dist/vue/capture/deadclick.d.ts +19 -0
  40. package/dist/vue/capture/formfields.d.ts +53 -0
  41. package/dist/vue/capture/rageclick.d.ts +25 -0
  42. package/dist/vue/core/config.d.ts +14 -1
  43. package/dist/vue/core/init.d.ts +25 -0
  44. package/dist/vue/recorder/chunk.d.ts +59 -0
  45. package/dist/vue/recorder/index.d.ts +46 -0
  46. package/dist/vue/recorder/mask.d.ts +13 -0
  47. package/dist/vue/recorder/rrweb.d.ts +19 -0
  48. package/dist/vue/recorder/upload.d.ts +20 -0
  49. package/dist/vue/types.d.ts +119 -4
  50. package/package.json +10 -7
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Session Vision Core v0.2.0
2
+ * Session Vision Core v0.4.0
3
3
  * (c) 2026 Session Vision
4
4
  * Released under the MIT License
5
5
  */
@@ -12,6 +12,9 @@
12
12
  /**
13
13
  * Session Vision Snippet Type Definitions
14
14
  */
15
+ /**
16
+ * Recording chunk configuration
17
+ */
15
18
  /**
16
19
  * Storage key constants
17
20
  */
@@ -35,8 +38,11 @@
35
38
  maskAllInputs: true,
36
39
  autocapture: {
37
40
  pageview: true,
38
- clicks: true,
41
+ click: true,
39
42
  formSubmit: true,
43
+ rageClick: true,
44
+ deadClick: true,
45
+ formAbandonment: true,
40
46
  },
41
47
  };
42
48
  /**
@@ -53,9 +59,9 @@
53
59
  RETRY_DELAYS_MS: [1000, 2000, 4000],
54
60
  };
55
61
  /**
56
- * Config cache TTL in milliseconds (1 hour)
62
+ * Config cache TTL in milliseconds (4 hours)
57
63
  */
58
- const CONFIG_CACHE_TTL_MS = 60 * 60 * 1000;
64
+ const CONFIG_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
59
65
 
60
66
  /**
61
67
  * Storage utility for localStorage and sessionStorage operations
@@ -356,8 +362,11 @@
356
362
  // Boolean: enable or disable all
357
363
  autocapture = {
358
364
  pageview: userConfig.autocapture,
359
- clicks: userConfig.autocapture,
365
+ click: userConfig.autocapture,
360
366
  formSubmit: userConfig.autocapture,
367
+ rageClick: userConfig.autocapture,
368
+ deadClick: userConfig.autocapture,
369
+ formAbandonment: userConfig.autocapture,
361
370
  };
362
371
  }
363
372
  else if (typeof userConfig.autocapture === 'object') {
@@ -365,8 +374,11 @@
365
374
  const userAutocapture = userConfig.autocapture;
366
375
  autocapture = {
367
376
  pageview: userAutocapture.pageview ?? DEFAULT_CONFIG.autocapture.pageview,
368
- clicks: userAutocapture.clicks ?? DEFAULT_CONFIG.autocapture.clicks,
377
+ click: userAutocapture.click ?? DEFAULT_CONFIG.autocapture.click,
369
378
  formSubmit: userAutocapture.formSubmit ?? DEFAULT_CONFIG.autocapture.formSubmit,
379
+ rageClick: userAutocapture.rageClick ?? DEFAULT_CONFIG.autocapture.rageClick,
380
+ deadClick: userAutocapture.deadClick ?? DEFAULT_CONFIG.autocapture.deadClick,
381
+ formAbandonment: userAutocapture.formAbandonment ?? DEFAULT_CONFIG.autocapture.formAbandonment,
370
382
  };
371
383
  }
372
384
  }
@@ -379,6 +391,7 @@
379
391
  optOut: userConfig?.optOut ?? DEFAULT_CONFIG.optOut,
380
392
  maskAllInputs: userConfig?.maskAllInputs ?? DEFAULT_CONFIG.maskAllInputs,
381
393
  autocapture,
394
+ recording: userConfig?.recording,
382
395
  };
383
396
  }
384
397
  /**
@@ -388,9 +401,9 @@
388
401
  return `${STORAGE_KEYS.CONFIG_CACHE}_${projectToken}`;
389
402
  }
390
403
  /**
391
- * Get cached remote config if valid
404
+ * Get cached remote config if valid (exported for fast path in init.ts)
392
405
  */
393
- function getCachedConfig(projectToken) {
406
+ function getCachedRemoteConfig(projectToken) {
394
407
  const cached = getLocalStorage(getCacheKey(projectToken));
395
408
  if (!cached) {
396
409
  return null;
@@ -411,19 +424,38 @@
411
424
  };
412
425
  setLocalStorage(getCacheKey(projectToken), cached);
413
426
  }
427
+ /**
428
+ * Resolve the recorder bundle URL relative to the SDK's apiHost
429
+ */
430
+ function resolveRecorderUrl(config) {
431
+ return `${config.apiHost}/v${config.version}/sessionvision-recorder.min.js`;
432
+ }
414
433
  /**
415
434
  * Fetch remote configuration from server
416
435
  */
417
- async function fetchRemoteConfig(resolvedConfig) {
436
+ async function fetchRemoteConfig(resolvedConfig, options) {
418
437
  const { projectToken, debug } = resolvedConfig;
419
438
  // Check cache first
420
- const cached = getCachedConfig(projectToken);
439
+ const cached = getCachedRemoteConfig(projectToken);
421
440
  if (cached) {
422
441
  if (debug) {
423
442
  console.log('[SessionVision] Using cached config');
424
443
  }
425
444
  return cached;
426
445
  }
446
+ // Speculative prefetch for first-time visitors: inject <link rel="prefetch">
447
+ // for the recorder bundle while the config fetch is in flight
448
+ if (options?.prefetchRecorder && typeof document !== 'undefined') {
449
+ try {
450
+ const link = document.createElement('link');
451
+ link.rel = 'prefetch';
452
+ link.href = resolveRecorderUrl(resolvedConfig);
453
+ document.head.appendChild(link);
454
+ }
455
+ catch {
456
+ // Ignore prefetch errors — best-effort optimization
457
+ }
458
+ }
427
459
  try {
428
460
  // Fetch config from CDN (static file hosted at apiHost)
429
461
  const url = `${resolvedConfig.apiHost}/static/config/${projectToken}`;
@@ -838,7 +870,7 @@
838
870
  $timezone: getTimezone(),
839
871
  $locale: getLocale(),
840
872
  $connection_type: getConnectionType(),
841
- $lib_version: "0.2.0"
873
+ $lib_version: "0.4.0"
842
874
  ,
843
875
  };
844
876
  }
@@ -941,6 +973,246 @@
941
973
  captureEvent(eventName, properties, { includeAutoProperties: true });
942
974
  }
943
975
 
976
+ /**
977
+ * Payload compression module
978
+ * Uses CompressionStream API when available
979
+ */
980
+ /**
981
+ * Check if compression is supported
982
+ */
983
+ function isCompressionSupported() {
984
+ return (typeof CompressionStream !== 'undefined' &&
985
+ typeof ReadableStream !== 'undefined');
986
+ }
987
+ /**
988
+ * Compress a string using gzip
989
+ * Returns the compressed data as a Blob, or null if compression is not supported
990
+ */
991
+ async function compressPayload(data) {
992
+ if (!isCompressionSupported()) {
993
+ return null;
994
+ }
995
+ try {
996
+ const encoder = new TextEncoder();
997
+ const inputBytes = encoder.encode(data);
998
+ const stream = new ReadableStream({
999
+ start(controller) {
1000
+ controller.enqueue(inputBytes);
1001
+ controller.close();
1002
+ },
1003
+ });
1004
+ const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
1005
+ const reader = compressedStream.getReader();
1006
+ const chunks = [];
1007
+ while (true) {
1008
+ const { done, value } = await reader.read();
1009
+ if (done)
1010
+ break;
1011
+ chunks.push(value);
1012
+ }
1013
+ // Combine chunks into a single Blob
1014
+ return new Blob(chunks, { type: 'application/gzip' });
1015
+ }
1016
+ catch {
1017
+ // Compression failed, return null to use uncompressed
1018
+ return null;
1019
+ }
1020
+ }
1021
+ /**
1022
+ * Get the size of data in bytes
1023
+ */
1024
+ function getByteSize(data) {
1025
+ return new Blob([data]).size;
1026
+ }
1027
+ /**
1028
+ * Check if payload should be compressed (based on size threshold)
1029
+ * Only compress if payload is larger than 1KB
1030
+ */
1031
+ function shouldCompress(data) {
1032
+ return isCompressionSupported() && getByteSize(data) > 1024;
1033
+ }
1034
+
1035
+ /**
1036
+ * HTTP transport module
1037
+ * Handles sending events to the ingest API
1038
+ */
1039
+ let config$2 = null;
1040
+ let consecutiveFailures = 0;
1041
+ /**
1042
+ * Set the configuration
1043
+ */
1044
+ function setTransportConfig(cfg) {
1045
+ config$2 = cfg;
1046
+ }
1047
+ /**
1048
+ * Sleep for a given number of milliseconds
1049
+ */
1050
+ function sleep(ms) {
1051
+ return new Promise((resolve) => setTimeout(resolve, ms));
1052
+ }
1053
+ /**
1054
+ * Send events to the ingest API
1055
+ * Handles compression and retry logic
1056
+ */
1057
+ async function sendEvents(payload) {
1058
+ if (!config$2) {
1059
+ console.warn('[SessionVision] SDK not initialized');
1060
+ return false;
1061
+ }
1062
+ const url = `${config$2.ingestHost}/api/v1/ingest/events`;
1063
+ const jsonPayload = JSON.stringify(payload);
1064
+ // Try to compress if payload is large enough
1065
+ const useCompression = shouldCompress(jsonPayload);
1066
+ let body = jsonPayload;
1067
+ const headers = {
1068
+ 'Content-Type': 'application/json',
1069
+ };
1070
+ if (useCompression) {
1071
+ const compressed = await compressPayload(jsonPayload);
1072
+ if (compressed) {
1073
+ body = compressed;
1074
+ headers['Content-Type'] = 'application/json';
1075
+ headers['Content-Encoding'] = 'gzip';
1076
+ }
1077
+ }
1078
+ // Attempt to send with retries
1079
+ for (let attempt = 0; attempt <= BUFFER_CONFIG.MAX_RETRIES; attempt++) {
1080
+ try {
1081
+ const response = await fetch(url, {
1082
+ method: 'POST',
1083
+ headers,
1084
+ body,
1085
+ keepalive: true, // Keep connection alive for background sends
1086
+ });
1087
+ if (response.ok || response.status === 202) {
1088
+ // Success
1089
+ consecutiveFailures = 0;
1090
+ if (config$2.debug) {
1091
+ console.log(`[SessionVision] Events sent successfully (${payload.events.length} events)`);
1092
+ }
1093
+ return true;
1094
+ }
1095
+ // Server error, might be worth retrying
1096
+ if (response.status >= 500) {
1097
+ throw new Error(`Server error: ${response.status}`);
1098
+ }
1099
+ // Client error (4xx), don't retry
1100
+ if (config$2.debug) {
1101
+ console.warn(`[SessionVision] Failed to send events: ${response.status}`);
1102
+ }
1103
+ return false;
1104
+ }
1105
+ catch (error) {
1106
+ // Network error or server error, retry if attempts remaining
1107
+ if (attempt < BUFFER_CONFIG.MAX_RETRIES) {
1108
+ const delay = BUFFER_CONFIG.RETRY_DELAYS_MS[attempt] || 4000;
1109
+ if (config$2.debug) {
1110
+ console.log(`[SessionVision] Retry ${attempt + 1}/${BUFFER_CONFIG.MAX_RETRIES} in ${delay}ms`);
1111
+ }
1112
+ await sleep(delay);
1113
+ }
1114
+ else {
1115
+ // All retries exhausted
1116
+ consecutiveFailures++;
1117
+ if (config$2.debug) {
1118
+ console.warn('[SessionVision] Failed to send events after retries:', error);
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ return false;
1124
+ }
1125
+ /**
1126
+ * Check if we should stop retrying (3+ consecutive failures)
1127
+ */
1128
+ function shouldStopRetrying() {
1129
+ return consecutiveFailures >= 3;
1130
+ }
1131
+
1132
+ /**
1133
+ * Event buffer module
1134
+ * Buffers events and flushes them periodically or when buffer is full
1135
+ */
1136
+ let eventBuffer = [];
1137
+ let flushTimer = null;
1138
+ let config$1 = null;
1139
+ let isFlushing = false;
1140
+ /**
1141
+ * Set the configuration
1142
+ */
1143
+ function setBufferConfig(cfg) {
1144
+ config$1 = cfg;
1145
+ }
1146
+ /**
1147
+ * Add an event to the buffer
1148
+ */
1149
+ function addToBuffer(event) {
1150
+ // If we've had too many failures, drop events
1151
+ if (shouldStopRetrying()) {
1152
+ if (config$1?.debug) {
1153
+ console.warn('[SessionVision] Too many failures, dropping event');
1154
+ }
1155
+ return;
1156
+ }
1157
+ eventBuffer.push(event);
1158
+ // Flush if buffer is full
1159
+ if (eventBuffer.length >= BUFFER_CONFIG.MAX_EVENTS) {
1160
+ flush();
1161
+ }
1162
+ }
1163
+ /**
1164
+ * Flush the event buffer
1165
+ * Sends all buffered events to the server
1166
+ */
1167
+ async function flush() {
1168
+ if (isFlushing || eventBuffer.length === 0 || !config$1) {
1169
+ return;
1170
+ }
1171
+ isFlushing = true;
1172
+ // Take events from buffer (FIFO eviction on failure)
1173
+ const eventsToSend = [...eventBuffer];
1174
+ eventBuffer = [];
1175
+ const payload = {
1176
+ projectToken: config$1.projectToken,
1177
+ events: eventsToSend,
1178
+ };
1179
+ const success = await sendEvents(payload);
1180
+ if (!success) {
1181
+ // Re-add events to buffer if we haven't exceeded max retries
1182
+ if (!shouldStopRetrying()) {
1183
+ // Only keep most recent events up to max buffer size
1184
+ const combined = [...eventsToSend, ...eventBuffer];
1185
+ eventBuffer = combined.slice(-10);
1186
+ if (config$1.debug && combined.length > BUFFER_CONFIG.MAX_EVENTS) {
1187
+ console.warn(`[SessionVision] Buffer overflow, dropped ${combined.length - BUFFER_CONFIG.MAX_EVENTS} oldest events`);
1188
+ }
1189
+ }
1190
+ }
1191
+ isFlushing = false;
1192
+ }
1193
+ /**
1194
+ * Start the flush timer
1195
+ */
1196
+ function startFlushTimer() {
1197
+ if (flushTimer) {
1198
+ return;
1199
+ }
1200
+ flushTimer = setInterval(() => {
1201
+ flush();
1202
+ }, BUFFER_CONFIG.FLUSH_INTERVAL_MS);
1203
+ }
1204
+ /**
1205
+ * Initialize visibility change handler for flushing on tab hide
1206
+ */
1207
+ function initVisibilityHandler() {
1208
+ document.addEventListener('visibilitychange', () => {
1209
+ if (document.visibilityState === 'hidden') {
1210
+ // Best-effort flush when tab is hidden
1211
+ flush();
1212
+ }
1213
+ });
1214
+ }
1215
+
944
1216
  /**
945
1217
  * Pageview tracking module
946
1218
  * Handles initial page load and SPA navigation
@@ -964,6 +1236,9 @@
964
1236
  ...customProperties,
965
1237
  };
966
1238
  captureEvent('$pageview', properties);
1239
+ // Flush immediately to ensure pageview is sent quickly
1240
+ // This captures users who bounce before the 5-second interval
1241
+ flush();
967
1242
  }
968
1243
  /**
969
1244
  * Handle history state changes for SPA navigation
@@ -1216,331 +1491,607 @@
1216
1491
  }
1217
1492
 
1218
1493
  /**
1219
- * Autocapture module
1220
- * Handles automatic capture of clicks and form submissions
1221
- */
1222
- let isClickTrackingActive = false;
1223
- let isFormTrackingActive = false;
1224
- let config$2 = null;
1225
- /**
1226
- * Set the configuration
1227
- */
1228
- function setAutocaptureConfig(cfg) {
1229
- config$2 = cfg;
1230
- }
1231
- /**
1232
- * Handle click events
1233
- */
1234
- function handleClick(event) {
1235
- const target = event.target;
1236
- if (!target || shouldIgnoreElement(target)) {
1237
- return;
1494
+ * Rage Click Detection
1495
+ *
1496
+ * Detects rapid repeated clicks indicating user frustration.
1497
+ * Emits a single $rage_click event after the click sequence ends.
1498
+ */
1499
+ /** Elements that legitimately receive rapid clicks */
1500
+ const RAGE_CLICK_EXCLUDED_SELECTORS = [
1501
+ 'input[type="number"]',
1502
+ 'input[type="range"]',
1503
+ '[role="spinbutton"]',
1504
+ '[role="slider"]',
1505
+ '[class*="quantity"]',
1506
+ '[class*="stepper"]',
1507
+ '[class*="increment"]',
1508
+ '[class*="decrement"]',
1509
+ '[class*="plus"]',
1510
+ '[class*="minus"]',
1511
+ 'video',
1512
+ 'audio',
1513
+ '[class*="player"]',
1514
+ '[class*="volume"]',
1515
+ '[class*="seek"]',
1516
+ 'canvas',
1517
+ ];
1518
+ function shouldExcludeFromRageClick(element) {
1519
+ if (element.closest('[data-sessionvision-no-rageclick]')) {
1520
+ return true;
1238
1521
  }
1239
- // Find the most relevant interactive element
1240
- const element = getInteractiveParent(target) || target;
1241
- if (shouldIgnoreElement(element)) {
1242
- return;
1522
+ for (const selector of RAGE_CLICK_EXCLUDED_SELECTORS) {
1523
+ if (element.matches(selector) || element.closest(selector)) {
1524
+ return true;
1525
+ }
1243
1526
  }
1244
- const properties = {
1245
- $element_tag: getElementTag(element),
1246
- $element_text: maskPII(getElementText(element)),
1247
- $element_classes: getElementClasses(element),
1248
- $element_id: getElementId(element),
1249
- $element_selector: generateSelector(element),
1250
- $element_href: getElementHref(element),
1251
- };
1252
- captureEvent('$click', properties);
1527
+ return false;
1253
1528
  }
1254
- /**
1255
- * Handle form submission events
1256
- */
1257
- function handleFormSubmit(event) {
1258
- const form = event.target;
1259
- if (!form || !(form instanceof HTMLFormElement)) {
1260
- return;
1529
+ class RageClickDetector {
1530
+ constructor(_config) {
1531
+ this.clicks = [];
1532
+ this.emitTimeout = null;
1533
+ this.threshold = RageClickDetector.DEFAULT_THRESHOLD;
1534
+ this.windowMs = RageClickDetector.DEFAULT_WINDOW_MS;
1535
+ this.radiusPx = RageClickDetector.DEFAULT_RADIUS_PX;
1536
+ }
1537
+ recordClick(event, element, properties) {
1538
+ const now = Date.now();
1539
+ this.clicks.push({
1540
+ timestamp: now,
1541
+ x: event.clientX,
1542
+ y: event.clientY,
1543
+ elementSelector: properties.$element_selector || '',
1544
+ element,
1545
+ properties,
1546
+ });
1547
+ // Remove expired clicks outside the window
1548
+ this.clicks = this.clicks.filter((c) => now - c.timestamp <= this.windowMs);
1549
+ // Reset the emit timeout - we'll check after the sequence ends
1550
+ this.scheduleEmit();
1551
+ }
1552
+ scheduleEmit() {
1553
+ if (this.emitTimeout) {
1554
+ clearTimeout(this.emitTimeout);
1555
+ }
1556
+ this.emitTimeout = setTimeout(() => {
1557
+ this.maybeEmitRageClick();
1558
+ }, this.windowMs);
1559
+ }
1560
+ maybeEmitRageClick() {
1561
+ if (this.clicks.length < this.threshold) {
1562
+ this.clicks = [];
1563
+ return;
1564
+ }
1565
+ const sameElement = this.clicks.every((c) => c.elementSelector === this.clicks[0].elementSelector);
1566
+ const sameArea = this.isClickCluster(this.clicks);
1567
+ if (sameElement || sameArea) {
1568
+ this.emitRageClick();
1569
+ }
1570
+ this.clicks = [];
1571
+ }
1572
+ isClickCluster(clicks) {
1573
+ for (let i = 0; i < clicks.length; i++) {
1574
+ for (let j = i + 1; j < clicks.length; j++) {
1575
+ const dx = clicks[i].x - clicks[j].x;
1576
+ const dy = clicks[i].y - clicks[j].y;
1577
+ const distance = Math.sqrt(dx * dx + dy * dy);
1578
+ if (distance > this.radiusPx) {
1579
+ return false;
1580
+ }
1581
+ }
1582
+ }
1583
+ return true;
1261
1584
  }
1262
- if (shouldIgnoreElement(form)) {
1263
- return;
1585
+ emitRageClick() {
1586
+ const lastClick = this.clicks[this.clicks.length - 1];
1587
+ const firstClick = this.clicks[0];
1588
+ const duration = lastClick.timestamp - firstClick.timestamp;
1589
+ let elementX = 0;
1590
+ let elementY = 0;
1591
+ try {
1592
+ const rect = lastClick.element.getBoundingClientRect();
1593
+ elementX = lastClick.x - rect.left;
1594
+ elementY = lastClick.y - rect.top;
1595
+ }
1596
+ catch {
1597
+ // Element may have been removed from DOM
1598
+ }
1599
+ const rageClickProperties = {
1600
+ ...lastClick.properties,
1601
+ $click_count: this.clicks.length,
1602
+ $rage_click_duration_ms: duration,
1603
+ $click_x: lastClick.x,
1604
+ $click_y: lastClick.y,
1605
+ $element_x: elementX,
1606
+ $element_y: elementY,
1607
+ $click_positions: this.clicks.map((c) => ({
1608
+ x: c.x,
1609
+ y: c.y,
1610
+ timestamp: c.timestamp,
1611
+ })),
1612
+ };
1613
+ captureEvent('$rage_click', rageClickProperties);
1614
+ }
1615
+ destroy() {
1616
+ if (this.emitTimeout) {
1617
+ clearTimeout(this.emitTimeout);
1618
+ this.emitTimeout = null;
1619
+ }
1620
+ this.clicks = [];
1264
1621
  }
1265
- const properties = {
1266
- $form_id: form.id || null,
1267
- $form_action: form.action || '',
1268
- $form_method: (form.method || 'GET').toUpperCase(),
1269
- $form_name: form.name || null,
1270
- };
1271
- captureEvent('$form_submit', properties);
1272
1622
  }
1623
+ RageClickDetector.DEFAULT_THRESHOLD = 3;
1624
+ RageClickDetector.DEFAULT_WINDOW_MS = 1000;
1625
+ RageClickDetector.DEFAULT_RADIUS_PX = 30;
1626
+
1273
1627
  /**
1274
- * Initialize click tracking
1275
- */
1276
- function initClickTracking() {
1277
- if (isClickTrackingActive) {
1278
- return;
1628
+ * Dead Click Detection
1629
+ *
1630
+ * Detects clicks that produce no visible DOM changes.
1631
+ * Emits $dead_click events when no response is detected.
1632
+ */
1633
+ /** Elements that may not cause visible DOM changes when clicked */
1634
+ const DEAD_CLICK_EXCLUDED_SELECTORS = [
1635
+ 'input[type="text"]',
1636
+ 'input[type="password"]',
1637
+ 'input[type="email"]',
1638
+ 'input[type="search"]',
1639
+ 'input[type="tel"]',
1640
+ 'input[type="url"]',
1641
+ 'textarea',
1642
+ 'select',
1643
+ 'video',
1644
+ 'audio',
1645
+ ];
1646
+ function shouldExcludeFromDeadClick(element) {
1647
+ if (element.closest('[data-sessionvision-no-deadclick]')) {
1648
+ return true;
1279
1649
  }
1280
- isClickTrackingActive = true;
1281
- document.addEventListener('click', handleClick, { capture: true });
1282
- }
1283
- /**
1284
- * Initialize form submission tracking
1285
- */
1286
- function initFormTracking() {
1287
- if (isFormTrackingActive) {
1288
- return;
1650
+ for (const selector of DEAD_CLICK_EXCLUDED_SELECTORS) {
1651
+ if (element.matches(selector) || element.closest(selector)) {
1652
+ return true;
1653
+ }
1289
1654
  }
1290
- isFormTrackingActive = true;
1291
- document.addEventListener('submit', handleFormSubmit, { capture: true });
1655
+ return false;
1292
1656
  }
1293
- /**
1294
- * Initialize all autocapture based on configuration
1295
- */
1296
- function initAutocapture(cfg) {
1297
- config$2 = cfg;
1298
- if (config$2.autocapture.clicks) {
1299
- initClickTracking();
1657
+ class DeadClickDetector {
1658
+ constructor(_config) {
1659
+ this.pendingClick = null;
1660
+ this.timeoutMs = DeadClickDetector.DEFAULT_TIMEOUT_MS;
1661
+ }
1662
+ monitorClick(event, element, properties) {
1663
+ // Cancel any pending detection
1664
+ this.cancelPending();
1665
+ const timestamp = Date.now();
1666
+ let mutationDetected = false;
1667
+ const observer = new MutationObserver((mutations) => {
1668
+ const meaningful = mutations.some((m) => this.isMeaningfulMutation(m, element));
1669
+ if (meaningful) {
1670
+ mutationDetected = true;
1671
+ this.cancelPending();
1672
+ }
1673
+ });
1674
+ observer.observe(document.body, {
1675
+ childList: true,
1676
+ attributes: true,
1677
+ characterData: true,
1678
+ subtree: true,
1679
+ });
1680
+ const timeout = setTimeout(() => {
1681
+ if (!mutationDetected && this.pendingClick) {
1682
+ this.emitDeadClick(this.pendingClick);
1683
+ }
1684
+ this.cancelPending();
1685
+ }, this.timeoutMs);
1686
+ this.pendingClick = {
1687
+ x: event.clientX,
1688
+ y: event.clientY,
1689
+ element,
1690
+ properties,
1691
+ timestamp,
1692
+ observer,
1693
+ timeout,
1694
+ };
1695
+ }
1696
+ cancelPending() {
1697
+ if (this.pendingClick) {
1698
+ this.pendingClick.observer.disconnect();
1699
+ clearTimeout(this.pendingClick.timeout);
1700
+ this.pendingClick = null;
1701
+ }
1300
1702
  }
1301
- if (config$2.autocapture.formSubmit) {
1302
- initFormTracking();
1703
+ isMeaningfulMutation(mutation, clickedElement) {
1704
+ // Ignore class changes on clicked element (often just :active styles)
1705
+ if (mutation.type === 'attributes' &&
1706
+ mutation.attributeName === 'class' &&
1707
+ mutation.target === clickedElement) {
1708
+ return false;
1709
+ }
1710
+ // Ignore data-* attribute changes (often analytics)
1711
+ if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
1712
+ return false;
1713
+ }
1714
+ // Ignore script/style/link/meta injections
1715
+ if (mutation.type === 'childList') {
1716
+ const ignoredNodes = ['SCRIPT', 'STYLE', 'LINK', 'META'];
1717
+ const addedNonIgnored = Array.from(mutation.addedNodes).some((node) => !ignoredNodes.includes(node.nodeName));
1718
+ if (!addedNonIgnored && mutation.removedNodes.length === 0) {
1719
+ return false;
1720
+ }
1721
+ }
1722
+ return true;
1303
1723
  }
1304
- }
1724
+ emitDeadClick(pending) {
1725
+ let elementX = 0;
1726
+ let elementY = 0;
1727
+ try {
1728
+ const rect = pending.element.getBoundingClientRect();
1729
+ elementX = pending.x - rect.left;
1730
+ elementY = pending.y - rect.top;
1731
+ }
1732
+ catch {
1733
+ // Element may have been removed from DOM
1734
+ }
1735
+ const deadClickProperties = {
1736
+ ...pending.properties,
1737
+ $click_x: pending.x,
1738
+ $click_y: pending.y,
1739
+ $element_x: elementX,
1740
+ $element_y: elementY,
1741
+ $wait_duration_ms: Date.now() - pending.timestamp,
1742
+ $element_is_interactive: isInteractiveElement(pending.element),
1743
+ };
1744
+ captureEvent('$dead_click', deadClickProperties);
1745
+ }
1746
+ destroy() {
1747
+ this.cancelPending();
1748
+ }
1749
+ }
1750
+ DeadClickDetector.DEFAULT_TIMEOUT_MS = 1000;
1305
1751
 
1306
1752
  /**
1307
- * Payload compression module
1308
- * Uses CompressionStream API when available
1753
+ * Form Field Tracking
1754
+ *
1755
+ * Tracks form field interactions for abandonment analysis.
1756
+ * Emits $form_start when user begins filling a form,
1757
+ * $form_field_change when fields are modified.
1309
1758
  */
1310
1759
  /**
1311
- * Check if compression is supported
1760
+ * Form field tracker instance
1312
1761
  */
1313
- function isCompressionSupported() {
1314
- return (typeof CompressionStream !== 'undefined' &&
1315
- typeof ReadableStream !== 'undefined');
1762
+ let isActive = false;
1763
+ const startedForms = new Map();
1764
+ /**
1765
+ * Get form fields (inputs, textareas, selects)
1766
+ */
1767
+ function getFormFields(form) {
1768
+ const fields = form.querySelectorAll('input, textarea, select');
1769
+ return Array.from(fields);
1316
1770
  }
1317
1771
  /**
1318
- * Compress a string using gzip
1319
- * Returns the compressed data as a Blob, or null if compression is not supported
1772
+ * Get the index of a field within its form
1320
1773
  */
1321
- async function compressPayload(data) {
1322
- if (!isCompressionSupported()) {
1323
- return null;
1774
+ function getFieldIndex(field, form) {
1775
+ const fields = getFormFields(form);
1776
+ return fields.indexOf(field);
1777
+ }
1778
+ /**
1779
+ * Get the type of a form field
1780
+ */
1781
+ function getFieldType(field) {
1782
+ if (field instanceof HTMLInputElement) {
1783
+ return field.type || 'text';
1324
1784
  }
1325
- try {
1326
- const encoder = new TextEncoder();
1327
- const inputBytes = encoder.encode(data);
1328
- const stream = new ReadableStream({
1329
- start(controller) {
1330
- controller.enqueue(inputBytes);
1331
- controller.close();
1332
- },
1333
- });
1334
- const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
1335
- const reader = compressedStream.getReader();
1336
- const chunks = [];
1337
- while (true) {
1338
- const { done, value } = await reader.read();
1339
- if (done)
1340
- break;
1341
- chunks.push(value);
1342
- }
1343
- // Combine chunks into a single Blob
1344
- return new Blob(chunks, { type: 'application/gzip' });
1785
+ if (field instanceof HTMLTextAreaElement) {
1786
+ return 'textarea';
1345
1787
  }
1346
- catch {
1347
- // Compression failed, return null to use uncompressed
1348
- return null;
1788
+ if (field instanceof HTMLSelectElement) {
1789
+ return 'select';
1349
1790
  }
1791
+ return 'unknown';
1350
1792
  }
1351
1793
  /**
1352
- * Get the size of data in bytes
1794
+ * Check if a field has a value
1353
1795
  */
1354
- function getByteSize(data) {
1355
- return new Blob([data]).size;
1796
+ function fieldHasValue(field) {
1797
+ if (field instanceof HTMLInputElement) {
1798
+ if (field.type === 'checkbox' || field.type === 'radio') {
1799
+ return field.checked;
1800
+ }
1801
+ return field.value.length > 0;
1802
+ }
1803
+ if (field instanceof HTMLTextAreaElement) {
1804
+ return field.value.length > 0;
1805
+ }
1806
+ if (field instanceof HTMLSelectElement) {
1807
+ return field.selectedIndex > 0 || (field.selectedIndex === 0 && field.value !== '');
1808
+ }
1809
+ return false;
1356
1810
  }
1357
1811
  /**
1358
- * Check if payload should be compressed (based on size threshold)
1359
- * Only compress if payload is larger than 1KB
1812
+ * Check if a field is a form field
1360
1813
  */
1361
- function shouldCompress(data) {
1362
- return isCompressionSupported() && getByteSize(data) > 1024;
1814
+ function isFormField(element) {
1815
+ return (element instanceof HTMLInputElement ||
1816
+ element instanceof HTMLTextAreaElement ||
1817
+ element instanceof HTMLSelectElement);
1363
1818
  }
1364
-
1365
1819
  /**
1366
- * HTTP transport module
1367
- * Handles sending events to the ingest API
1368
- */
1369
- let config$1 = null;
1370
- let consecutiveFailures = 0;
1371
- /**
1372
- * Set the configuration
1820
+ * Get the parent form of an element
1373
1821
  */
1374
- function setTransportConfig(cfg) {
1375
- config$1 = cfg;
1822
+ function getParentForm(element) {
1823
+ return element.closest('form');
1376
1824
  }
1377
1825
  /**
1378
- * Sleep for a given number of milliseconds
1826
+ * Handle focus events on form fields
1379
1827
  */
1380
- function sleep(ms) {
1381
- return new Promise((resolve) => setTimeout(resolve, ms));
1828
+ function handleFocusIn(event) {
1829
+ const target = event.target;
1830
+ if (!target || !isFormField(target)) {
1831
+ return;
1832
+ }
1833
+ if (shouldIgnoreElement(target)) {
1834
+ return;
1835
+ }
1836
+ const form = getParentForm(target);
1837
+ if (!form || shouldIgnoreElement(form)) {
1838
+ return;
1839
+ }
1840
+ const formSelector = generateSelector(form);
1841
+ // Check if this form has already been started
1842
+ if (startedForms.has(formSelector)) {
1843
+ return;
1844
+ }
1845
+ // Emit $form_start
1846
+ const fields = getFormFields(form);
1847
+ const properties = {
1848
+ $form_id: form.id || null,
1849
+ $form_name: form.name || null,
1850
+ $form_selector: formSelector,
1851
+ $form_field_count: fields.length,
1852
+ };
1853
+ captureEvent('$form_start', properties);
1854
+ // Track form state
1855
+ startedForms.set(formSelector, {
1856
+ startTime: Date.now(),
1857
+ fieldCount: fields.length,
1858
+ interactedFields: new Set(),
1859
+ filledFields: new Set(),
1860
+ });
1382
1861
  }
1383
1862
  /**
1384
- * Send events to the ingest API
1385
- * Handles compression and retry logic
1863
+ * Handle change events on form fields
1386
1864
  */
1387
- async function sendEvents(payload) {
1388
- if (!config$1) {
1389
- console.warn('[SessionVision] SDK not initialized');
1390
- return false;
1865
+ function handleChange(event) {
1866
+ const target = event.target;
1867
+ if (!target || !isFormField(target)) {
1868
+ return;
1391
1869
  }
1392
- const url = `${config$1.ingestHost}/api/v1/ingest/events`;
1393
- const jsonPayload = JSON.stringify(payload);
1394
- // Try to compress if payload is large enough
1395
- const useCompression = shouldCompress(jsonPayload);
1396
- let body = jsonPayload;
1397
- const headers = {
1398
- 'Content-Type': 'application/json',
1399
- };
1400
- if (useCompression) {
1401
- const compressed = await compressPayload(jsonPayload);
1402
- if (compressed) {
1403
- body = compressed;
1404
- headers['Content-Type'] = 'application/json';
1405
- headers['Content-Encoding'] = 'gzip';
1870
+ if (shouldIgnoreElement(target)) {
1871
+ return;
1872
+ }
1873
+ const form = getParentForm(target);
1874
+ if (!form || shouldIgnoreElement(form)) {
1875
+ return;
1876
+ }
1877
+ const formSelector = generateSelector(form);
1878
+ const fieldSelector = generateSelector(target);
1879
+ const formState = startedForms.get(formSelector);
1880
+ // Track interaction even if form wasn't started (edge case)
1881
+ if (!formState) {
1882
+ // Start the form now
1883
+ const fields = getFormFields(form);
1884
+ const startProps = {
1885
+ $form_id: form.id || null,
1886
+ $form_name: form.name || null,
1887
+ $form_selector: formSelector,
1888
+ $form_field_count: fields.length,
1889
+ };
1890
+ captureEvent('$form_start', startProps);
1891
+ startedForms.set(formSelector, {
1892
+ startTime: Date.now(),
1893
+ fieldCount: fields.length,
1894
+ interactedFields: new Set([fieldSelector]),
1895
+ filledFields: new Set(fieldHasValue(target) ? [fieldSelector] : []),
1896
+ });
1897
+ }
1898
+ else {
1899
+ // Update tracking
1900
+ formState.interactedFields.add(fieldSelector);
1901
+ if (fieldHasValue(target)) {
1902
+ formState.filledFields.add(fieldSelector);
1903
+ }
1904
+ else {
1905
+ formState.filledFields.delete(fieldSelector);
1406
1906
  }
1407
1907
  }
1408
- // Attempt to send with retries
1409
- for (let attempt = 0; attempt <= BUFFER_CONFIG.MAX_RETRIES; attempt++) {
1410
- try {
1411
- const response = await fetch(url, {
1412
- method: 'POST',
1413
- headers,
1414
- body,
1415
- keepalive: true, // Keep connection alive for background sends
1416
- });
1417
- if (response.ok || response.status === 202) {
1418
- // Success
1419
- consecutiveFailures = 0;
1420
- if (config$1.debug) {
1421
- console.log(`[SessionVision] Events sent successfully (${payload.events.length} events)`);
1422
- }
1423
- return true;
1424
- }
1425
- // Server error, might be worth retrying
1426
- if (response.status >= 500) {
1427
- throw new Error(`Server error: ${response.status}`);
1428
- }
1429
- // Client error (4xx), don't retry
1430
- if (config$1.debug) {
1431
- console.warn(`[SessionVision] Failed to send events: ${response.status}`);
1908
+ // Emit $form_field_change
1909
+ const properties = {
1910
+ $form_selector: formSelector,
1911
+ $field_selector: fieldSelector,
1912
+ $field_name: target.name || null,
1913
+ $field_type: getFieldType(target),
1914
+ $field_index: getFieldIndex(target, form),
1915
+ $has_value: fieldHasValue(target),
1916
+ };
1917
+ captureEvent('$form_field_change', properties);
1918
+ }
1919
+ /**
1920
+ * Reset tracking for a specific form (called after submit)
1921
+ */
1922
+ function resetForm(formSelector) {
1923
+ startedForms.delete(formSelector);
1924
+ }
1925
+ /**
1926
+ * Get form tracking data for enhanced submit event
1927
+ */
1928
+ function getFormTrackingData(form) {
1929
+ const formSelector = generateSelector(form);
1930
+ const fields = getFormFields(form);
1931
+ const formState = startedForms.get(formSelector);
1932
+ if (!formState) {
1933
+ // Form submitted without tracking (e.g., direct submit without field focus)
1934
+ const filledFields = [];
1935
+ for (const field of fields) {
1936
+ if (fieldHasValue(field) && !shouldIgnoreElement(field)) {
1937
+ filledFields.push(generateSelector(field));
1432
1938
  }
1433
- return false;
1434
1939
  }
1435
- catch (error) {
1436
- // Network error or server error, retry if attempts remaining
1437
- if (attempt < BUFFER_CONFIG.MAX_RETRIES) {
1438
- const delay = BUFFER_CONFIG.RETRY_DELAYS_MS[attempt] || 4000;
1439
- if (config$1.debug) {
1440
- console.log(`[SessionVision] Retry ${attempt + 1}/${BUFFER_CONFIG.MAX_RETRIES} in ${delay}ms`);
1441
- }
1442
- await sleep(delay);
1443
- }
1444
- else {
1445
- // All retries exhausted
1446
- consecutiveFailures++;
1447
- if (config$1.debug) {
1448
- console.warn('[SessionVision] Failed to send events after retries:', error);
1449
- }
1450
- }
1940
+ return {
1941
+ formSelector,
1942
+ formFieldCount: fields.length,
1943
+ formFieldsFilled: filledFields,
1944
+ formFieldsInteracted: [],
1945
+ formCompletionRate: fields.length > 0 ? filledFields.length / fields.length : 0,
1946
+ formTimeToSubmitMs: null,
1947
+ };
1948
+ }
1949
+ // Update filled fields snapshot at submit time
1950
+ const currentFilledFields = [];
1951
+ for (const field of fields) {
1952
+ if (fieldHasValue(field) && !shouldIgnoreElement(field)) {
1953
+ currentFilledFields.push(generateSelector(field));
1451
1954
  }
1452
1955
  }
1453
- return false;
1956
+ const completionRate = fields.length > 0 ? currentFilledFields.length / fields.length : 0;
1957
+ return {
1958
+ formSelector,
1959
+ formFieldCount: formState.fieldCount,
1960
+ formFieldsFilled: currentFilledFields,
1961
+ formFieldsInteracted: Array.from(formState.interactedFields),
1962
+ formCompletionRate: Math.round(completionRate * 100) / 100,
1963
+ formTimeToSubmitMs: Date.now() - formState.startTime,
1964
+ };
1454
1965
  }
1455
1966
  /**
1456
- * Check if we should stop retrying (3+ consecutive failures)
1967
+ * Initialize form field tracking
1457
1968
  */
1458
- function shouldStopRetrying() {
1459
- return consecutiveFailures >= 3;
1969
+ function initFormFieldTracking(_cfg) {
1970
+ if (isActive) {
1971
+ return;
1972
+ }
1973
+ isActive = true;
1974
+ document.addEventListener('focusin', handleFocusIn, { capture: true });
1975
+ document.addEventListener('change', handleChange, { capture: true });
1460
1976
  }
1461
1977
 
1462
1978
  /**
1463
- * Event buffer module
1464
- * Buffers events and flushes them periodically or when buffer is full
1979
+ * Autocapture module
1980
+ * Handles automatic capture of clicks, form submissions,
1981
+ * rage clicks, and dead clicks
1465
1982
  */
1466
- let eventBuffer = [];
1467
- let flushTimer = null;
1983
+ let isClickTrackingActive = false;
1984
+ let isFormTrackingActive = false;
1468
1985
  let config = null;
1469
- let isFlushing = false;
1986
+ let rageClickDetector = null;
1987
+ let deadClickDetector = null;
1470
1988
  /**
1471
1989
  * Set the configuration
1472
1990
  */
1473
- function setBufferConfig(cfg) {
1991
+ function setAutocaptureConfig(cfg) {
1474
1992
  config = cfg;
1475
1993
  }
1476
1994
  /**
1477
- * Add an event to the buffer
1995
+ * Handle click events
1478
1996
  */
1479
- function addToBuffer(event) {
1480
- // If we've had too many failures, drop events
1481
- if (shouldStopRetrying()) {
1482
- if (config?.debug) {
1483
- console.warn('[SessionVision] Too many failures, dropping event');
1484
- }
1997
+ function handleClick(event) {
1998
+ const target = event.target;
1999
+ if (!target || shouldIgnoreElement(target)) {
1485
2000
  return;
1486
2001
  }
1487
- eventBuffer.push(event);
1488
- // Flush if buffer is full
1489
- if (eventBuffer.length >= BUFFER_CONFIG.MAX_EVENTS) {
1490
- flush();
2002
+ // Find the most relevant interactive element
2003
+ const element = getInteractiveParent(target) || target;
2004
+ if (shouldIgnoreElement(element)) {
2005
+ return;
2006
+ }
2007
+ const properties = {
2008
+ $element_tag: getElementTag(element),
2009
+ $element_text: maskPII(getElementText(element)),
2010
+ $element_classes: getElementClasses(element),
2011
+ $element_id: getElementId(element),
2012
+ $element_selector: generateSelector(element),
2013
+ $element_href: getElementHref(element),
2014
+ };
2015
+ captureEvent('$click', properties);
2016
+ // Feed click to frustration detectors
2017
+ if (rageClickDetector && !shouldExcludeFromRageClick(element)) {
2018
+ rageClickDetector.recordClick(event, element, properties);
2019
+ }
2020
+ if (deadClickDetector && !shouldExcludeFromDeadClick(element)) {
2021
+ deadClickDetector.monitorClick(event, element, properties);
1491
2022
  }
1492
2023
  }
1493
2024
  /**
1494
- * Flush the event buffer
1495
- * Sends all buffered events to the server
2025
+ * Handle form submission events
1496
2026
  */
1497
- async function flush() {
1498
- if (isFlushing || eventBuffer.length === 0 || !config) {
2027
+ function handleFormSubmit(event) {
2028
+ const form = event.target;
2029
+ if (!form || !(form instanceof HTMLFormElement)) {
1499
2030
  return;
1500
2031
  }
1501
- isFlushing = true;
1502
- // Take events from buffer (FIFO eviction on failure)
1503
- const eventsToSend = [...eventBuffer];
1504
- eventBuffer = [];
1505
- const payload = {
1506
- projectToken: config.projectToken,
1507
- events: eventsToSend,
2032
+ if (shouldIgnoreElement(form)) {
2033
+ return;
2034
+ }
2035
+ // Get form field tracking data
2036
+ const trackingData = getFormTrackingData(form);
2037
+ const properties = {
2038
+ $form_id: form.id || null,
2039
+ $form_action: form.action || '',
2040
+ $form_method: (form.method || 'GET').toUpperCase(),
2041
+ $form_name: form.name || null,
2042
+ $form_selector: trackingData.formSelector,
2043
+ $form_field_count: trackingData.formFieldCount,
2044
+ $form_fields_filled: trackingData.formFieldsFilled,
2045
+ $form_fields_interacted: trackingData.formFieldsInteracted,
2046
+ $form_completion_rate: trackingData.formCompletionRate,
2047
+ $form_time_to_submit_ms: trackingData.formTimeToSubmitMs,
1508
2048
  };
1509
- const success = await sendEvents(payload);
1510
- if (!success) {
1511
- // Re-add events to buffer if we haven't exceeded max retries
1512
- if (!shouldStopRetrying()) {
1513
- // Only keep most recent events up to max buffer size
1514
- const combined = [...eventsToSend, ...eventBuffer];
1515
- eventBuffer = combined.slice(-10);
1516
- if (config.debug && combined.length > BUFFER_CONFIG.MAX_EVENTS) {
1517
- console.warn(`[SessionVision] Buffer overflow, dropped ${combined.length - BUFFER_CONFIG.MAX_EVENTS} oldest events`);
1518
- }
1519
- }
2049
+ captureEvent('$form_submit', properties);
2050
+ // Reset form tracking state after submit
2051
+ resetForm(trackingData.formSelector);
2052
+ }
2053
+ /**
2054
+ * Initialize click tracking
2055
+ */
2056
+ function initClickTracking() {
2057
+ if (isClickTrackingActive) {
2058
+ return;
1520
2059
  }
1521
- isFlushing = false;
2060
+ isClickTrackingActive = true;
2061
+ document.addEventListener('click', handleClick, { capture: true });
1522
2062
  }
1523
2063
  /**
1524
- * Start the flush timer
2064
+ * Initialize form submission tracking
1525
2065
  */
1526
- function startFlushTimer() {
1527
- if (flushTimer) {
2066
+ function initFormTracking() {
2067
+ if (isFormTrackingActive) {
1528
2068
  return;
1529
2069
  }
1530
- flushTimer = setInterval(() => {
1531
- flush();
1532
- }, BUFFER_CONFIG.FLUSH_INTERVAL_MS);
2070
+ isFormTrackingActive = true;
2071
+ document.addEventListener('submit', handleFormSubmit, { capture: true });
1533
2072
  }
1534
2073
  /**
1535
- * Initialize visibility change handler for flushing on tab hide
2074
+ * Initialize all autocapture based on configuration
1536
2075
  */
1537
- function initVisibilityHandler() {
1538
- document.addEventListener('visibilitychange', () => {
1539
- if (document.visibilityState === 'hidden') {
1540
- // Best-effort flush when tab is hidden
1541
- flush();
1542
- }
1543
- });
2076
+ function initAutocapture(cfg) {
2077
+ config = cfg;
2078
+ if (config.autocapture.click) {
2079
+ initClickTracking();
2080
+ }
2081
+ if (config.autocapture.formSubmit) {
2082
+ initFormTracking();
2083
+ }
2084
+ // Initialize form field tracking for abandonment analysis
2085
+ if (config.autocapture.formAbandonment) {
2086
+ initFormFieldTracking();
2087
+ }
2088
+ // Initialize frustration detectors
2089
+ if (config.autocapture.rageClick) {
2090
+ rageClickDetector = new RageClickDetector(config);
2091
+ }
2092
+ if (config.autocapture.deadClick) {
2093
+ deadClickDetector = new DeadClickDetector(config);
2094
+ }
1544
2095
  }
1545
2096
 
1546
2097
  /**
@@ -1594,10 +2145,24 @@
1594
2145
  isInitialized = true;
1595
2146
  return;
1596
2147
  }
1597
- // Fetch remote config (async, don't block initialization)
1598
- fetchRemoteConfig(resolvedConfig).then((remoteConfig) => {
2148
+ // FAST PATH: Check cached config synchronously to start recorder loading early.
2149
+ // This avoids waiting for the network fetch on return visits.
2150
+ let recorderAlreadyLoading = false;
2151
+ const cachedConfig = getCachedRemoteConfig(resolvedConfig.projectToken);
2152
+ if (cachedConfig?.recording?.enabled) {
2153
+ recorderAlreadyLoading = true;
2154
+ maybeLoadRecorder(cachedConfig, getSessionId());
2155
+ }
2156
+ // Always fetch fresh config to keep cache warm for next page load.
2157
+ // Only prefetch recorder if: no cache yet AND recording isn't locally disabled.
2158
+ const shouldPrefetch = !cachedConfig && resolvedConfig.recording !== false;
2159
+ fetchRemoteConfig(resolvedConfig, { prefetchRecorder: shouldPrefetch }).then((remoteConfig) => {
1599
2160
  if (remoteConfig) {
1600
2161
  applyRemoteConfig(remoteConfig);
2162
+ // Only trigger recorder loading if the fast path didn't already handle it.
2163
+ if (!recorderAlreadyLoading) {
2164
+ maybeLoadRecorder(remoteConfig, getSessionId());
2165
+ }
1601
2166
  }
1602
2167
  });
1603
2168
  // Start event buffer flush timer
@@ -1674,6 +2239,208 @@
1674
2239
  }
1675
2240
  registerOnceProperties(properties);
1676
2241
  }
2242
+ /**
2243
+ * Manually flush the event buffer
2244
+ */
2245
+ function flushEvents() {
2246
+ return flush();
2247
+ }
2248
+ // Cached remote config for startRecording() to check
2249
+ let lastRemoteConfig = null;
2250
+ // Track recording state locally so isRecording() can be synchronous
2251
+ let recordingActive = false;
2252
+ // Lightweight event emitter (lives in main SDK so listeners can register before recorder loads)
2253
+ const eventListeners = new Map();
2254
+ // Cache the SDK script base URL at load time (document.currentScript is only available synchronously)
2255
+ let cachedScriptBaseUrl = null;
2256
+ if (typeof document !== 'undefined' && document.currentScript) {
2257
+ const src = document.currentScript.src;
2258
+ if (src) {
2259
+ cachedScriptBaseUrl = src.substring(0, src.lastIndexOf('/') + 1);
2260
+ }
2261
+ }
2262
+ /**
2263
+ * Get the recorder module from the global (set by the recorder bundle on load)
2264
+ */
2265
+ function getRecorderModule() {
2266
+ if (typeof window === 'undefined')
2267
+ return null;
2268
+ return (window
2269
+ .__sessionvision_recorder ?? null);
2270
+ }
2271
+ /**
2272
+ * Load the recorder bundle via script injection
2273
+ */
2274
+ function loadRecorderBundle() {
2275
+ return new Promise((resolve, reject) => {
2276
+ // Already loaded?
2277
+ const existing = getRecorderModule();
2278
+ if (existing) {
2279
+ resolve(existing);
2280
+ return;
2281
+ }
2282
+ if (typeof document === 'undefined') {
2283
+ reject(new Error('No document available'));
2284
+ return;
2285
+ }
2286
+ // Resolve recorder URL from SDK script base or CDN
2287
+ const baseUrl = cachedScriptBaseUrl || `${resolvedConfig?.apiHost}/v${resolvedConfig?.version}/`;
2288
+ const url = `${baseUrl}sessionvision-recorder.min.js`;
2289
+ const script = document.createElement('script');
2290
+ script.src = url;
2291
+ script.async = true;
2292
+ script.onload = () => {
2293
+ const mod = getRecorderModule();
2294
+ if (mod) {
2295
+ // Register pending event listeners with the newly loaded recorder
2296
+ for (const [event, callbacks] of eventListeners) {
2297
+ for (const cb of callbacks) {
2298
+ mod.on(event, cb);
2299
+ }
2300
+ }
2301
+ resolve(mod);
2302
+ }
2303
+ else {
2304
+ reject(new Error('Recorder bundle loaded but module not registered'));
2305
+ }
2306
+ };
2307
+ script.onerror = () => {
2308
+ reject(new Error(`Failed to load recorder bundle from ${url}`));
2309
+ };
2310
+ document.head.appendChild(script);
2311
+ });
2312
+ }
2313
+ /**
2314
+ * FNV-1a hash for deterministic sampling
2315
+ */
2316
+ function hashSessionId(sessionId) {
2317
+ let hash = 0x811c9dc5;
2318
+ for (let i = 0; i < sessionId.length; i++) {
2319
+ hash ^= sessionId.charCodeAt(i);
2320
+ hash = (hash * 0x01000193) >>> 0;
2321
+ }
2322
+ return hash >>> 0;
2323
+ }
2324
+ /**
2325
+ * Deterministic sampling based on session ID
2326
+ */
2327
+ function shouldRecordSession(sampleRate, sessionId) {
2328
+ if (sampleRate >= 1)
2329
+ return true;
2330
+ if (sampleRate <= 0)
2331
+ return false;
2332
+ const hash = hashSessionId(sessionId);
2333
+ return hash % 100 < sampleRate * 100;
2334
+ }
2335
+ /**
2336
+ * Load the recorder bundle if conditions are met
2337
+ */
2338
+ async function maybeLoadRecorder(config, sessionId) {
2339
+ // Gate 1: Remote config must have recording enabled
2340
+ if (!config.recording?.enabled) {
2341
+ return;
2342
+ }
2343
+ // Gate 2: Local config can force-disable recording
2344
+ if (resolvedConfig?.recording === false) {
2345
+ return;
2346
+ }
2347
+ // Gate 3: Deterministic sampling based on session ID
2348
+ if (!shouldRecordSession(config.recording.sampleRate, sessionId)) {
2349
+ if (resolvedConfig?.debug) {
2350
+ console.log('[SessionVision] Session not sampled for recording');
2351
+ }
2352
+ return;
2353
+ }
2354
+ // Store remote config for later use by public API
2355
+ lastRemoteConfig = config;
2356
+ // Only now do we fetch the recorder bundle (separate HTTP request)
2357
+ try {
2358
+ const recorder = await loadRecorderBundle();
2359
+ recorder.initRecorder(resolvedConfig, config);
2360
+ recordingActive = true;
2361
+ }
2362
+ catch (error) {
2363
+ if (resolvedConfig?.debug) {
2364
+ console.warn('[SessionVision] Failed to load recorder:', error);
2365
+ }
2366
+ // Recorder failure must never affect core SDK functionality
2367
+ }
2368
+ }
2369
+ /**
2370
+ * Manually start recording. Respects remote config — no-op if recording.enabled is false.
2371
+ * Bypasses sampling when recording is enabled.
2372
+ */
2373
+ async function startRecording() {
2374
+ if (!isInitialized || !resolvedConfig)
2375
+ return;
2376
+ if (resolvedConfig.recording === false)
2377
+ return;
2378
+ // Check remote config
2379
+ const remoteConfig = lastRemoteConfig || getCachedRemoteConfig(resolvedConfig.projectToken);
2380
+ if (!remoteConfig?.recording?.enabled)
2381
+ return;
2382
+ try {
2383
+ const recorder = getRecorderModule() || (await loadRecorderBundle());
2384
+ if (!recorder.isRecorderActive()) {
2385
+ recorder.initRecorder(resolvedConfig, remoteConfig);
2386
+ recordingActive = true;
2387
+ }
2388
+ }
2389
+ catch (error) {
2390
+ if (resolvedConfig.debug) {
2391
+ console.warn('[SessionVision] Failed to start recording:', error);
2392
+ }
2393
+ }
2394
+ }
2395
+ /**
2396
+ * Stop recording for this session
2397
+ */
2398
+ function stopRecording() {
2399
+ const recorder = getRecorderModule();
2400
+ if (recorder) {
2401
+ recorder.stopRecorder();
2402
+ }
2403
+ recordingActive = false;
2404
+ }
2405
+ /**
2406
+ * Check if recording is active
2407
+ */
2408
+ function isRecording() {
2409
+ return recordingActive;
2410
+ }
2411
+ /**
2412
+ * Tag the current recording with custom metadata
2413
+ */
2414
+ function tagRecordingFn(tags) {
2415
+ const recorder = getRecorderModule();
2416
+ if (recorder) {
2417
+ recorder.tagRecording(tags);
2418
+ }
2419
+ }
2420
+ /**
2421
+ * Subscribe to SDK events
2422
+ */
2423
+ function onEvent(event, callback) {
2424
+ if (!eventListeners.has(event)) {
2425
+ eventListeners.set(event, new Set());
2426
+ }
2427
+ eventListeners.get(event).add(callback);
2428
+ // Also register with recorder if already loaded
2429
+ const recorder = getRecorderModule();
2430
+ if (recorder) {
2431
+ recorder.on(event, callback);
2432
+ }
2433
+ }
2434
+ /**
2435
+ * Unsubscribe from SDK events
2436
+ */
2437
+ function offEvent(event, callback) {
2438
+ eventListeners.get(event)?.delete(callback);
2439
+ const recorder = getRecorderModule();
2440
+ if (recorder) {
2441
+ recorder.off(event, callback);
2442
+ }
2443
+ }
1677
2444
 
1678
2445
  /**
1679
2446
  * Queue replay module
@@ -1745,7 +2512,7 @@
1745
2512
  * Session Vision JavaScript Snippet
1746
2513
  * Main SDK entry point
1747
2514
  *
1748
- * @version "0.2.0"
2515
+ * @version "0.4.0"
1749
2516
  */
1750
2517
  /**
1751
2518
  * Session Vision SDK instance
@@ -1754,7 +2521,7 @@
1754
2521
  /**
1755
2522
  * SDK version
1756
2523
  */
1757
- version: "0.2.0" ,
2524
+ version: "0.4.0" ,
1758
2525
  /**
1759
2526
  * Initialize the SDK with a project token and optional configuration
1760
2527
  *
@@ -1869,6 +2636,61 @@
1869
2636
  registerOnce(properties) {
1870
2637
  registerOnce(properties);
1871
2638
  },
2639
+ /**
2640
+ * Manually flush the event buffer
2641
+ * Useful before navigation or when you need to ensure events are sent immediately
2642
+ *
2643
+ * @returns Promise that resolves when the flush is complete
2644
+ *
2645
+ * @example
2646
+ * ```js
2647
+ * // Before navigating away
2648
+ * await sessionvision.flushEvents();
2649
+ * window.location.href = '/new-page';
2650
+ * ```
2651
+ */
2652
+ flushEvents() {
2653
+ return flushEvents();
2654
+ },
2655
+ /**
2656
+ * Subscribe to SDK events (e.g., 'recording:error', 'recording:quota_exceeded')
2657
+ */
2658
+ on(event, callback) {
2659
+ onEvent(event, callback);
2660
+ },
2661
+ /**
2662
+ * Unsubscribe from SDK events
2663
+ */
2664
+ off(event, callback) {
2665
+ offEvent(event, callback);
2666
+ },
2667
+ /**
2668
+ * Manually start recording
2669
+ * Respects remote config — no-op if recording.enabled is false
2670
+ * Bypasses sampling when recording is enabled
2671
+ */
2672
+ startRecording() {
2673
+ startRecording();
2674
+ },
2675
+ /**
2676
+ * Stop recording for this session
2677
+ */
2678
+ stopRecording() {
2679
+ stopRecording();
2680
+ },
2681
+ /**
2682
+ * Check if recording is active
2683
+ */
2684
+ isRecording() {
2685
+ return isRecording();
2686
+ },
2687
+ /**
2688
+ * Tag the current recording with custom metadata
2689
+ * Tags are accumulated in memory and sent with the next chunk upload
2690
+ */
2691
+ tagRecording(tags) {
2692
+ tagRecordingFn(tags);
2693
+ },
1872
2694
  };
1873
2695
  /**
1874
2696
  * Bootstrap the SDK