overcentric
Version:
Overcentric watches your website, product, and users - and tells you what matters and what to do about it.
353 lines (352 loc) • 13.4 kB
JavaScript
import { record } from "@rrweb/record";
import { CONFIG, log } from "./config";
import { getDeviceId } from "./identity";
import { onUserActivity } from "./activity";
const DEFAULT_MAX_RECORDING_DURATION = 90 * 60 * 1000;
// Stop recording (and let the session lapse) after this much continuous
// inactivity, even if the tab stays open and focused. Kept in sync with the
// session timeout in session.ts.
const INACTIVITY_TIMEOUT = 30 * 60 * 1000;
const MAX_EVENTS_IN_MEMORY = 1000;
const MIN_RECORDING_DURATION = 1 * 1000;
// While true, the current page URL matches a block pattern: rrweb events are
// dropped (the blocked page's content never leaves the browser) and the replay
// shows a frozen gap, bookended by custom marker events.
let isBlockGateActive = false;
let activeRecording = null;
// True while the current page URL matches one of the configured block patterns.
function isUrlBlocked() {
const patterns = CONFIG.recordingBlockUrlPatterns;
if (!patterns || patterns.length === 0)
return false;
const url = window.location.href;
return patterns.some((pattern) => url.includes(pattern));
}
// Toggle the block gate to match the current URL. On entering a blocked page we
// emit an `oc-block-start` marker and start dropping events; on leaving we emit
// `oc-block-end` and take a fresh full snapshot so the replay re-syncs with the
// live DOM. The live page is never touched.
function syncBlockGate() {
const shouldBlock = isUrlBlocked();
if (shouldBlock === isBlockGateActive)
return;
if (shouldBlock) {
try {
record.addCustomEvent("oc-block-start", {});
}
catch (e) { }
isBlockGateActive = true;
log.debug("Recording block gate engaged for blocked URL");
}
else {
isBlockGateActive = false;
try {
record.addCustomEvent("oc-block-end", {});
// Re-serialize the now-visible page so the replay resumes correctly after
// the blocked gap (incremental mutations were dropped while gated).
record.takeFullSnapshot(true);
}
catch (e) { }
log.debug("Recording block gate released; full snapshot taken");
}
}
// Watch for URL changes (history API + popstate/hashchange) so SPA navigations
// toggle the gate too. Returns an unsubscribe function.
function watchUrlForBlocking() {
var _a;
syncBlockGate();
if (!((_a = CONFIG.recordingBlockUrlPatterns) === null || _a === void 0 ? void 0 : _a.length)) {
return () => { };
}
const check = () => {
syncBlockGate();
};
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function (...args) {
const r = origPushState.apply(this, args);
check();
return r;
};
history.replaceState = function (...args) {
const r = origReplaceState.apply(this, args);
check();
return r;
};
window.addEventListener("popstate", check);
window.addEventListener("hashchange", check);
return () => {
history.pushState = origPushState;
history.replaceState = origReplaceState;
window.removeEventListener("popstate", check);
window.removeEventListener("hashchange", check);
isBlockGateActive = false;
};
}
function stopActiveRecording() {
if (activeRecording) {
if (activeRecording.stopFn) {
activeRecording.stopFn();
}
if (activeRecording.flushInterval) {
clearInterval(activeRecording.flushInterval);
}
if (activeRecording.maxDurationTimeout) {
clearTimeout(activeRecording.maxDurationTimeout);
}
if (activeRecording.inactivityTimeout) {
clearTimeout(activeRecording.inactivityTimeout);
}
if (activeRecording.unsubscribeActivity) {
try {
activeRecording.unsubscribeActivity();
}
catch (e) { }
}
if (activeRecording.beforeUnloadHandler) {
try {
window.removeEventListener("beforeunload", activeRecording.beforeUnloadHandler);
}
catch (e) { }
}
if (activeRecording.visibilityHandler) {
try {
document.removeEventListener("visibilitychange", activeRecording.visibilityHandler);
}
catch (e) { }
}
if (activeRecording.stopUrlWatch) {
try {
activeRecording.stopUrlWatch();
}
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);
}
// Stop the recording after a stretch of genuine inactivity, even if the tab
// stays open and focused (visibilitychange/beforeunload won't fire then).
// The timer is reset on every real user-input event.
let inactivityTimeout = setTimeout(() => {
log.debug("Inactivity timeout reached, stopping recording");
stopRecording();
}, INACTIVITY_TIMEOUT);
const unsubscribeActivity = onUserActivity(() => {
if (isRecordingStopped)
return;
if (inactivityTimeout)
clearTimeout(inactivityTimeout);
inactivityTimeout = setTimeout(() => {
log.debug("Inactivity timeout reached, stopping recording");
stopRecording();
}, INACTIVITY_TIMEOUT);
if (activeRecording)
activeRecording.inactivityTimeout = inactivityTimeout;
});
window.overcentricStopRecording = stopRecording;
const stopFn = record({
emit(event) {
if (isRecordingStopped)
return;
// While on a blocked URL, drop all events so the page's content never
// leaves the browser. The block start/end markers are custom events
// (EventType.Custom === 5) emitted by syncBlockGate and must pass through
// so the replay knows the gap is intentional.
const isCustomEvent = (event === null || event === void 0 ? void 0 : event.type) === 5;
if (isBlockGateActive && !isCustomEvent)
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));
}
},
blockClass: "oc-block",
ignoreClass: "oc-ignore",
maskTextClass: "oc-mask",
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,
},
collectFonts: true
});
const stopUrlWatch = watchUrlForBlocking();
activeRecording = {
sessionId,
stopFn: stopFn || null,
flushInterval,
maxDurationTimeout,
inactivityTimeout,
unsubscribeActivity,
beforeUnloadHandler,
visibilityHandler,
stopUrlWatch,
};
}
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 };