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