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