@sessionvision/core 0.2.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Session Vision Core v0.2.0
2
+ * Session Vision Core v0.3.0
3
3
  * (c) 2026 Session Vision
4
4
  * Released under the MIT License
5
5
  */
@@ -31,8 +31,11 @@ const DEFAULT_CONFIG = {
31
31
  maskAllInputs: true,
32
32
  autocapture: {
33
33
  pageview: true,
34
- clicks: true,
34
+ click: true,
35
35
  formSubmit: true,
36
+ rageClick: true,
37
+ deadClick: true,
38
+ formAbandonment: true,
36
39
  },
37
40
  };
38
41
  /**
@@ -352,8 +355,11 @@ function resolveConfig(projectToken, userConfig) {
352
355
  // Boolean: enable or disable all
353
356
  autocapture = {
354
357
  pageview: userConfig.autocapture,
355
- clicks: userConfig.autocapture,
358
+ click: userConfig.autocapture,
356
359
  formSubmit: userConfig.autocapture,
360
+ rageClick: userConfig.autocapture,
361
+ deadClick: userConfig.autocapture,
362
+ formAbandonment: userConfig.autocapture,
357
363
  };
358
364
  }
359
365
  else if (typeof userConfig.autocapture === 'object') {
@@ -361,8 +367,11 @@ function resolveConfig(projectToken, userConfig) {
361
367
  const userAutocapture = userConfig.autocapture;
362
368
  autocapture = {
363
369
  pageview: userAutocapture.pageview ?? DEFAULT_CONFIG.autocapture.pageview,
364
- clicks: userAutocapture.clicks ?? DEFAULT_CONFIG.autocapture.clicks,
370
+ click: userAutocapture.click ?? DEFAULT_CONFIG.autocapture.click,
365
371
  formSubmit: userAutocapture.formSubmit ?? DEFAULT_CONFIG.autocapture.formSubmit,
372
+ rageClick: userAutocapture.rageClick ?? DEFAULT_CONFIG.autocapture.rageClick,
373
+ deadClick: userAutocapture.deadClick ?? DEFAULT_CONFIG.autocapture.deadClick,
374
+ formAbandonment: userAutocapture.formAbandonment ?? DEFAULT_CONFIG.autocapture.formAbandonment,
366
375
  };
367
376
  }
368
377
  }
@@ -834,7 +843,7 @@ function getAutomaticProperties() {
834
843
  $timezone: getTimezone(),
835
844
  $locale: getLocale(),
836
845
  $connection_type: getConnectionType(),
837
- $lib_version: "0.2.0"
846
+ $lib_version: "0.3.0"
838
847
  ,
839
848
  };
840
849
  }
@@ -937,6 +946,246 @@ function captureSystemEvent(eventName, properties = {}) {
937
946
  captureEvent(eventName, properties, { includeAutoProperties: true });
938
947
  }
939
948
 
949
+ /**
950
+ * Payload compression module
951
+ * Uses CompressionStream API when available
952
+ */
953
+ /**
954
+ * Check if compression is supported
955
+ */
956
+ function isCompressionSupported() {
957
+ return (typeof CompressionStream !== 'undefined' &&
958
+ typeof ReadableStream !== 'undefined');
959
+ }
960
+ /**
961
+ * Compress a string using gzip
962
+ * Returns the compressed data as a Blob, or null if compression is not supported
963
+ */
964
+ async function compressPayload(data) {
965
+ if (!isCompressionSupported()) {
966
+ return null;
967
+ }
968
+ try {
969
+ const encoder = new TextEncoder();
970
+ const inputBytes = encoder.encode(data);
971
+ const stream = new ReadableStream({
972
+ start(controller) {
973
+ controller.enqueue(inputBytes);
974
+ controller.close();
975
+ },
976
+ });
977
+ const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
978
+ const reader = compressedStream.getReader();
979
+ const chunks = [];
980
+ while (true) {
981
+ const { done, value } = await reader.read();
982
+ if (done)
983
+ break;
984
+ chunks.push(value);
985
+ }
986
+ // Combine chunks into a single Blob
987
+ return new Blob(chunks, { type: 'application/gzip' });
988
+ }
989
+ catch {
990
+ // Compression failed, return null to use uncompressed
991
+ return null;
992
+ }
993
+ }
994
+ /**
995
+ * Get the size of data in bytes
996
+ */
997
+ function getByteSize(data) {
998
+ return new Blob([data]).size;
999
+ }
1000
+ /**
1001
+ * Check if payload should be compressed (based on size threshold)
1002
+ * Only compress if payload is larger than 1KB
1003
+ */
1004
+ function shouldCompress(data) {
1005
+ return isCompressionSupported() && getByteSize(data) > 1024;
1006
+ }
1007
+
1008
+ /**
1009
+ * HTTP transport module
1010
+ * Handles sending events to the ingest API
1011
+ */
1012
+ let config$2 = null;
1013
+ let consecutiveFailures = 0;
1014
+ /**
1015
+ * Set the configuration
1016
+ */
1017
+ function setTransportConfig(cfg) {
1018
+ config$2 = cfg;
1019
+ }
1020
+ /**
1021
+ * Sleep for a given number of milliseconds
1022
+ */
1023
+ function sleep(ms) {
1024
+ return new Promise((resolve) => setTimeout(resolve, ms));
1025
+ }
1026
+ /**
1027
+ * Send events to the ingest API
1028
+ * Handles compression and retry logic
1029
+ */
1030
+ async function sendEvents(payload) {
1031
+ if (!config$2) {
1032
+ console.warn('[SessionVision] SDK not initialized');
1033
+ return false;
1034
+ }
1035
+ const url = `${config$2.ingestHost}/api/v1/ingest/events`;
1036
+ const jsonPayload = JSON.stringify(payload);
1037
+ // Try to compress if payload is large enough
1038
+ const useCompression = shouldCompress(jsonPayload);
1039
+ let body = jsonPayload;
1040
+ const headers = {
1041
+ 'Content-Type': 'application/json',
1042
+ };
1043
+ if (useCompression) {
1044
+ const compressed = await compressPayload(jsonPayload);
1045
+ if (compressed) {
1046
+ body = compressed;
1047
+ headers['Content-Type'] = 'application/json';
1048
+ headers['Content-Encoding'] = 'gzip';
1049
+ }
1050
+ }
1051
+ // Attempt to send with retries
1052
+ for (let attempt = 0; attempt <= BUFFER_CONFIG.MAX_RETRIES; attempt++) {
1053
+ try {
1054
+ const response = await fetch(url, {
1055
+ method: 'POST',
1056
+ headers,
1057
+ body,
1058
+ keepalive: true, // Keep connection alive for background sends
1059
+ });
1060
+ if (response.ok || response.status === 202) {
1061
+ // Success
1062
+ consecutiveFailures = 0;
1063
+ if (config$2.debug) {
1064
+ console.log(`[SessionVision] Events sent successfully (${payload.events.length} events)`);
1065
+ }
1066
+ return true;
1067
+ }
1068
+ // Server error, might be worth retrying
1069
+ if (response.status >= 500) {
1070
+ throw new Error(`Server error: ${response.status}`);
1071
+ }
1072
+ // Client error (4xx), don't retry
1073
+ if (config$2.debug) {
1074
+ console.warn(`[SessionVision] Failed to send events: ${response.status}`);
1075
+ }
1076
+ return false;
1077
+ }
1078
+ catch (error) {
1079
+ // Network error or server error, retry if attempts remaining
1080
+ if (attempt < BUFFER_CONFIG.MAX_RETRIES) {
1081
+ const delay = BUFFER_CONFIG.RETRY_DELAYS_MS[attempt] || 4000;
1082
+ if (config$2.debug) {
1083
+ console.log(`[SessionVision] Retry ${attempt + 1}/${BUFFER_CONFIG.MAX_RETRIES} in ${delay}ms`);
1084
+ }
1085
+ await sleep(delay);
1086
+ }
1087
+ else {
1088
+ // All retries exhausted
1089
+ consecutiveFailures++;
1090
+ if (config$2.debug) {
1091
+ console.warn('[SessionVision] Failed to send events after retries:', error);
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+ return false;
1097
+ }
1098
+ /**
1099
+ * Check if we should stop retrying (3+ consecutive failures)
1100
+ */
1101
+ function shouldStopRetrying() {
1102
+ return consecutiveFailures >= 3;
1103
+ }
1104
+
1105
+ /**
1106
+ * Event buffer module
1107
+ * Buffers events and flushes them periodically or when buffer is full
1108
+ */
1109
+ let eventBuffer = [];
1110
+ let flushTimer = null;
1111
+ let config$1 = null;
1112
+ let isFlushing = false;
1113
+ /**
1114
+ * Set the configuration
1115
+ */
1116
+ function setBufferConfig(cfg) {
1117
+ config$1 = cfg;
1118
+ }
1119
+ /**
1120
+ * Add an event to the buffer
1121
+ */
1122
+ function addToBuffer(event) {
1123
+ // If we've had too many failures, drop events
1124
+ if (shouldStopRetrying()) {
1125
+ if (config$1?.debug) {
1126
+ console.warn('[SessionVision] Too many failures, dropping event');
1127
+ }
1128
+ return;
1129
+ }
1130
+ eventBuffer.push(event);
1131
+ // Flush if buffer is full
1132
+ if (eventBuffer.length >= BUFFER_CONFIG.MAX_EVENTS) {
1133
+ flush();
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Flush the event buffer
1138
+ * Sends all buffered events to the server
1139
+ */
1140
+ async function flush() {
1141
+ if (isFlushing || eventBuffer.length === 0 || !config$1) {
1142
+ return;
1143
+ }
1144
+ isFlushing = true;
1145
+ // Take events from buffer (FIFO eviction on failure)
1146
+ const eventsToSend = [...eventBuffer];
1147
+ eventBuffer = [];
1148
+ const payload = {
1149
+ projectToken: config$1.projectToken,
1150
+ events: eventsToSend,
1151
+ };
1152
+ const success = await sendEvents(payload);
1153
+ if (!success) {
1154
+ // Re-add events to buffer if we haven't exceeded max retries
1155
+ if (!shouldStopRetrying()) {
1156
+ // Only keep most recent events up to max buffer size
1157
+ const combined = [...eventsToSend, ...eventBuffer];
1158
+ eventBuffer = combined.slice(-10);
1159
+ if (config$1.debug && combined.length > BUFFER_CONFIG.MAX_EVENTS) {
1160
+ console.warn(`[SessionVision] Buffer overflow, dropped ${combined.length - BUFFER_CONFIG.MAX_EVENTS} oldest events`);
1161
+ }
1162
+ }
1163
+ }
1164
+ isFlushing = false;
1165
+ }
1166
+ /**
1167
+ * Start the flush timer
1168
+ */
1169
+ function startFlushTimer() {
1170
+ if (flushTimer) {
1171
+ return;
1172
+ }
1173
+ flushTimer = setInterval(() => {
1174
+ flush();
1175
+ }, BUFFER_CONFIG.FLUSH_INTERVAL_MS);
1176
+ }
1177
+ /**
1178
+ * Initialize visibility change handler for flushing on tab hide
1179
+ */
1180
+ function initVisibilityHandler() {
1181
+ document.addEventListener('visibilitychange', () => {
1182
+ if (document.visibilityState === 'hidden') {
1183
+ // Best-effort flush when tab is hidden
1184
+ flush();
1185
+ }
1186
+ });
1187
+ }
1188
+
940
1189
  /**
941
1190
  * Pageview tracking module
942
1191
  * Handles initial page load and SPA navigation
@@ -960,6 +1209,9 @@ function capturePageview(customProperties = {}) {
960
1209
  ...customProperties,
961
1210
  };
962
1211
  captureEvent('$pageview', properties);
1212
+ // Flush immediately to ensure pageview is sent quickly
1213
+ // This captures users who bounce before the 5-second interval
1214
+ flush();
963
1215
  }
964
1216
  /**
965
1217
  * Handle history state changes for SPA navigation
@@ -1208,21 +1460,509 @@ function maskPII(text) {
1208
1460
  pii.pattern.lastIndex = 0;
1209
1461
  masked = masked.replace(pii.pattern, pii.mask);
1210
1462
  }
1211
- return masked;
1463
+ return masked;
1464
+ }
1465
+
1466
+ /**
1467
+ * Rage Click Detection
1468
+ *
1469
+ * Detects rapid repeated clicks indicating user frustration.
1470
+ * Emits a single $rage_click event after the click sequence ends.
1471
+ */
1472
+ /** Elements that legitimately receive rapid clicks */
1473
+ const RAGE_CLICK_EXCLUDED_SELECTORS = [
1474
+ 'input[type="number"]',
1475
+ 'input[type="range"]',
1476
+ '[role="spinbutton"]',
1477
+ '[role="slider"]',
1478
+ '[class*="quantity"]',
1479
+ '[class*="stepper"]',
1480
+ '[class*="increment"]',
1481
+ '[class*="decrement"]',
1482
+ '[class*="plus"]',
1483
+ '[class*="minus"]',
1484
+ 'video',
1485
+ 'audio',
1486
+ '[class*="player"]',
1487
+ '[class*="volume"]',
1488
+ '[class*="seek"]',
1489
+ 'canvas',
1490
+ ];
1491
+ function shouldExcludeFromRageClick(element) {
1492
+ if (element.closest('[data-sessionvision-no-rageclick]')) {
1493
+ return true;
1494
+ }
1495
+ for (const selector of RAGE_CLICK_EXCLUDED_SELECTORS) {
1496
+ if (element.matches(selector) || element.closest(selector)) {
1497
+ return true;
1498
+ }
1499
+ }
1500
+ return false;
1501
+ }
1502
+ class RageClickDetector {
1503
+ constructor(_config) {
1504
+ this.clicks = [];
1505
+ this.emitTimeout = null;
1506
+ this.threshold = RageClickDetector.DEFAULT_THRESHOLD;
1507
+ this.windowMs = RageClickDetector.DEFAULT_WINDOW_MS;
1508
+ this.radiusPx = RageClickDetector.DEFAULT_RADIUS_PX;
1509
+ }
1510
+ recordClick(event, element, properties) {
1511
+ const now = Date.now();
1512
+ this.clicks.push({
1513
+ timestamp: now,
1514
+ x: event.clientX,
1515
+ y: event.clientY,
1516
+ elementSelector: properties.$element_selector || '',
1517
+ element,
1518
+ properties,
1519
+ });
1520
+ // Remove expired clicks outside the window
1521
+ this.clicks = this.clicks.filter((c) => now - c.timestamp <= this.windowMs);
1522
+ // Reset the emit timeout - we'll check after the sequence ends
1523
+ this.scheduleEmit();
1524
+ }
1525
+ scheduleEmit() {
1526
+ if (this.emitTimeout) {
1527
+ clearTimeout(this.emitTimeout);
1528
+ }
1529
+ this.emitTimeout = setTimeout(() => {
1530
+ this.maybeEmitRageClick();
1531
+ }, this.windowMs);
1532
+ }
1533
+ maybeEmitRageClick() {
1534
+ if (this.clicks.length < this.threshold) {
1535
+ this.clicks = [];
1536
+ return;
1537
+ }
1538
+ const sameElement = this.clicks.every((c) => c.elementSelector === this.clicks[0].elementSelector);
1539
+ const sameArea = this.isClickCluster(this.clicks);
1540
+ if (sameElement || sameArea) {
1541
+ this.emitRageClick();
1542
+ }
1543
+ this.clicks = [];
1544
+ }
1545
+ isClickCluster(clicks) {
1546
+ for (let i = 0; i < clicks.length; i++) {
1547
+ for (let j = i + 1; j < clicks.length; j++) {
1548
+ const dx = clicks[i].x - clicks[j].x;
1549
+ const dy = clicks[i].y - clicks[j].y;
1550
+ const distance = Math.sqrt(dx * dx + dy * dy);
1551
+ if (distance > this.radiusPx) {
1552
+ return false;
1553
+ }
1554
+ }
1555
+ }
1556
+ return true;
1557
+ }
1558
+ emitRageClick() {
1559
+ const lastClick = this.clicks[this.clicks.length - 1];
1560
+ const firstClick = this.clicks[0];
1561
+ const duration = lastClick.timestamp - firstClick.timestamp;
1562
+ let elementX = 0;
1563
+ let elementY = 0;
1564
+ try {
1565
+ const rect = lastClick.element.getBoundingClientRect();
1566
+ elementX = lastClick.x - rect.left;
1567
+ elementY = lastClick.y - rect.top;
1568
+ }
1569
+ catch {
1570
+ // Element may have been removed from DOM
1571
+ }
1572
+ const rageClickProperties = {
1573
+ ...lastClick.properties,
1574
+ $click_count: this.clicks.length,
1575
+ $rage_click_duration_ms: duration,
1576
+ $click_x: lastClick.x,
1577
+ $click_y: lastClick.y,
1578
+ $element_x: elementX,
1579
+ $element_y: elementY,
1580
+ $click_positions: this.clicks.map((c) => ({
1581
+ x: c.x,
1582
+ y: c.y,
1583
+ timestamp: c.timestamp,
1584
+ })),
1585
+ };
1586
+ captureEvent('$rage_click', rageClickProperties);
1587
+ }
1588
+ destroy() {
1589
+ if (this.emitTimeout) {
1590
+ clearTimeout(this.emitTimeout);
1591
+ this.emitTimeout = null;
1592
+ }
1593
+ this.clicks = [];
1594
+ }
1595
+ }
1596
+ RageClickDetector.DEFAULT_THRESHOLD = 3;
1597
+ RageClickDetector.DEFAULT_WINDOW_MS = 1000;
1598
+ RageClickDetector.DEFAULT_RADIUS_PX = 30;
1599
+
1600
+ /**
1601
+ * Dead Click Detection
1602
+ *
1603
+ * Detects clicks that produce no visible DOM changes.
1604
+ * Emits $dead_click events when no response is detected.
1605
+ */
1606
+ /** Elements that may not cause visible DOM changes when clicked */
1607
+ const DEAD_CLICK_EXCLUDED_SELECTORS = [
1608
+ 'input[type="text"]',
1609
+ 'input[type="password"]',
1610
+ 'input[type="email"]',
1611
+ 'input[type="search"]',
1612
+ 'input[type="tel"]',
1613
+ 'input[type="url"]',
1614
+ 'textarea',
1615
+ 'select',
1616
+ 'video',
1617
+ 'audio',
1618
+ ];
1619
+ function shouldExcludeFromDeadClick(element) {
1620
+ if (element.closest('[data-sessionvision-no-deadclick]')) {
1621
+ return true;
1622
+ }
1623
+ for (const selector of DEAD_CLICK_EXCLUDED_SELECTORS) {
1624
+ if (element.matches(selector) || element.closest(selector)) {
1625
+ return true;
1626
+ }
1627
+ }
1628
+ return false;
1629
+ }
1630
+ class DeadClickDetector {
1631
+ constructor(_config) {
1632
+ this.pendingClick = null;
1633
+ this.timeoutMs = DeadClickDetector.DEFAULT_TIMEOUT_MS;
1634
+ }
1635
+ monitorClick(event, element, properties) {
1636
+ // Cancel any pending detection
1637
+ this.cancelPending();
1638
+ const timestamp = Date.now();
1639
+ let mutationDetected = false;
1640
+ const observer = new MutationObserver((mutations) => {
1641
+ const meaningful = mutations.some((m) => this.isMeaningfulMutation(m, element));
1642
+ if (meaningful) {
1643
+ mutationDetected = true;
1644
+ this.cancelPending();
1645
+ }
1646
+ });
1647
+ observer.observe(document.body, {
1648
+ childList: true,
1649
+ attributes: true,
1650
+ characterData: true,
1651
+ subtree: true,
1652
+ });
1653
+ const timeout = setTimeout(() => {
1654
+ if (!mutationDetected && this.pendingClick) {
1655
+ this.emitDeadClick(this.pendingClick);
1656
+ }
1657
+ this.cancelPending();
1658
+ }, this.timeoutMs);
1659
+ this.pendingClick = {
1660
+ x: event.clientX,
1661
+ y: event.clientY,
1662
+ element,
1663
+ properties,
1664
+ timestamp,
1665
+ observer,
1666
+ timeout,
1667
+ };
1668
+ }
1669
+ cancelPending() {
1670
+ if (this.pendingClick) {
1671
+ this.pendingClick.observer.disconnect();
1672
+ clearTimeout(this.pendingClick.timeout);
1673
+ this.pendingClick = null;
1674
+ }
1675
+ }
1676
+ isMeaningfulMutation(mutation, clickedElement) {
1677
+ // Ignore class changes on clicked element (often just :active styles)
1678
+ if (mutation.type === 'attributes' &&
1679
+ mutation.attributeName === 'class' &&
1680
+ mutation.target === clickedElement) {
1681
+ return false;
1682
+ }
1683
+ // Ignore data-* attribute changes (often analytics)
1684
+ if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
1685
+ return false;
1686
+ }
1687
+ // Ignore script/style/link/meta injections
1688
+ if (mutation.type === 'childList') {
1689
+ const ignoredNodes = ['SCRIPT', 'STYLE', 'LINK', 'META'];
1690
+ const addedNonIgnored = Array.from(mutation.addedNodes).some((node) => !ignoredNodes.includes(node.nodeName));
1691
+ if (!addedNonIgnored && mutation.removedNodes.length === 0) {
1692
+ return false;
1693
+ }
1694
+ }
1695
+ return true;
1696
+ }
1697
+ emitDeadClick(pending) {
1698
+ let elementX = 0;
1699
+ let elementY = 0;
1700
+ try {
1701
+ const rect = pending.element.getBoundingClientRect();
1702
+ elementX = pending.x - rect.left;
1703
+ elementY = pending.y - rect.top;
1704
+ }
1705
+ catch {
1706
+ // Element may have been removed from DOM
1707
+ }
1708
+ const deadClickProperties = {
1709
+ ...pending.properties,
1710
+ $click_x: pending.x,
1711
+ $click_y: pending.y,
1712
+ $element_x: elementX,
1713
+ $element_y: elementY,
1714
+ $wait_duration_ms: Date.now() - pending.timestamp,
1715
+ $element_is_interactive: isInteractiveElement(pending.element),
1716
+ };
1717
+ captureEvent('$dead_click', deadClickProperties);
1718
+ }
1719
+ destroy() {
1720
+ this.cancelPending();
1721
+ }
1722
+ }
1723
+ DeadClickDetector.DEFAULT_TIMEOUT_MS = 1000;
1724
+
1725
+ /**
1726
+ * Form Field Tracking
1727
+ *
1728
+ * Tracks form field interactions for abandonment analysis.
1729
+ * Emits $form_start when user begins filling a form,
1730
+ * $form_field_change when fields are modified.
1731
+ */
1732
+ /**
1733
+ * Form field tracker instance
1734
+ */
1735
+ let isActive = false;
1736
+ const startedForms = new Map();
1737
+ /**
1738
+ * Get form fields (inputs, textareas, selects)
1739
+ */
1740
+ function getFormFields(form) {
1741
+ const fields = form.querySelectorAll('input, textarea, select');
1742
+ return Array.from(fields);
1743
+ }
1744
+ /**
1745
+ * Get the index of a field within its form
1746
+ */
1747
+ function getFieldIndex(field, form) {
1748
+ const fields = getFormFields(form);
1749
+ return fields.indexOf(field);
1750
+ }
1751
+ /**
1752
+ * Get the type of a form field
1753
+ */
1754
+ function getFieldType(field) {
1755
+ if (field instanceof HTMLInputElement) {
1756
+ return field.type || 'text';
1757
+ }
1758
+ if (field instanceof HTMLTextAreaElement) {
1759
+ return 'textarea';
1760
+ }
1761
+ if (field instanceof HTMLSelectElement) {
1762
+ return 'select';
1763
+ }
1764
+ return 'unknown';
1765
+ }
1766
+ /**
1767
+ * Check if a field has a value
1768
+ */
1769
+ function fieldHasValue(field) {
1770
+ if (field instanceof HTMLInputElement) {
1771
+ if (field.type === 'checkbox' || field.type === 'radio') {
1772
+ return field.checked;
1773
+ }
1774
+ return field.value.length > 0;
1775
+ }
1776
+ if (field instanceof HTMLTextAreaElement) {
1777
+ return field.value.length > 0;
1778
+ }
1779
+ if (field instanceof HTMLSelectElement) {
1780
+ return field.selectedIndex > 0 || (field.selectedIndex === 0 && field.value !== '');
1781
+ }
1782
+ return false;
1783
+ }
1784
+ /**
1785
+ * Check if a field is a form field
1786
+ */
1787
+ function isFormField(element) {
1788
+ return (element instanceof HTMLInputElement ||
1789
+ element instanceof HTMLTextAreaElement ||
1790
+ element instanceof HTMLSelectElement);
1791
+ }
1792
+ /**
1793
+ * Get the parent form of an element
1794
+ */
1795
+ function getParentForm(element) {
1796
+ return element.closest('form');
1797
+ }
1798
+ /**
1799
+ * Handle focus events on form fields
1800
+ */
1801
+ function handleFocusIn(event) {
1802
+ const target = event.target;
1803
+ if (!target || !isFormField(target)) {
1804
+ return;
1805
+ }
1806
+ if (shouldIgnoreElement(target)) {
1807
+ return;
1808
+ }
1809
+ const form = getParentForm(target);
1810
+ if (!form || shouldIgnoreElement(form)) {
1811
+ return;
1812
+ }
1813
+ const formSelector = generateSelector(form);
1814
+ // Check if this form has already been started
1815
+ if (startedForms.has(formSelector)) {
1816
+ return;
1817
+ }
1818
+ // Emit $form_start
1819
+ const fields = getFormFields(form);
1820
+ const properties = {
1821
+ $form_id: form.id || null,
1822
+ $form_name: form.name || null,
1823
+ $form_selector: formSelector,
1824
+ $form_field_count: fields.length,
1825
+ };
1826
+ captureEvent('$form_start', properties);
1827
+ // Track form state
1828
+ startedForms.set(formSelector, {
1829
+ startTime: Date.now(),
1830
+ fieldCount: fields.length,
1831
+ interactedFields: new Set(),
1832
+ filledFields: new Set(),
1833
+ });
1834
+ }
1835
+ /**
1836
+ * Handle change events on form fields
1837
+ */
1838
+ function handleChange(event) {
1839
+ const target = event.target;
1840
+ if (!target || !isFormField(target)) {
1841
+ return;
1842
+ }
1843
+ if (shouldIgnoreElement(target)) {
1844
+ return;
1845
+ }
1846
+ const form = getParentForm(target);
1847
+ if (!form || shouldIgnoreElement(form)) {
1848
+ return;
1849
+ }
1850
+ const formSelector = generateSelector(form);
1851
+ const fieldSelector = generateSelector(target);
1852
+ const formState = startedForms.get(formSelector);
1853
+ // Track interaction even if form wasn't started (edge case)
1854
+ if (!formState) {
1855
+ // Start the form now
1856
+ const fields = getFormFields(form);
1857
+ const startProps = {
1858
+ $form_id: form.id || null,
1859
+ $form_name: form.name || null,
1860
+ $form_selector: formSelector,
1861
+ $form_field_count: fields.length,
1862
+ };
1863
+ captureEvent('$form_start', startProps);
1864
+ startedForms.set(formSelector, {
1865
+ startTime: Date.now(),
1866
+ fieldCount: fields.length,
1867
+ interactedFields: new Set([fieldSelector]),
1868
+ filledFields: new Set(fieldHasValue(target) ? [fieldSelector] : []),
1869
+ });
1870
+ }
1871
+ else {
1872
+ // Update tracking
1873
+ formState.interactedFields.add(fieldSelector);
1874
+ if (fieldHasValue(target)) {
1875
+ formState.filledFields.add(fieldSelector);
1876
+ }
1877
+ else {
1878
+ formState.filledFields.delete(fieldSelector);
1879
+ }
1880
+ }
1881
+ // Emit $form_field_change
1882
+ const properties = {
1883
+ $form_selector: formSelector,
1884
+ $field_selector: fieldSelector,
1885
+ $field_name: target.name || null,
1886
+ $field_type: getFieldType(target),
1887
+ $field_index: getFieldIndex(target, form),
1888
+ $has_value: fieldHasValue(target),
1889
+ };
1890
+ captureEvent('$form_field_change', properties);
1891
+ }
1892
+ /**
1893
+ * Reset tracking for a specific form (called after submit)
1894
+ */
1895
+ function resetForm(formSelector) {
1896
+ startedForms.delete(formSelector);
1897
+ }
1898
+ /**
1899
+ * Get form tracking data for enhanced submit event
1900
+ */
1901
+ function getFormTrackingData(form) {
1902
+ const formSelector = generateSelector(form);
1903
+ const fields = getFormFields(form);
1904
+ const formState = startedForms.get(formSelector);
1905
+ if (!formState) {
1906
+ // Form submitted without tracking (e.g., direct submit without field focus)
1907
+ const filledFields = [];
1908
+ for (const field of fields) {
1909
+ if (fieldHasValue(field) && !shouldIgnoreElement(field)) {
1910
+ filledFields.push(generateSelector(field));
1911
+ }
1912
+ }
1913
+ return {
1914
+ formSelector,
1915
+ formFieldCount: fields.length,
1916
+ formFieldsFilled: filledFields,
1917
+ formFieldsInteracted: [],
1918
+ formCompletionRate: fields.length > 0 ? filledFields.length / fields.length : 0,
1919
+ formTimeToSubmitMs: null,
1920
+ };
1921
+ }
1922
+ // Update filled fields snapshot at submit time
1923
+ const currentFilledFields = [];
1924
+ for (const field of fields) {
1925
+ if (fieldHasValue(field) && !shouldIgnoreElement(field)) {
1926
+ currentFilledFields.push(generateSelector(field));
1927
+ }
1928
+ }
1929
+ const completionRate = fields.length > 0 ? currentFilledFields.length / fields.length : 0;
1930
+ return {
1931
+ formSelector,
1932
+ formFieldCount: formState.fieldCount,
1933
+ formFieldsFilled: currentFilledFields,
1934
+ formFieldsInteracted: Array.from(formState.interactedFields),
1935
+ formCompletionRate: Math.round(completionRate * 100) / 100,
1936
+ formTimeToSubmitMs: Date.now() - formState.startTime,
1937
+ };
1938
+ }
1939
+ /**
1940
+ * Initialize form field tracking
1941
+ */
1942
+ function initFormFieldTracking(_cfg) {
1943
+ if (isActive) {
1944
+ return;
1945
+ }
1946
+ isActive = true;
1947
+ document.addEventListener('focusin', handleFocusIn, { capture: true });
1948
+ document.addEventListener('change', handleChange, { capture: true });
1212
1949
  }
1213
1950
 
1214
1951
  /**
1215
1952
  * Autocapture module
1216
- * Handles automatic capture of clicks and form submissions
1953
+ * Handles automatic capture of clicks, form submissions,
1954
+ * rage clicks, and dead clicks
1217
1955
  */
1218
1956
  let isClickTrackingActive = false;
1219
1957
  let isFormTrackingActive = false;
1220
- let config$2 = null;
1958
+ let config = null;
1959
+ let rageClickDetector = null;
1960
+ let deadClickDetector = null;
1221
1961
  /**
1222
1962
  * Set the configuration
1223
1963
  */
1224
1964
  function setAutocaptureConfig(cfg) {
1225
- config$2 = cfg;
1965
+ config = cfg;
1226
1966
  }
1227
1967
  /**
1228
1968
  * Handle click events
@@ -1246,6 +1986,13 @@ function handleClick(event) {
1246
1986
  $element_href: getElementHref(element),
1247
1987
  };
1248
1988
  captureEvent('$click', properties);
1989
+ // Feed click to frustration detectors
1990
+ if (rageClickDetector && !shouldExcludeFromRageClick(element)) {
1991
+ rageClickDetector.recordClick(event, element, properties);
1992
+ }
1993
+ if (deadClickDetector && !shouldExcludeFromDeadClick(element)) {
1994
+ deadClickDetector.monitorClick(event, element, properties);
1995
+ }
1249
1996
  }
1250
1997
  /**
1251
1998
  * Handle form submission events
@@ -1258,13 +2005,23 @@ function handleFormSubmit(event) {
1258
2005
  if (shouldIgnoreElement(form)) {
1259
2006
  return;
1260
2007
  }
2008
+ // Get form field tracking data
2009
+ const trackingData = getFormTrackingData(form);
1261
2010
  const properties = {
1262
2011
  $form_id: form.id || null,
1263
2012
  $form_action: form.action || '',
1264
2013
  $form_method: (form.method || 'GET').toUpperCase(),
1265
2014
  $form_name: form.name || null,
2015
+ $form_selector: trackingData.formSelector,
2016
+ $form_field_count: trackingData.formFieldCount,
2017
+ $form_fields_filled: trackingData.formFieldsFilled,
2018
+ $form_fields_interacted: trackingData.formFieldsInteracted,
2019
+ $form_completion_rate: trackingData.formCompletionRate,
2020
+ $form_time_to_submit_ms: trackingData.formTimeToSubmitMs,
1266
2021
  };
1267
2022
  captureEvent('$form_submit', properties);
2023
+ // Reset form tracking state after submit
2024
+ resetForm(trackingData.formSelector);
1268
2025
  }
1269
2026
  /**
1270
2027
  * Initialize click tracking
@@ -1290,253 +2047,24 @@ function initFormTracking() {
1290
2047
  * Initialize all autocapture based on configuration
1291
2048
  */
1292
2049
  function initAutocapture(cfg) {
1293
- config$2 = cfg;
1294
- if (config$2.autocapture.clicks) {
2050
+ config = cfg;
2051
+ if (config.autocapture.click) {
1295
2052
  initClickTracking();
1296
2053
  }
1297
- if (config$2.autocapture.formSubmit) {
2054
+ if (config.autocapture.formSubmit) {
1298
2055
  initFormTracking();
1299
2056
  }
1300
- }
1301
-
1302
- /**
1303
- * Payload compression module
1304
- * Uses CompressionStream API when available
1305
- */
1306
- /**
1307
- * Check if compression is supported
1308
- */
1309
- function isCompressionSupported() {
1310
- return (typeof CompressionStream !== 'undefined' &&
1311
- typeof ReadableStream !== 'undefined');
1312
- }
1313
- /**
1314
- * Compress a string using gzip
1315
- * Returns the compressed data as a Blob, or null if compression is not supported
1316
- */
1317
- async function compressPayload(data) {
1318
- if (!isCompressionSupported()) {
1319
- return null;
1320
- }
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' });
1341
- }
1342
- catch {
1343
- // Compression failed, return null to use uncompressed
1344
- return null;
1345
- }
1346
- }
1347
- /**
1348
- * Get the size of data in bytes
1349
- */
1350
- function getByteSize(data) {
1351
- return new Blob([data]).size;
1352
- }
1353
- /**
1354
- * Check if payload should be compressed (based on size threshold)
1355
- * Only compress if payload is larger than 1KB
1356
- */
1357
- function shouldCompress(data) {
1358
- return isCompressionSupported() && getByteSize(data) > 1024;
1359
- }
1360
-
1361
- /**
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
1369
- */
1370
- function setTransportConfig(cfg) {
1371
- config$1 = cfg;
1372
- }
1373
- /**
1374
- * Sleep for a given number of milliseconds
1375
- */
1376
- function sleep(ms) {
1377
- return new Promise((resolve) => setTimeout(resolve, ms));
1378
- }
1379
- /**
1380
- * Send events to the ingest API
1381
- * Handles compression and retry logic
1382
- */
1383
- async function sendEvents(payload) {
1384
- if (!config$1) {
1385
- console.warn('[SessionVision] SDK not initialized');
1386
- return false;
1387
- }
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';
1402
- }
1403
- }
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}`);
1428
- }
1429
- return false;
1430
- }
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
- }
1447
- }
1448
- }
1449
- return false;
1450
- }
1451
- /**
1452
- * Check if we should stop retrying (3+ consecutive failures)
1453
- */
1454
- function shouldStopRetrying() {
1455
- return consecutiveFailures >= 3;
1456
- }
1457
-
1458
- /**
1459
- * Event buffer module
1460
- * Buffers events and flushes them periodically or when buffer is full
1461
- */
1462
- let eventBuffer = [];
1463
- let flushTimer = null;
1464
- let config = null;
1465
- let isFlushing = false;
1466
- /**
1467
- * Set the configuration
1468
- */
1469
- function setBufferConfig(cfg) {
1470
- config = cfg;
1471
- }
1472
- /**
1473
- * Add an event to the buffer
1474
- */
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
- }
1481
- return;
1482
- }
1483
- eventBuffer.push(event);
1484
- // Flush if buffer is full
1485
- if (eventBuffer.length >= BUFFER_CONFIG.MAX_EVENTS) {
1486
- flush();
1487
- }
1488
- }
1489
- /**
1490
- * Flush the event buffer
1491
- * Sends all buffered events to the server
1492
- */
1493
- async function flush() {
1494
- if (isFlushing || eventBuffer.length === 0 || !config) {
1495
- return;
2057
+ // Initialize form field tracking for abandonment analysis
2058
+ if (config.autocapture.formAbandonment) {
2059
+ initFormFieldTracking();
1496
2060
  }
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,
1504
- };
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
- }
2061
+ // Initialize frustration detectors
2062
+ if (config.autocapture.rageClick) {
2063
+ rageClickDetector = new RageClickDetector(config);
1516
2064
  }
1517
- isFlushing = false;
1518
- }
1519
- /**
1520
- * Start the flush timer
1521
- */
1522
- function startFlushTimer() {
1523
- if (flushTimer) {
1524
- return;
2065
+ if (config.autocapture.deadClick) {
2066
+ deadClickDetector = new DeadClickDetector(config);
1525
2067
  }
1526
- flushTimer = setInterval(() => {
1527
- flush();
1528
- }, BUFFER_CONFIG.FLUSH_INTERVAL_MS);
1529
- }
1530
- /**
1531
- * Initialize visibility change handler for flushing on tab hide
1532
- */
1533
- function initVisibilityHandler() {
1534
- document.addEventListener('visibilitychange', () => {
1535
- if (document.visibilityState === 'hidden') {
1536
- // Best-effort flush when tab is hidden
1537
- flush();
1538
- }
1539
- });
1540
2068
  }
1541
2069
 
1542
2070
  /**
@@ -1670,6 +2198,12 @@ function registerOnce(properties) {
1670
2198
  }
1671
2199
  registerOnceProperties(properties);
1672
2200
  }
2201
+ /**
2202
+ * Manually flush the event buffer
2203
+ */
2204
+ function flushEvents() {
2205
+ return flush();
2206
+ }
1673
2207
 
1674
2208
  /**
1675
2209
  * Queue replay module
@@ -1741,7 +2275,7 @@ function getInitCalls(initArray) {
1741
2275
  * Session Vision JavaScript Snippet
1742
2276
  * Main SDK entry point
1743
2277
  *
1744
- * @version "0.2.0"
2278
+ * @version "0.3.0"
1745
2279
  */
1746
2280
  /**
1747
2281
  * Session Vision SDK instance
@@ -1750,7 +2284,7 @@ const sessionvision = {
1750
2284
  /**
1751
2285
  * SDK version
1752
2286
  */
1753
- version: "0.2.0" ,
2287
+ version: "0.3.0" ,
1754
2288
  /**
1755
2289
  * Initialize the SDK with a project token and optional configuration
1756
2290
  *
@@ -1865,6 +2399,22 @@ const sessionvision = {
1865
2399
  registerOnce(properties) {
1866
2400
  registerOnce(properties);
1867
2401
  },
2402
+ /**
2403
+ * Manually flush the event buffer
2404
+ * Useful before navigation or when you need to ensure events are sent immediately
2405
+ *
2406
+ * @returns Promise that resolves when the flush is complete
2407
+ *
2408
+ * @example
2409
+ * ```js
2410
+ * // Before navigating away
2411
+ * await sessionvision.flushEvents();
2412
+ * window.location.href = '/new-page';
2413
+ * ```
2414
+ */
2415
+ flushEvents() {
2416
+ return flushEvents();
2417
+ },
1868
2418
  };
1869
2419
  /**
1870
2420
  * Bootstrap the SDK