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