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