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