Files
stats/packages/sdks/web/src/replay/recorder.ts
2026-03-03 10:59:45 +01:00

171 lines
4.9 KiB
TypeScript

import type { eventWithTime } from 'rrweb';
import { record } from 'rrweb';
export type ReplayRecorderConfig = {
maskAllInputs?: boolean;
maskAllText?: boolean;
unmaskTextSelector?: string;
blockSelector?: string;
blockClass?: string;
ignoreSelector?: string;
flushIntervalMs?: number;
maxEventsPerChunk?: number;
maxPayloadBytes?: number;
};
export type ReplayChunkPayload = {
chunk_index: number;
events_count: number;
is_full_snapshot: boolean;
started_at: string;
ended_at: string;
payload: string;
};
let stopRecording: (() => void) | null = null;
export function startReplayRecorder(
config: ReplayRecorderConfig,
sendChunk: (payload: ReplayChunkPayload) => void,
): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
// Stop any existing recorder before starting a new one to avoid leaks
if (stopRecording) {
stopRecording();
}
const maxEventsPerChunk = config.maxEventsPerChunk ?? 200;
const flushIntervalMs = config.flushIntervalMs ?? 10_000;
const maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1MB
let buffer: eventWithTime[] = [];
let chunkIndex = 0;
let flushTimer: ReturnType<typeof setInterval> | null = null;
function flush(isFullSnapshot: boolean): void {
if (buffer.length === 0) return;
const payloadJson = JSON.stringify(buffer);
const payloadBytes = new TextEncoder().encode(payloadJson).length;
if (payloadBytes > maxPayloadBytes) {
if (buffer.length > 1) {
const mid = Math.floor(buffer.length / 2);
const firstHalf = buffer.slice(0, mid);
const secondHalf = buffer.slice(mid);
const firstHasFullSnapshot =
isFullSnapshot && firstHalf.some((e) => e.type === 2);
buffer = firstHalf;
flush(firstHasFullSnapshot);
buffer = secondHalf;
flush(false);
return;
}
// Single event exceeds limit — drop it to avoid server rejection
buffer = [];
return;
}
const startedAt = buffer[0]!.timestamp;
const endedAt = buffer[buffer.length - 1]!.timestamp;
try {
sendChunk({
chunk_index: chunkIndex,
events_count: buffer.length,
is_full_snapshot: isFullSnapshot,
started_at: new Date(startedAt).toISOString(),
ended_at: new Date(endedAt).toISOString(),
payload: payloadJson,
});
chunkIndex += 1;
buffer = [];
} catch (err) {
console.error('[ReplayRecorder] sendChunk failed', err);
throw err;
}
}
function flushIfNeeded(isCheckout: boolean): void {
const isFullSnapshot =
isCheckout ||
buffer.some((e) => e.type === 2); /* EventType.FullSnapshot */
if (buffer.length >= maxEventsPerChunk) {
flush(isFullSnapshot);
} else if (isCheckout && buffer.length > 0) {
flush(true);
}
}
const maskAllText = config.maskAllText !== false;
const unmaskTextSelector = config.unmaskTextSelector;
const stopFn = record({
emit(event: eventWithTime, isCheckout?: boolean) {
buffer.push(event);
flushIfNeeded(!!isCheckout);
},
checkoutEveryNms: flushIntervalMs,
maskAllInputs: config.maskAllInputs ?? true,
maskTextSelector: maskAllText ? '*' : '[data-openpanel-replay-mask]',
maskTextFn: maskAllText && unmaskTextSelector
? (text, element) => {
if (element?.closest(unmaskTextSelector)) return text;
return text.replace(/\S/g, '*');
}
: undefined,
blockSelector: config.blockSelector ?? '[data-openpanel-replay-block]',
blockClass: config.blockClass,
ignoreSelector: config.ignoreSelector,
});
flushTimer = setInterval(() => {
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}, flushIntervalMs);
function onVisibilityChange(): void {
if (document.visibilityState === 'hidden' && buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}
function onPageHide(): void {
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
stopRecording = () => {
// Flush any buffered events before tearing down (same logic as flushTimer)
if (buffer.length > 0) {
const hasFullSnapshot = buffer.some((e) => e.type === 2);
flush(hasFullSnapshot);
}
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
stopFn?.();
stopRecording = null;
};
}
export function stopReplayRecorder(): void {
if (stopRecording) {
stopRecording();
}
}