import { AgentToggleButton, useSendToAgentChat, } from "@agent-native/core/client/agent-chat"; import { appApiPath } from "@agent-native/core/client/api-path"; import { PromptComposer } from "@agent-native/core/client/composer"; import { callAction, useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { SESSION_REPLAY_AGENT_ACCESS_PARAM } from "@shared/session-replay-agent-access"; import { isFailedSessionReplayNetworkStatus, SESSION_REPLAY_CONSOLE_EVENT_TAG, SESSION_REPLAY_NETWORK_EVENT_TAG, } from "@shared/session-replay-diagnostics"; import { IconArrowLeft, IconCheck, IconChevronRight, IconCopy, IconExclamationCircle, IconKeyboard, IconMessageCircle, IconMouse, IconPlayerPause, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward, IconPlayerTrackNext, IconRoute, IconSearch, IconTerminal2, IconTimelineEvent, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, type FormEvent, } from "react"; import { Link, useParams, useSearchParams } from "react-router"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { getIdToken } from "@/lib/auth"; import { cn } from "@/lib/utils"; import { extractReplayDiagnostics } from "./session-replay-devtools"; import type { ReplayDevToolsDiagnostics } from "./session-replay-devtools"; import { type SessionIssueMatch, SessionDevToolsPanel, } from "./SessionDevToolsPanel"; type SessionRecordingSummary = { id: string; clientRecordingId: string; sessionId: string; userId: string | null; anonymousId: string | null; userKey: string | null; startedAt: string; endedAt: string | null; durationMs: number | null; chunkCount: number; eventCount: number; totalBytes: number; pageCount: number; errorCount: number; rageClickCount: number; privacyMode: string; firstUrl: string | null; lastUrl: string | null; path: string | null; hostname: string | null; referrer: string | null; app: string | null; template: string | null; status: "active" | "completed"; createdAt: string; updatedAt: string; lastIngestedAt: string | null; }; type ReplayChunkEvents = { seq: number; checksum: string; byteLength: number; eventCount: number; events: unknown[]; unavailable?: boolean; }; type SessionReplayManifestResponse = { recording: SessionRecordingSummary; chunks: Array<{ seq: number; checksum: string; byteLength: number; eventCount: number; startedAt: string | null; endedAt: string | null; bytesPath: string; }>; }; type SessionReplayPlaybackResponse = { recording: SessionRecordingSummary; chunks: ReplayChunkEvents[]; eventCount: number; truncated: boolean; unavailableChunks: number; loadedChunks: number; totalChunks: number; loadedBytes: number; totalBytes: number; isComplete: boolean; }; type AnyReplayEvent = Record; type AnyRecord = Record; type ReplayPlayerStatus = "idle" | "loading" | "ready" | "error"; type ReplayMarker = { id: string; offsetMs: number; timestamp: number; kind: "navigation" | "input" | "click" | "console" | "custom"; label: string; detail?: string; severity?: "info" | "warn" | "error"; fields?: Array<{ label: string; value: string }>; }; type SkipRange = { startMs: number; endMs: number; }; type ReplayViewportDimensions = { width: number; height: number; }; type ReplayViewportChange = ReplayViewportDimensions & { offsetMs: number; }; const DEFAULT_PLAYER_WIDTH = 1024; const DEFAULT_PLAYER_HEIGHT = 640; const DEFAULT_SPEED = 2; const SPEED_OPTIONS = [0.5, 1, 2, 4, 8]; const SKIP_STEP_MS = 5000; const MIN_IDLE_SKIP_MS = 8000; const IDLE_EDGE_PAD_MS = 1200; const REPLAY_CHUNK_BATCH_MAX_CHUNKS = 20; const REPLAY_CHUNK_BATCH_MAX_DECLARED_BYTES = 4 * 1024 * 1024; const REPLAY_CHUNK_BATCH_FETCH_CONCURRENCY = 3; const REPLAY_CHUNK_UNAVAILABLE_MESSAGE = "Session replay chunk is unavailable"; const SCROLL_MARKER_BURST_MS = 1_000; const DEFAULT_DEVTOOLS_HEIGHT = 220; const MIN_STAGE_HEIGHT_PX = 240; const SCRUBBER_MARKER_LIMIT = 500; const TIMELINE_MARKER_LIMIT = 300; const TIMELINE_FOLLOW_PAUSE_MS = 4000; const REPLAY_CLOCK_UPDATE_INTERVAL_MS = 100; /** * Keep captured overlays intact. Toasts and snackbars are product feedback, * not recorder chrome, and can be essential to understanding the session. * The recorder does not inject notification UI into the recorded document, so * blanket selectors (including Sonner's data attributes) are not justified. */ export const REPLAY_OVERLAY_STYLE_RULES: string[] = []; type ReplayConsoleDiagnostics = ReturnType< typeof extractReplayDiagnostics >["console"]; type ConsoleErrorSignaturePayload = { key: string; source: string; message: string; stack?: string; }; /** * Distill the resolvable error lines from a session's console diagnostics for * issue matching. Only error-level lines are worth sending: window errors, * unhandled rejections, and manual `captureException` all surface at `error` * level with a serialized `Name: message` (+ stack) that the server can * fingerprint back to a captured issue. Non-error console lines are left alone, * and anything without a captured issue simply comes back unmatched. */ function buildConsoleErrorSignatures( entries: ReplayConsoleDiagnostics, ): ConsoleErrorSignaturePayload[] { const signatures: ConsoleErrorSignaturePayload[] = []; for (const entry of entries) { if (entry.level !== "error" || !entry.message) continue; signatures.push({ key: entry.id, source: entry.source, message: entry.message, ...(entry.stack ? { stack: entry.stack } : {}), }); if (signatures.length >= 100) break; } return signatures; } const RRWEB_EVENT_TYPE = { FullSnapshot: 2, IncrementalSnapshot: 3, Meta: 4, Custom: 5, } as const; const INCREMENTAL_SOURCE = { Mutation: 0, MouseMove: 1, MouseInteraction: 2, Scroll: 3, ViewportResize: 4, Input: 5, TouchMove: 6, Drag: 12, } as const; const MOUSE_INTERACTION = { MouseUp: 0, MouseDown: 1, Click: 2, ContextMenu: 3, DblClick: 4, Focus: 5, TouchStart: 7, TouchEnd: 9, } as const; export default function SessionDetailPage() { const t = useT(); const { recordingId = "" } = useParams(); const [searchParams] = useSearchParams(); const { codeRequiredDialog } = useSendToAgentChat(); const { data, isLoading, error } = useSessionReplayPlayback(recordingId); const recording = data?.recording; const initialSeekMs = useMemo(() => { const raw = searchParams.get("atMs"); if (!raw || !/^\d+$/.test(raw)) return 0; return Number(raw); }, [searchParams]); return (
{codeRequiredDialog}
{recording ? (
{recording.app || recording.template || t("sessions.unknownApp")}{" "} · {formatDuration(recording.durationMs)} ·{" "} {t("sessions.eventCountCompact", { count: formatNumber(recording.eventCount), })}{" "} · {visitorLabel(recording, t)}
) : null}
{recording ? (
) : null}
{error ? ( {t("sessions.loadFailed", { message: error instanceof Error ? error.message : String(error), })} ) : isLoading ? ( ) : data && recording ? (
) : null}
); } function CopySessionForAgentButton({ recordingId }: { recordingId: string }) { const t = useT(); const [copied, setCopied] = useState(false); const createLink = useActionMutation( "create-session-replay-agent-link" as any, ); async function handleCopy() { if (createLink.isPending) return; const result = (await createLink.mutateAsync({ recordingId, })) as { url?: string }; if (!result?.url) return; await navigator.clipboard.writeText(result.url).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1400); } return ( {t("sessions.copyForAgentTooltip")} ); } function AskSessionPopover({ recording, }: { recording: SessionRecordingSummary; }) { const t = useT(); const [open, setOpen] = useState(false); const { send, isGenerating } = useSendToAgentChat(); function handleSubmit(text: string) { const trimmed = text.trim(); if (!trimmed || isGenerating) return; send({ message: trimmed, context: `The user is looking at session replay recording ${recording.id} on /sessions/${recording.id}. ` + `Use get-session-replay-summary first, and only use bounded get-session-replay-events if the user asks about timeline details. ` + `Keep raw rrweb JSON out of the answer; summarize the session, friction, errors, rage clicks, navigation, and next investigative steps.`, submit: true, }); setOpen(false); } return ( {t("sessions.askAgentTooltip")}

{t("sessions.askSessionTitle")}

{t("sessions.askSessionDescription")}

); } function ReplayWorkbench({ response, initialSeekMs, }: { response: SessionReplayPlaybackResponse; initialSeekMs: number; }) { const events = useReplayEvents(response); const markers = useMemo(() => buildReplayMarkers(events), [events]); const [currentTime, setCurrentTime] = useState(0); const [activeMarkerId, setActiveMarkerId] = useState(null); const seekRef = useRef<(ms: number, autoplay?: boolean) => void>(() => {}); const registerSeek = useCallback( (seek: (ms: number, autoplay?: boolean) => void) => { seekRef.current = seek; }, [], ); useEffect(() => { let active: ReplayMarker | null = null; for (const marker of markers) { if (marker.offsetMs <= currentTime + 250) active = marker; else break; } setActiveMarkerId(active?.id ?? null); }, [currentTime, markers]); return (
seekRef.current(ms, true)} />
); } function ReplayPlayer({ events, markers, response, initialSeekMs, onTimeUpdate, registerSeek, }: { events: AnyReplayEvent[]; markers: ReplayMarker[]; response: SessionReplayPlaybackResponse; initialSeekMs: number; onTimeUpdate: (ms: number) => void; registerSeek: (seek: (ms: number, autoplay?: boolean) => void) => void; }) { const t = useT(); const stageAreaRef = useRef(null); const stageRootRef = useRef(null); const replayerRef = useRef(null); const rafRef = useRef(null); const lastClockUpdateAtRef = useRef(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [totalTime, setTotalTime] = useState(0); const [speed, setSpeed] = useState(DEFAULT_SPEED); const [skipInactive, setSkipInactive] = useState(true); const [devToolsOpen, setDevToolsOpen] = useState(false); const [devToolsHeight, setDevToolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT); const [maxDevToolsHeight, setMaxDevToolsHeight] = useState(420); const playerShellRef = useRef(null); const [streamedDims, setStreamedDims] = useState<{ width: number; height: number; } | null>(null); const [fitScale, setFitScale] = useState(1); const initialDims = useMemo( () => replayInitialViewportDimensions(events), [events], ); const viewportTimeline = useMemo( () => buildReplayViewportTimeline(events), [events], ); const eventsRef = useLiveRef(events); const viewportTimelineRef = useLiveRef(viewportTimeline); const streamedDimsRef = useLiveRef(streamedDims); // Stable identity for the loaded event set so progressive chunk publishes // that only grow the array do not tear down a healthy Replayer mid-playback. const eventsIdentity = useMemo( () => `${events.length}:${Number(events[0]?.timestamp ?? 0)}:${Number( events[events.length - 1]?.timestamp ?? 0, )}`, [events], ); const scrubbingRef = useRef(false); const scrubResumePlayingRef = useRef(false); // Fall back to the default player size only when the viewport is unknown; // otherwise render the raw recorded dimensions untouched. See the Replayer // construction below for why "recovery" heuristics were removed here. const displayDims = resolveReplayDisplayDimensions( streamedDims ?? initialDims, ); const playerWidth = displayDims.width; const playerHeight = displayDims.height; const skipRanges = useMemo(() => buildIdleSkipRanges(events), [events]); const skipRangesRef = useLiveRef(skipRanges); const skipInactiveRef = useLiveRef(skipInactive); const currentTimeRef = useLiveRef(currentTime); const playingRef = useLiveRef(playing); const speedRef = useLiveRef(speed); const currentUrl = useMemo( () => currentUrlAt(events, currentTime), [events, currentTime], ); const diagnostics = useMemo(() => extractReplayDiagnostics(events), [events]); const devToolsIssueCount = replayDevToolsIssueCount( diagnostics, response.isComplete, ); // Resolve captured console errors in this replay to their Sentry-style issue // groups (one batched, access-scoped call, server-computed fingerprints) so // each error can deep-link to its full issue detail. Only runs once devtools // are open and there is at least one error line worth looking up. const errorSignatures = useMemo( () => buildConsoleErrorSignatures(diagnostics.console), [diagnostics.console], ); const errorSignaturesKey = useMemo( () => JSON.stringify(errorSignatures), [errorSignatures], ); const recordingId = response.recording.id; const issueMatchQuery = useQuery({ queryKey: ["match-error-issues", recordingId, errorSignaturesKey], queryFn: () => callAction>( "match-error-issues", { signatures: errorSignatures }, { method: "POST" }, ), enabled: devToolsOpen && errorSignatures.length > 0, staleTime: 60_000, }); const issueMatches = useMemo(() => { const map = new Map(); const data = issueMatchQuery.data; if (data) { for (const [key, value] of Object.entries(data)) map.set(key, value); } return map; }, [issueMatchQuery.data]); const loadingPercent = response.totalChunks > 0 ? clamp(response.loadedChunks / response.totalChunks, 0, 1) : response.isComplete ? 1 : 0; useEffect(() => { const el = stageAreaRef.current; if (!el) return; const update = () => { const next = Math.min( 1, el.clientWidth / playerWidth, el.clientHeight / playerHeight, ); setFitScale(Number.isFinite(next) && next > 0 ? next : 1); }; update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, [playerHeight, playerWidth]); // Keep Dev Tools from eating the stage. On short viewports the panel used to // shrink the replay area into a ribbon even when Meta dimensions were fine. useEffect(() => { const el = playerShellRef.current; if (!el) return; const update = () => { const chromeBudget = 140; const available = Math.max( 160, el.clientHeight - MIN_STAGE_HEIGHT_PX - chromeBudget, ); setMaxDevToolsHeight(available); setDevToolsHeight((current) => Math.min(current, available)); }; update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, [devToolsOpen]); const updateTime = useCallback( (next: number) => { if (!Number.isFinite(next) || next === currentTimeRef.current) return; // Keep the live value in sync before React commits. The animation clock // can run again while React is still processing the previous render; // relying on useLiveRef's effect here republishes the same value and can // create a nested update loop in development. currentTimeRef.current = next; setCurrentTime(next); onTimeUpdate(next); }, [currentTimeRef, onTimeUpdate], ); const seek = useCallback( (ms: number, autoplay = playingRef.current) => { const replayer = replayerRef.current; if (!replayer || status !== "ready") return; const clamped = clamp(ms, 0, Math.max(totalTime, 0)); try { if (autoplay) { replayer.play(clamped); setPlaying(true); } else { replayer.pause(clamped); setPlaying(false); } } catch (seekError) { console.warn("[session-replay] seek failed", seekError); return; } const seekDims = replayViewportDimensionsAtTime( viewportTimelineRef.current, clamped, ); if (seekDims) { // rrweb 2.1 does not reliably re-emit its resize event when seeking // backwards, so mirror the raw viewport-timeline lookup onto both // rrweb's iframe and the outer stage state to keep them in sync. const currentDims = streamedDimsRef.current; if ( currentDims?.width !== seekDims.width || currentDims?.height !== seekDims.height ) { replayer.handleResize?.(seekDims); setStreamedDims(seekDims); } } updateTime(clamped); }, [ playingRef, status, streamedDimsRef, totalTime, updateTime, viewportTimelineRef, ], ); const beginScrub = useCallback( (ms: number) => { if (!scrubbingRef.current) { scrubResumePlayingRef.current = playingRef.current; } scrubbingRef.current = true; seek(ms, false); }, [playingRef, seek], ); const endScrub = useCallback( (ms: number) => { const resume = scrubResumePlayingRef.current; scrubbingRef.current = false; scrubResumePlayingRef.current = false; seek(ms, resume); }, [seek], ); useEffect(() => { registerSeek(seek); }, [registerSeek, seek]); useEffect(() => { if (!stageRootRef.current) return; let cancelled = false; let localReplayer: any = null; let stopCursorVisibilityObserver = () => {}; async function loadReplay() { const replayEvents = eventsRef.current; // Replayer construction remains all-or-nothing. Progressive response // publishes update loading progress only; rebuilding during download // rewinds the player and can desync the scrubber/playhead. if (!response.isComplete) { setStatus("loading"); setError(null); setPlaying(false); return; } if (replayEvents.length < 2 || !hasPlayableReplayEvents(replayEvents)) { throw new Error(t("sessions.noReplayEvents")); } setStatus("loading"); setError(null); await import("@rrweb/replay/dist/style.css"); const { Replayer } = await import("@rrweb/replay"); if (cancelled || !stageRootRef.current) return; stageRootRef.current.innerHTML = ""; // Viewport and pointer events must pass through to rrweb untouched. // The 2026-07 "ultra-wide replay" bugs (e.g. a stored 1,152px-wide // recording rendering as a 4,491px Meta width) were caused by demo // mode's fetch redaction faking numbers >= 1000 in raw replay JSON at // view time, not by malformed stored geometry — fixed in // packages/core/src/demo/fetch-interceptor.ts. Stored recordings were // always sane, so never add viewport "recovery" heuristics here; they // can only corrupt genuine recordings (e.g. a real ultrawide window). setStreamedDims(replayInitialViewportDimensions(replayEvents)); // Keep this loosely typed: our internal AnyReplayEvent shape doesn't // exactly match rrweb's declared eventWithTime type. localReplayer = new Replayer(replayEvents as any[], { root: stageRootRef.current, speed: speedRef.current, skipInactive: false, showWarning: false, showDebug: false, mouseTail: false, // Match stock rrweb/builder-internal focus replay. Disabling focus // drops recorded focus-visible state and can leave menus/forms looking // unlike the source page even when the snapshot CSS is correct. triggerFocus: true, insertStyleRules: REPLAY_OVERLAY_STYLE_RULES, }); // rrweb already sandboxes the replay document without script execution. // Do not mutate recorded URLs/CSS; suppress viewer-page referrer leakage // at the iframe boundary while retaining historical visual resources. localReplayer.iframe?.setAttribute?.("referrerpolicy", "no-referrer"); stopCursorVisibilityObserver = hideReplayCursorUntilPosition(localReplayer); replayerRef.current = localReplayer; const meta = localReplayer.getMetaData?.(); const total = Number(meta?.totalTime ?? replayDuration(replayEvents)); setTotalTime(Number.isFinite(total) ? total : 0); const startAt = clamp( initialSeekMs || currentTimeRef.current, 0, Number.isFinite(total) ? total : 0, ); localReplayer.on?.("finish", () => { setPlaying(false); const finalTime = Number(localReplayer.getCurrentTime?.() ?? total); updateTime(Number.isFinite(finalTime) ? finalTime : total); }); localReplayer.on?.("resize", (payload: unknown) => { const dims = payload as { width?: unknown; height?: unknown }; if ( typeof dims.width === "number" && typeof dims.height === "number" && Number.isFinite(dims.width) && Number.isFinite(dims.height) && dims.width > 0 && dims.height > 0 ) { const rawDims = { width: Math.round(dims.width), height: Math.round(dims.height), }; // rrweb owns its iframe geometry from raw Meta/ViewportResize // events. Only mirror the raw dims into React state here, for the // outer stage's fit-to-container scaling. const currentDims = streamedDimsRef.current; if ( currentDims?.width === rawDims.width && currentDims?.height === rawDims.height ) { return; } setStreamedDims(rawDims); } }); updateTime(startAt); setStatus("ready"); try { localReplayer.play?.(startAt); setPlaying(true); } catch (autoplayError) { console.warn("[session-replay] autoplay failed", autoplayError); try { localReplayer.pause?.(startAt); } catch { // Some rrweb versions only render after play; the first click still works. } setPlaying(false); } } void loadReplay().catch((loadError: any) => { if (cancelled) return; setError(loadError?.message || String(loadError)); setStatus("error"); setPlaying(false); }); return () => { cancelled = true; stopCursorVisibilityObserver(); if (rafRef.current != null) cancelAnimationFrame(rafRef.current); rafRef.current = null; try { localReplayer?.pause?.(); localReplayer?.destroy?.(); replayerRef.current?.pause?.(); replayerRef.current?.destroy?.(); } catch { // rrweb cleanup is best-effort across versions. } replayerRef.current = null; if (stageRootRef.current) stageRootRef.current.innerHTML = ""; }; }, [ currentTimeRef, eventsIdentity, eventsRef, initialSeekMs, response.isComplete, speedRef, t, updateTime, ]); useEffect(() => { if (!playing || status !== "ready") { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); rafRef.current = null; lastClockUpdateAtRef.current = null; return; } const tick = (frameTime: number) => { const replayer = replayerRef.current; if (replayer && !scrubbingRef.current) { let nextTime = Number( replayer.getCurrentTime?.() ?? currentTimeRef.current, ); if (skipInactiveRef.current) { const range = skipRangesRef.current.find( (candidate) => nextTime >= candidate.startMs && nextTime < candidate.endMs - 50, ); if (range) { try { replayer.play(range.endMs); nextTime = range.endMs; } catch (skipError) { console.warn( "[session-replay] skip inactivity failed", skipError, ); } } } if ( shouldPublishReplayClockUpdate( lastClockUpdateAtRef.current, frameTime, currentTimeRef.current, nextTime, ) ) { lastClockUpdateAtRef.current = frameTime; updateTime(nextTime); } } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); rafRef.current = null; lastClockUpdateAtRef.current = null; }; }, [ currentTimeRef, playing, skipInactiveRef, skipRangesRef, status, updateTime, ]); function togglePlay() { if (status !== "ready") return; const replayer = replayerRef.current; if (!replayer) return; if (playing) { try { replayer.pause(); } catch { // Ignore transient rrweb pause errors. } setPlaying(false); return; } const restart = totalTime > 0 && currentTime >= totalTime - 50; const startAt = restart ? 0 : currentTime; try { replayer.play(startAt); updateTime(startAt); setPlaying(true); } catch (playError) { setError( playError instanceof Error ? playError.message : String(playError), ); setStatus("error"); } } function updateSpeed(next: number) { setSpeed(next); try { replayerRef.current?.setConfig?.({ speed: next }); } catch { // Older rrweb builds may not expose setConfig; new sessions still use it. } } const disabled = status !== "ready"; return (
{currentUrl ? (
{currentUrl}
) : null}
seek(currentTime - SKIP_STEP_MS)} > seek(currentTime + SKIP_STEP_MS)} > {formatClock(currentTime)} {formatClock(totalTime)} updateSpeed(Number(value))} > {SPEED_OPTIONS.map((option) => ( {option}x ))} {skipInactive ? t("sessions.skipInactiveOn") : t("sessions.skipInactiveOff")} {response.isComplete ? ( {t("sessions.replayEventCount", { events: String(response.eventCount), })} {response.truncated ? ` ${t("sessions.truncated")}` : ""} ) : null}
{devToolsOpen ? ( seek(ms, true)} issueMatches={issueMatches} issueMatching={issueMatchQuery.isFetching} /> ) : null} {response.unavailableChunks > 0 ? (
{t("sessions.unavailableChunks", { count: String(response.unavailableChunks), })}
) : null}
); } function ReplayScrubber({ currentTime, totalTime, markers, skipRanges, skipInactive, disabled, onScrub, onScrubEnd, }: { currentTime: number; totalTime: number; markers: ReplayMarker[]; skipRanges: SkipRange[]; skipInactive: boolean; disabled: boolean; onScrub: (ms: number) => void; onScrubEnd: (ms: number) => void; }) { const t = useT(); const scrubberMarkers = useMemo( () => visibleScrubberMarkers(markers, totalTime), [markers, totalTime], ); function handleScrub(event: FormEvent) { onScrub(Number(event.currentTarget.value)); } return (
onScrubEnd(Number((event.target as HTMLInputElement).value)) } onKeyUp={(event) => onScrubEnd(Number((event.target as HTMLInputElement).value)) } aria-label={t("sessions.replayTimeline")} />
{skipRanges.map((range) => { const left = totalTime > 0 ? (range.startMs / totalTime) * 100 : 0; const width = totalTime > 0 ? ((range.endMs - range.startMs) / totalTime) * 100 : 0; return ( ); })}
{scrubberMarkers.map((marker) => { const left = totalTime > 0 ? (marker.offsetMs / totalTime) * 100 : 0; return ( ); })}
); } function ReplayIconButton({ label, disabled, onClick, children, }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode; }) { return ( {label} ); } function ReplayTimeline({ markers, isLoading, activeMarkerId, onSeek, }: { markers: ReplayMarker[]; isLoading: boolean; activeMarkerId: string | null; onSeek: (ms: number) => void; }) { const t = useT(); const [expandedMarkerId, setExpandedMarkerId] = useState(null); const [query, setQuery] = useState(""); const listRef = useRef(null); const lastManualScrollAtRef = useRef(0); const activeRowRef = useRef(null); const filteredMarkers = useMemo( () => filterReplayMarkers(markers, query).slice(0, TIMELINE_MARKER_LIMIT), [markers, query], ); useEffect(() => { if (!activeMarkerId) return; if (Date.now() - lastManualScrollAtRef.current < TIMELINE_FOLLOW_PAUSE_MS) { return; } const row = activeRowRef.current; const list = listRef.current; if (!row || !list) return; const rowTop = row.offsetTop; const rowBottom = rowTop + row.offsetHeight; if ( rowTop >= list.scrollTop && rowBottom <= list.scrollTop + list.clientHeight ) { return; } list.scrollTo({ top: Math.max(0, rowTop - list.clientHeight / 3), behavior: "smooth", }); }, [activeMarkerId, filteredMarkers]); return (
{t("sessions.timeline")}
{!isLoading || markers.length ? (
setQuery(event.target.value)} />
) : null}
{ lastManualScrollAtRef.current = Date.now(); }} onPointerDown={() => { lastManualScrollAtRef.current = Date.now(); }} > {filteredMarkers.length ? (
{filteredMarkers.map((marker) => { const expanded = marker.id === expandedMarkerId; const active = marker.id === activeMarkerId; return (
{expanded ? : null}
); })}
) : isLoading ? null : (
{query.trim() ? t("sessions.timelineNoMatches") : t("sessions.noTimelineEvents")}
)}
); } function MarkerIcon({ kind }: { kind: ReplayMarker["kind"] }) { if (kind === "navigation") return ; if (kind === "input") return ; if (kind === "click") return ; if (kind === "console") return ; return ; } function MarkerDetail({ marker }: { marker: ReplayMarker }) { return (
{marker.detail ? (

{marker.detail}

) : null} {marker.fields?.length ? (
{marker.fields.map((field) => (
{field.label}
{field.value}
))}
) : null}
); } function DetailSkeleton() { return (
); } function useSessionReplayPlayback(recordingId: string) { const agentAccessToken = currentSessionReplayAgentAccessToken(); const [state, setState] = useState<{ data: SessionReplayPlaybackResponse | null; isLoading: boolean; error: Error | null; }>({ data: null, isLoading: Boolean(recordingId), error: null, }); useEffect(() => { if (!recordingId) { setState({ data: null, isLoading: false, error: null }); return; } let cancelled = false; setState({ data: null, isLoading: true, error: null }); async function loadProgressively() { try { const manifest = await fetchReplayManifest(recordingId, { agentAccessToken, }); if (cancelled) return; const loadedChunks = new Array( manifest.chunks.length, ); let loadedCount = 0; let loadedBytes = 0; let unavailableChunks = 0; const chunkIndexBySeq = new Map( manifest.chunks.map((chunk, index) => [chunk.seq, index]), ); const publish = (force = false) => { const complete = loadedCount >= manifest.chunks.length; const chunks = loadedChunks.filter( (chunk): chunk is ReplayChunkEvents => Boolean(chunk), ); // Only hand events to the player once every chunk is in. Partial // publishes used to rebuild the Replayer mid-playback and break the // scrubber; the loading bar still updates while chunks stream in. const shouldPublishEvents = force || complete; if (shouldPublishEvents) { setState({ data: playbackResponseFromChunks(manifest, chunks, { isComplete: complete, loadedChunks: loadedCount, loadedBytes, unavailableChunks, }), isLoading: false, error: null, }); return; } setState((current) => ({ data: current.data ? { ...current.data, loadedChunks: loadedCount, loadedBytes, unavailableChunks, } : playbackResponseFromChunks(manifest, [], { isComplete: complete, loadedChunks: loadedCount, loadedBytes, unavailableChunks, }), // Keep the page shell mounted so the player loading bar can show // chunk progress; the Replayer itself still waits for isComplete. isLoading: false, error: null, })); }; publish(); await fetchReplayChunks( manifest.chunks, { agentAccessToken }, (batch) => { for (const chunk of batch) { const index = chunkIndexBySeq.get(chunk.seq); if (index === undefined || loadedChunks[index]) continue; loadedChunks[index] = chunk; loadedCount += 1; loadedBytes += manifest.chunks[index].byteLength; if (chunk.unavailable) unavailableChunks += 1; } if (!cancelled) publish(); }, ); if (!cancelled) publish(true); } catch (loadError) { if (cancelled) return; cancelled = true; setState({ data: null, isLoading: false, error: loadError instanceof Error ? loadError : new Error(String(loadError)), }); } } void loadProgressively(); return () => { cancelled = true; }; }, [agentAccessToken, recordingId]); return state; } interface FetchSessionReplayPlaybackOptions { agentAccessToken?: string; } export async function fetchSessionReplayPlayback( recordingId: string, options: FetchSessionReplayPlaybackOptions = {}, ): Promise { const manifest = await fetchReplayManifest(recordingId, options); const chunks = await fetchReplayChunks(manifest.chunks, options); const unavailableChunks = chunks.filter((chunk) => chunk.unavailable).length; const loadedBytes = manifest.chunks.reduce( (sum, chunk) => sum + chunk.byteLength, 0, ); return playbackResponseFromChunks(manifest, chunks, { isComplete: true, loadedChunks: chunks.length, loadedBytes, unavailableChunks, }); } function playbackResponseFromChunks( manifest: SessionReplayManifestResponse, chunks: ReplayChunkEvents[], progress: { isComplete: boolean; loadedChunks: number; loadedBytes: number; unavailableChunks: number; }, ): SessionReplayPlaybackResponse { const eventCount = chunks.reduce( (sum, chunk) => sum + chunk.events.length, 0, ); const totalBytes = manifest.chunks.reduce( (sum, chunk) => sum + chunk.byteLength, 0, ); return { recording: manifest.recording, chunks, eventCount, truncated: false, unavailableChunks: progress.unavailableChunks, loadedChunks: progress.loadedChunks, totalChunks: manifest.chunks.length, loadedBytes: progress.loadedBytes, totalBytes, isComplete: progress.isComplete, }; } async function fetchReplayManifest( recordingId: string, options: FetchSessionReplayPlaybackOptions, ): Promise { const response = await fetchReplayApi( `/api/session-replay/recordings/${encodeURIComponent( recordingId, )}/manifest`, options.agentAccessToken, ); if (!response.ok) throw await replayFetchError(response); return (await response.json()) as SessionReplayManifestResponse; } async function fetchReplayChunks( chunks: SessionReplayManifestResponse["chunks"], options: FetchSessionReplayPlaybackOptions, onBatch?: (chunks: ReplayChunkEvents[]) => void, ): Promise { const results = new Array(chunks.length); const indexBySeq = new Map(chunks.map((chunk, index) => [chunk.seq, index])); const batches = partitionReplayChunkBatches(chunks); let nextIndex = 0; async function worker() { while (nextIndex < batches.length) { const index = nextIndex; nextIndex += 1; const loaded = await fetchReplayChunkBatch(batches[index], options); for (const chunk of loaded) { const resultIndex = indexBySeq.get(chunk.seq); if (resultIndex !== undefined) results[resultIndex] = chunk; } onBatch?.(loaded); } } await Promise.all( Array.from( { length: Math.min(REPLAY_CHUNK_BATCH_FETCH_CONCURRENCY, batches.length), }, worker, ), ); return results; } export function partitionReplayChunkBatches( chunks: SessionReplayManifestResponse["chunks"], ): Array { const batches: Array = []; let batch: SessionReplayManifestResponse["chunks"] = []; let declaredBytes = 0; for (const chunk of chunks) { const exceedsChunkLimit = batch.length >= REPLAY_CHUNK_BATCH_MAX_CHUNKS; const exceedsByteLimit = batch.length > 0 && declaredBytes + chunk.byteLength > REPLAY_CHUNK_BATCH_MAX_DECLARED_BYTES; if (exceedsChunkLimit || exceedsByteLimit) { batches.push(batch); batch = []; declaredBytes = 0; } batch.push(chunk); declaredBytes += chunk.byteLength; } if (batch.length) batches.push(batch); return batches; } async function fetchReplayChunkBatch( chunks: SessionReplayManifestResponse["chunks"], options: FetchSessionReplayPlaybackOptions, ): Promise { const recordingId = replayRecordingIdFromChunkPath(chunks[0]?.bytesPath); if (!recordingId) { return Promise.all(chunks.map((chunk) => fetchReplayChunk(chunk, options))); } const query = new URLSearchParams({ seqs: chunks.map((chunk) => chunk.seq).join(","), }); const response = await fetchReplayApi( `/api/session-replay/recordings/${encodeURIComponent(recordingId)}/chunks?${query}`, options.agentAccessToken, ); if (!response.ok) { if (response.status === 404 || response.status === 405) { return Promise.all( chunks.map((chunk) => fetchReplayChunk(chunk, options)), ); } throw await replayFetchError(response); } const payload = (await response.json()) as { chunks?: unknown }; const returnedChunks = Array.isArray(payload?.chunks) ? payload.chunks.filter(isReplayChunkEvents) : []; const returnedBySeq = new Map( returnedChunks.map((chunk) => [chunk.seq, chunk]), ); return Promise.all( chunks.map(async (manifestChunk) => { const loaded = returnedBySeq.get(manifestChunk.seq); return loaded ?? fetchReplayChunk(manifestChunk, options); }), ); } function replayRecordingIdFromChunkPath(path: string | undefined): string { if (!path) return ""; const match = path.match(/\/recordings\/([^/]+)\/chunks\//); return match ? decodeURIComponent(match[1]) : ""; } function isReplayChunkEvents(value: unknown): value is ReplayChunkEvents { return ( isRecord(value) && typeof value.seq === "number" && typeof value.checksum === "string" && typeof value.byteLength === "number" && typeof value.eventCount === "number" && Array.isArray(value.events) ); } async function fetchReplayChunk( chunk: SessionReplayManifestResponse["chunks"][number], options: FetchSessionReplayPlaybackOptions, ): Promise { const response = await fetchReplayApi( chunk.bytesPath, options.agentAccessToken, ); if (!response.ok) { const error = await replayFetchError(response); if (isUnavailableReplayChunk(response, error)) { return replayUnavailableChunk(chunk); } throw error; } const payload = await response.json(); return { seq: chunk.seq, checksum: chunk.checksum, byteLength: chunk.byteLength, eventCount: chunk.eventCount, events: replayPayloadEvents(payload), }; } function isUnavailableReplayChunk(response: Response, error: Error): boolean { return ( response.status === 404 && error.message === REPLAY_CHUNK_UNAVAILABLE_MESSAGE ); } function replayUnavailableChunk( chunk: SessionReplayManifestResponse["chunks"][number], ): ReplayChunkEvents { return { seq: chunk.seq, checksum: chunk.checksum, byteLength: chunk.byteLength, eventCount: chunk.eventCount, events: [], unavailable: true, }; } function currentSessionReplayAgentAccessToken(): string { const browserSearch = globalThis.window?.location?.search ?? globalThis.location?.search ?? ""; return ( new URLSearchParams(browserSearch).get(SESSION_REPLAY_AGENT_ACCESS_PARAM) ?? "" ); } async function fetchReplayApi( path: string, explicitAgentAccessToken?: string, ): Promise { const token = await getIdToken(); const browserOrigin = globalThis.window?.location?.origin ?? globalThis.location?.origin ?? "http://localhost"; const url = new URL(appApiPath(path), browserOrigin); const agentAccessToken = explicitAgentAccessToken ?? currentSessionReplayAgentAccessToken(); if (agentAccessToken) { url.searchParams.set(SESSION_REPLAY_AGENT_ACCESS_PARAM, agentAccessToken); } return fetch(`${url.pathname}${url.search}${url.hash}`, { headers: { Accept: "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }); } async function replayFetchError(response: Response): Promise { try { const payload = await response.json(); if (isRecord(payload) && typeof payload.error === "string") { return new Error(payload.error); } } catch { // Fall through to the status text. } return new Error(response.statusText || `HTTP ${response.status}`); } export function replayPayloadEvents(payload: unknown): unknown[] { if (Array.isArray(payload)) return payload; if (isRecord(payload) && Array.isArray(payload.events)) { return payload.events; } return payload ? [payload] : []; } function useReplayEvents( response: SessionReplayPlaybackResponse, ): AnyReplayEvent[] { return useMemo( () => normalizeReplayEvents( response.chunks .flatMap((chunk) => chunk.events) .filter((event) => event && typeof event === "object"), ), [response.chunks], ); } /** * Stock rrweb consumes the recorded event objects directly. Playback-time URL * or CSS rewriting breaks snapshots, responsive rules, fonts, and navigation * metadata, so filtering invalid container entries is the only normalization. * * IMPORTANT — DO NOT add URL/CSS sanitization here. A previous security review * changed href/src/_cssText to about:blank and immediately regressed every * historical recording that depended on captured styles. Network/privacy * controls belong at capture or the sandbox boundary, never in rrweb events. */ export function normalizeReplayEvents(events: unknown[]): AnyReplayEvent[] { return events .filter((event): event is AnyReplayEvent => isRecord(event)) .sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0)); } export function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] { const startedAt = replayStartedAt(events); const markers: ReplayMarker[] = []; for (const event of events) { const timestamp = Number(event.timestamp ?? 0); if (!Number.isFinite(timestamp) || timestamp <= 0) continue; if ( event.type === RRWEB_EVENT_TYPE.Meta && typeof event.data?.href === "string" ) { const href = event.data.href; markers.push({ id: `nav-${timestamp}-${markers.length}`, timestamp, offsetMs: Math.max(0, timestamp - startedAt), kind: "navigation", label: "Navigate", detail: href, fields: [{ label: "URL", value: href }], }); } else if ( event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot && event.data?.source === INCREMENTAL_SOURCE.Input ) { const inputValue = replayInputValue(event.data); markers.push({ id: `input-${timestamp}-${markers.length}`, timestamp, offsetMs: Math.max(0, timestamp - startedAt), kind: "input", label: inputValue ? "Input changed" : "Input", detail: inputValue, fields: markerFields([ ["Element id", event.data?.id], ["Value", inputValue], ]), }); } else if ( event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot && event.data?.source === INCREMENTAL_SOURCE.MouseInteraction && (event.data?.type === MOUSE_INTERACTION.Click || event.data?.type === MOUSE_INTERACTION.DblClick || event.data?.type === MOUSE_INTERACTION.Focus) ) { markers.push({ id: `click-${timestamp}-${markers.length}`, timestamp, offsetMs: Math.max(0, timestamp - startedAt), kind: "click", label: mouseInteractionLabel(event.data?.type), detail: pointerDetail(event.data), fields: markerFields([ ["Element id", event.data?.id], ["X", event.data?.x], ["Y", event.data?.y], ]), }); } else if ( event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot && event.data?.source === INCREMENTAL_SOURCE.Scroll ) { markers.push({ id: `scroll-${timestamp}-${markers.length}`, timestamp, offsetMs: Math.max(0, timestamp - startedAt), kind: "custom", label: "Scroll", detail: pointerDetail(event.data), fields: markerFields([ ["Element id", event.data?.id], ["X", event.data?.x], ["Y", event.data?.y], ]), }); } else if (event.type === RRWEB_EVENT_TYPE.Custom) { const marker = customReplayMarker( event, timestamp, startedAt, markers.length, ); if (marker) markers.push(marker); } } return collapseScrollMarkerBursts( markers.sort((a, b) => a.offsetMs - b.offsetMs), ); } function collapseScrollMarkerBursts(markers: ReplayMarker[]): ReplayMarker[] { const collapsed: ReplayMarker[] = []; const lastScrollByTarget = new Map< string, { index: number; timestamp: number } >(); for (const marker of markers) { if (marker.label !== "Scroll") { collapsed.push(marker); continue; } const target = marker.fields?.find((field) => field.label === "Element id")?.value ?? "viewport"; const previous = lastScrollByTarget.get(target); if ( previous && marker.timestamp - previous.timestamp <= SCROLL_MARKER_BURST_MS ) { // Keep one marker per continuous scroll gesture, using its final // position while retaining the first marker's stable id/time. collapsed[previous.index] = { ...marker, id: collapsed[previous.index].id, timestamp: collapsed[previous.index].timestamp, offsetMs: collapsed[previous.index].offsetMs, }; previous.timestamp = marker.timestamp; continue; } const index = collapsed.push(marker) - 1; lastScrollByTarget.set(target, { index, timestamp: marker.timestamp }); } return collapsed; } export function replayDevToolsIssueCount( diagnostics: ReplayDevToolsDiagnostics, isComplete: boolean, ): number { if (!isComplete) return 0; return diagnostics.consoleErrorCount + diagnostics.networkFailedCount; } function customReplayMarker( event: AnyReplayEvent, timestamp: number, startedAt: number, index: number, ): ReplayMarker | null { const tag = String(event.data?.tag ?? "Custom event"); const payload = isRecord(event.data?.payload) ? event.data.payload : {}; const offsetMs = Math.max(0, timestamp - startedAt); if (tag === SESSION_REPLAY_CONSOLE_EVENT_TAG) { const level = typeof payload.level === "string" ? payload.level : "log"; if (level !== "error" && level !== "warn") return null; const message = typeof payload.message === "string" ? payload.message : undefined; return { id: `console-${timestamp}-${index}`, timestamp, offsetMs, kind: "console", label: level === "error" ? "Console error" : `Console ${level}`, detail: message, severity: level === "error" ? "error" : level === "warn" ? "warn" : "info", fields: markerFields([ ["Level", level], ["Source", payload.source], ["Message", message], ["URL", payload.url], ["Stack", payload.stack], ]), }; } if (tag === SESSION_REPLAY_NETWORK_EVENT_TAG) { const status = Number(payload.status ?? 0); if ( payload.ok !== false && (!Number.isFinite(status) || !isFailedSessionReplayNetworkStatus(status)) ) { return null; } const method = typeof payload.method === "string" ? payload.method : undefined; const url = typeof payload.url === "string" ? payload.url : undefined; const error = typeof payload.error === "string" ? payload.error : undefined; return { id: `network-${timestamp}-${index}`, timestamp, offsetMs, kind: "custom", label: "Network error", // i18n-ignore: timeline protocol label. detail: [method, url].filter(Boolean).join(" ") || error, severity: "error", fields: markerFields([ ["Method", method], ["URL", url], ["Status", Number.isFinite(status) ? status : undefined], ["Error", error], ["Duration", payload.durationMs], ]), }; } return { id: `custom-${timestamp}-${index}`, timestamp, offsetMs, kind: "custom", label: tag, detail: typeof payload.message === "string" ? payload.message : undefined, }; } export function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] { const startedAt = replayStartedAt(events); const interactions: number[] = []; let lastTimestamp = startedAt; for (const event of events) { const timestamp = Number(event.timestamp ?? 0); if (!Number.isFinite(timestamp) || timestamp <= 0) continue; lastTimestamp = Math.max(lastTimestamp, timestamp); interactions.push(...replayActivityTimestamps(event, timestamp)); } interactions.sort((a, b) => a - b); const ranges: SkipRange[] = []; for (let index = 1; index < interactions.length; index += 1) { pushIdleRange( ranges, interactions[index - 1], interactions[index], startedAt, ); } if (interactions.length) { pushIdleRange( ranges, interactions[interactions.length - 1], lastTimestamp, startedAt, ); } return ranges; } function replayActivityTimestamps( event: AnyReplayEvent, timestamp: number, ): number[] { if (event.type === RRWEB_EVENT_TYPE.Meta) return [timestamp]; if (event.type !== RRWEB_EVENT_TYPE.IncrementalSnapshot) return []; const source = event.data?.source; if ( source === INCREMENTAL_SOURCE.MouseMove || source === INCREMENTAL_SOURCE.TouchMove || source === INCREMENTAL_SOURCE.Drag ) { const positions = Array.isArray(event.data?.positions) ? event.data.positions : []; // IMPORTANT: rrweb batches pointer positions and schedules each one at // event.timestamp + timeOffset. Treat those exact moments as activity. // Ignoring the batch made Skip inactivity jump across visible movement, // which looked like a frozen cursor even though the recording was intact. return positions.flatMap((position: unknown) => { if (!isRecord(position)) return []; const timeOffset = Number(position.timeOffset ?? 0); return [timestamp + (Number.isFinite(timeOffset) ? timeOffset : 0)]; }); } if ( source === INCREMENTAL_SOURCE.Input || source === INCREMENTAL_SOURCE.Scroll ) { return [timestamp]; } if (source !== INCREMENTAL_SOURCE.MouseInteraction) return []; const interactionType = event.data?.type; return [ interactionType === MOUSE_INTERACTION.MouseUp || interactionType === MOUSE_INTERACTION.MouseDown || interactionType === MOUSE_INTERACTION.Click || interactionType === MOUSE_INTERACTION.ContextMenu || interactionType === MOUSE_INTERACTION.DblClick || interactionType === MOUSE_INTERACTION.Focus || interactionType === MOUSE_INTERACTION.TouchStart || interactionType === MOUSE_INTERACTION.TouchEnd ? timestamp : null, ].filter((value): value is number => value !== null); } function pushIdleRange( ranges: SkipRange[], fromTs: number, toTs: number, startedAt: number, ) { if (toTs - fromTs < MIN_IDLE_SKIP_MS) return; const startMs = Math.max(0, fromTs - startedAt + IDLE_EDGE_PAD_MS); const endMs = Math.max(0, toTs - startedAt - IDLE_EDGE_PAD_MS); if (endMs - startMs >= MIN_IDLE_SKIP_MS - IDLE_EDGE_PAD_MS * 2) { ranges.push({ startMs, endMs }); } } function currentUrlAt(events: AnyReplayEvent[], currentTime: number): string { const startedAt = replayStartedAt(events); let current = ""; for (const event of events) { if (event.type !== RRWEB_EVENT_TYPE.Meta) continue; if (typeof event.data?.href !== "string") continue; const offset = Number(event.timestamp ?? 0) - startedAt; if (offset <= currentTime + 50) current = event.data.href; else break; } return current; } function hasPlayableReplayEvents(events: unknown[]): boolean { let hasFullSnapshot = false; let hasMeta = false; for (const event of events) { if (!isRecord(event)) continue; if (event.type === RRWEB_EVENT_TYPE.FullSnapshot) hasFullSnapshot = true; if (event.type === RRWEB_EVENT_TYPE.Meta) hasMeta = true; if (hasFullSnapshot && hasMeta) return true; } return false; } function hideReplayCursorUntilPosition(replayer: any): () => void { const cursor = replayer?.mouse as HTMLElement | undefined; if (!cursor || typeof MutationObserver === "undefined") return () => {}; let observer: MutationObserver | null = null; const revealWhenPositioned = () => { if (!cursor.style.left || !cursor.style.top) return; cursor.classList.add("has-position"); observer?.disconnect(); observer = null; }; cursor.classList.remove("has-position"); observer = new MutationObserver(revealWhenPositioned); observer.observe(cursor, { attributes: true, attributeFilter: ["style"], }); revealWhenPositioned(); return () => { observer?.disconnect(); observer = null; }; } export function replayViewportDimensions( events: AnyReplayEvent[], ): ReplayViewportDimensions | null { // Latest Meta / ViewportResize for CSS fit-to-stage only. Never rewrite these // into the event stream — rrweb must keep Meta in sync with the FullSnapshot. let best: ReplayViewportDimensions | null = null; for (const event of events) { const dims = dimensionsFromReplayEvent(event); if (dims) best = dims; } return best; } export function replayInitialViewportDimensions( events: AnyReplayEvent[], ): ReplayViewportDimensions | null { // rrweb itself initializes from the first Meta event. A resize can appear // earlier in chunk order after reconnect/flush boundaries, but it must not // replace the snapshot's native starting viewport. for (const event of events) { if (event.type !== RRWEB_EVENT_TYPE.Meta) continue; const dims = dimensionsFromReplayEvent(event); if (dims) return dims; } for (const event of events) { const dims = dimensionsFromReplayEvent(event); if (dims) return dims; } return null; } export function buildReplayViewportTimeline( events: AnyReplayEvent[], ): ReplayViewportChange[] { const initial = replayInitialViewportDimensions(events); if (!initial) return []; const startedAt = replayStartedAt(events); const firstMetaTimestamp = events.reduce((best, event) => { if (event.type !== RRWEB_EVENT_TYPE.Meta) return best; const timestamp = Number(event.timestamp ?? 0); return Number.isFinite(timestamp) && timestamp > 0 ? Math.min(best, timestamp) : best; }, Number.POSITIVE_INFINITY); const changes: ReplayViewportChange[] = [{ ...initial, offsetMs: 0 }]; for (const event of events) { const timestamp = Number(event.timestamp ?? 0); if (!Number.isFinite(timestamp) || timestamp <= firstMetaTimestamp) continue; const dims = dimensionsFromReplayEvent(event); if (!dims) continue; const previous = changes[changes.length - 1]; if (previous?.width === dims.width && previous.height === dims.height) { continue; } changes.push({ ...dims, offsetMs: Math.max(0, timestamp - startedAt), }); } return changes; } export function replayViewportDimensionsAtTime( changes: ReplayViewportChange[], elapsedMs: number, ): ReplayViewportDimensions | null { if (changes.length === 0) return null; const target = Math.max(0, elapsedMs); let low = 0; let high = changes.length - 1; while (low < high) { const middle = Math.ceil((low + high) / 2); if ((changes[middle]?.offsetMs ?? 0) <= target) low = middle; else high = middle - 1; } const match = changes[low]; return match ? { width: match.width, height: match.height } : null; } function dimensionsFromReplayEvent( event: AnyReplayEvent, ): ReplayViewportDimensions | null { if (event.type === RRWEB_EVENT_TYPE.Meta) { return normalizeReplayDimensions(event.data?.width, event.data?.height); } if ( event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot && event.data?.source === INCREMENTAL_SOURCE.ViewportResize ) { return normalizeReplayDimensions(event.data?.width, event.data?.height); } return null; } /** Read raw positive finite dimensions; no aspect clamping. */ export function normalizeReplayDimensions( width: unknown, height: unknown, ): ReplayViewportDimensions | null { if ( typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 ) { return null; } return { width: Math.round(width), height: Math.round(height), }; } /** * Fall back to the default player size only when the viewport is entirely * unknown (no Meta/ViewportResize event yet). This is not a "recovery" * heuristic for real recorded geometry — it never rewrites valid dims, no * matter how wide, narrow, or unusual the aspect ratio. See the Replayer * construction in ReplayPlayer for why viewport "correction" was removed: * the 2026-07 ultra-wide replay bugs were caused by demo mode's fetch * redaction faking numbers at view time, not by malformed stored geometry. */ export function resolveReplayDisplayDimensions( dims: ReplayViewportDimensions | null, ): ReplayViewportDimensions { if ( dims && Number.isFinite(dims.width) && Number.isFinite(dims.height) && dims.width > 0 && dims.height > 0 ) { return dims; } return { width: DEFAULT_PLAYER_WIDTH, height: DEFAULT_PLAYER_HEIGHT }; } export function filterReplayMarkers( markers: ReplayMarker[], query: string, ): ReplayMarker[] { const needle = query.trim().toLowerCase(); if (!needle) return markers; return markers.filter((marker) => { const haystack = [ marker.label, marker.detail, marker.kind, marker.severity, ...(marker.fields?.flatMap((field) => [field.label, field.value]) ?? []), ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(needle); }); } function replayStartedAt(events: AnyReplayEvent[]): number { let startedAt = Number.POSITIVE_INFINITY; for (const event of events) { const timestamp = Number(event.timestamp); if (Number.isFinite(timestamp) && timestamp > 0) { startedAt = Math.min(startedAt, timestamp); } } return Number.isFinite(startedAt) ? startedAt : 0; } function replayDuration(events: AnyReplayEvent[]): number { const startedAt = replayStartedAt(events); let endedAt = startedAt; for (const event of events) { endedAt = Math.max(endedAt, Number(event.timestamp ?? 0)); } return Math.max(0, endedAt - startedAt); } function replayInputValue(data: AnyRecord): string | undefined { if (typeof data.text === "string" && data.text) return data.text; if (typeof data.value === "string" && data.value) return data.value; if (typeof data.isChecked === "boolean") { return data.isChecked ? "Checked" : "Unchecked"; } return undefined; } function mouseInteractionLabel(type: unknown): string { if (type === MOUSE_INTERACTION.DblClick) return "Double click"; if (type === MOUSE_INTERACTION.Focus) return "Focus"; return "Click"; } function pointerDetail(data: AnyRecord): string | undefined { const x = Number(data.x); const y = Number(data.y); if (Number.isFinite(x) && Number.isFinite(y)) { return `x ${Math.round(x)}, y ${Math.round(y)}`; } return undefined; } function markerFields( entries: Array<[string, unknown]>, ): Array<{ label: string; value: string }> | undefined { const fields = entries.flatMap(([label, value]) => { if (value === undefined || value === null || value === "") return []; return [{ label, value: String(value) }]; }); return fields.length ? fields : undefined; } function visibleScrubberMarkers( markers: ReplayMarker[], totalTime: number, ): ReplayMarker[] { if (markers.length <= SCRUBBER_MARKER_LIMIT || totalTime <= 0) return markers; const bucketMs = Math.max(1, totalTime / SCRUBBER_MARKER_LIMIT); const buckets = new Map(); for (const marker of markers) { const bucket = Math.floor(marker.offsetMs / bucketMs); const current = buckets.get(bucket); if (!current || markerPriority(marker) > markerPriority(current)) { buckets.set(bucket, marker); } } return Array.from(buckets.values()).sort((a, b) => a.offsetMs - b.offsetMs); } function markerPriority(marker: ReplayMarker): number { if (marker.severity === "error") return 6; if (marker.kind === "navigation") return 5; if (marker.kind === "click") return 4; if (marker.severity === "warn") return 3; if (marker.kind === "input") return 2; return 1; } function visitorLabel( recording: SessionRecordingSummary, t: ReturnType, ): string { const email = emailLike(recording.userId) || emailLike(recording.userKey); if (email) return email; return ( recording.userId || recording.userKey || recording.anonymousId || t("sessions.anonymous") ); } function emailLike(value: string | null): string | null { if (!value?.includes("@")) return null; return value; } function formatDuration(ms: number | null): string { if (!ms || !Number.isFinite(ms) || ms <= 0) return "0s"; const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m`; } function formatClock(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds .toString() .padStart(2, "0")}`; } return `${minutes}:${seconds.toString().padStart(2, "0")}`; } function formatNumber(value: number): string { return new Intl.NumberFormat().format(value); } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(max, Math.max(min, value)); } export function shouldPublishReplayClockUpdate( lastUpdateAt: number | null, frameTime: number, currentTime: number, nextTime: number, ): boolean { if (!Number.isFinite(frameTime) || !Number.isFinite(nextTime)) return false; if (nextTime === currentTime) return false; return ( lastUpdateAt == null || frameTime - lastUpdateAt >= REPLAY_CLOCK_UPDATE_INTERVAL_MS ); } function isRecord(value: unknown): value is AnyRecord { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function useLiveRef(value: T) { const ref = useRef(value); useEffect(() => { ref.current = value; }, [value]); return ref; }