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