overcentric
Version:
Overcentric watches your website, product, and users - and tells you what matters and what to do about it.
226 lines (225 loc) • 8.24 kB
JavaScript
import { record } from "@rrweb/record";
import { CONFIG, log } from "./config";
import { getDeviceId } from "./identity";
const DEFAULT_MAX_RECORDING_DURATION = 90 * 60 * 1000;
const MAX_EVENTS_IN_MEMORY = 1000;
const MIN_RECORDING_DURATION = 1 * 1000;
let activeRecording = null;
function stopActiveRecording() {
if (activeRecording) {
if (activeRecording.stopFn) {
activeRecording.stopFn();
}
if (activeRecording.flushInterval) {
clearInterval(activeRecording.flushInterval);
}
if (activeRecording.maxDurationTimeout) {
clearTimeout(activeRecording.maxDurationTimeout);
}
if (activeRecording.beforeUnloadHandler) {
try {
window.removeEventListener("beforeunload", activeRecording.beforeUnloadHandler);
}
catch (e) { }
}
if (activeRecording.visibilityHandler) {
try {
document.removeEventListener("visibilitychange", activeRecording.visibilityHandler);
}
catch (e) { }
}
activeRecording = null;
delete window.overcentricStopRecording;
log.debug("Active recording stopped and cleaned up");
}
}
async function initRecording(sessionId, maxDuration = DEFAULT_MAX_RECORDING_DURATION) {
var _a;
if (activeRecording) {
if (activeRecording.sessionId === sessionId) {
log.debug("Recording already active for this session, skipping");
return;
}
log.debug("New session detected, stopping previous recording");
stopActiveRecording();
}
log.debug("Starting recording");
const deviceId = getDeviceId();
const identityId = (_a = window.overcentricUserIdentity) === null || _a === void 0 ? void 0 : _a.uniqueIdentifier;
const events = [];
const BATCH_SIZE = 50;
const recordingStartTime = Date.now();
let isRecordingStopped = false;
try {
const response = await fetch(`${CONFIG.basePath}/recordings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
device_id: deviceId,
identity_id: identityId,
project_id: window.overcentricProjectId,
context: window.overcentricContext,
hostname: window.location.hostname,
pathname: window.location.pathname,
}),
});
if (!response.ok) {
throw new Error(`Failed to register recording: ${response.status}`);
}
log.debug("Recording session registered");
}
catch (error) {
log.error("Failed to register recording session", error);
return;
}
const sendRecordingEvents = async (eventsToSend) => {
if (!eventsToSend.length)
return;
try {
const response = await fetch(`${CONFIG.basePath}/recordings/${sessionId}/chunks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_id: window.overcentricProjectId,
events: eventsToSend,
timestamp: Date.now(),
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
log.debug(`Sent ${eventsToSend.length} recording events`);
}
catch (error) {
log.error("Failed to send recording events", error);
events.length = 0;
}
};
const stopRecording = () => {
if (!isRecordingStopped) {
isRecordingStopped = true;
if (events.length > 0) {
sendRecordingEvents(events.splice(0));
}
stopActiveRecording();
log.debug("Recording stopped");
}
};
const visibilityHandler = () => {
if (document.visibilityState === "hidden") {
log.debug("Tab hidden, stopping recording");
stopRecording();
}
};
document.addEventListener("visibilitychange", visibilityHandler);
const flushInterval = setInterval(() => {
if (isRecordingStopped)
return;
if (Date.now() - recordingStartTime >= maxDuration) {
log.debug(`Maximum recording duration of ${maxDuration / 1000}s reached, stopping recording`);
stopRecording();
return;
}
if (events.length >= MAX_EVENTS_IN_MEMORY) {
log.warn(`Too many events in memory (${events.length}), forcing flush`);
sendRecordingEvents(events.splice(0));
}
else if (events.length > 0) {
sendRecordingEvents(events.splice(0));
}
}, 2000);
const beforeUnloadHandler = () => {
const recordingDuration = Date.now() - recordingStartTime;
if (events.length > 0 &&
!isRecordingStopped &&
recordingDuration >= MIN_RECORDING_DURATION) {
try {
const blob = new Blob([
JSON.stringify({
project_id: window.overcentricProjectId,
events: events.splice(0),
timestamp: Date.now(),
}),
], { type: "application/json" });
const success = navigator.sendBeacon(`${CONFIG.basePath}/recordings/${sessionId}/chunks`, blob);
if (!success) {
log.warn("sendBeacon failed, data may be lost on page unload");
}
}
catch (error) {
log.warn("Failed to send events on page unload");
}
}
stopRecording();
};
window.addEventListener("beforeunload", beforeUnloadHandler);
let maxDurationTimeout = null;
if (maxDuration > 0) {
maxDurationTimeout = setTimeout(stopRecording, maxDuration);
}
window.overcentricStopRecording = stopRecording;
const stopFn = record({
emit(event) {
if (isRecordingStopped)
return;
events.push(event);
if (events.length >= MAX_EVENTS_IN_MEMORY) {
log.warn(`Event buffer full (${events.length}), forcing immediate flush`);
sendRecordingEvents(events.splice(0));
}
else if (events.length >= BATCH_SIZE) {
sendRecordingEvents(events.splice(0));
}
},
maskInputOptions: { password: true },
recordCanvas: true,
sampling: {
mousemove: true,
scroll: 150,
input: "last",
canvas: 2,
},
recordCrossOriginIframes: false,
slimDOMOptions: {
script: true,
comment: true,
headFavicon: true,
headWhitespace: true,
headMetaSocial: true,
headMetaRobots: true,
headMetaHttpEquiv: true,
headMetaVerification: true,
headMetaAuthorship: true,
},
});
activeRecording = {
sessionId,
stopFn: stopFn || null,
flushInterval,
maxDurationTimeout,
beforeUnloadHandler,
visibilityHandler,
};
}
async function updateRecordingIdentity(sessionId, userIdentity) {
try {
const response = await fetch(`${CONFIG.basePath}/recordings/${sessionId}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
identity_id: userIdentity.uniqueIdentifier,
project_id: window.overcentricProjectId,
}),
});
if (!response.ok) {
throw new Error(`Failed to update recording identity: ${response.status}`);
}
log.debug("Recording identity updated");
}
catch (error) {
log.error("Failed to update recording identity", error);
}
}
export { initRecording, updateRecordingIdentity, stopActiveRecording };