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