UNPKG

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
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 };