diff --git a/apps/api/src/utils/ids.ts b/apps/api/src/utils/ids.ts index 3944df6e..2c5c74dd 100644 --- a/apps/api/src/utils/ids.ts +++ b/apps/api/src/utils/ids.ts @@ -92,7 +92,7 @@ async function getDeviceIdFromSession({ * Deterministic session id for (projectId, deviceId) within a time window, * with a grace period at the *start* of each window to avoid boundary splits. * - * - windowMs: 5 minutes by default + * - windowMs: 30 minutes by default * - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket) * - Output: base64url, 128-bit (16 bytes) truncated from SHA-256 */ diff --git a/apps/start/src/components/events/table/columns.tsx b/apps/start/src/components/events/table/columns.tsx index 4e58244e..203d4f16 100644 --- a/apps/start/src/components/events/table/columns.tsx +++ b/apps/start/src/components/events/table/columns.tsx @@ -89,7 +89,7 @@ export function useColumns() { projectId: row.original.projectId, }); }} - className="font-medium" + className="font-medium hover:underline" > {renderName()} @@ -144,10 +144,21 @@ export function useColumns() { { accessorKey: 'sessionId', header: 'Session ID', - size: 320, + size: 100, meta: { hidden: true, }, + cell({ row }) { + const { sessionId } = row.original; + return ( + + {sessionId.slice(0,6)} + + ); + }, }, { accessorKey: 'deviceId', diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index 553bc8e0..0aaa78bc 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -1,32 +1,28 @@ -'use client'; - +import type { IServiceEvent } from '@openpanel/db'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Maximize2, Minimize2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BrowserChrome } from './browser-chrome'; +import { ReplayTime } from './replay-controls'; +import { getEventOffsetMs } from './replay-utils'; import { ReplayProvider, + useCurrentTime, useReplayContext, } from '@/components/sessions/replay/replay-context'; import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed'; import { ReplayPlayer } from '@/components/sessions/replay/replay-player'; import { ReplayTimeline } from '@/components/sessions/replay/replay-timeline'; import { useTRPC } from '@/integrations/trpc/react'; -import { useQuery } from '@tanstack/react-query'; -import type { IServiceEvent } from '@openpanel/db'; -import { type ReactNode, useMemo } from 'react'; -import { BrowserChrome } from './browser-chrome'; -import { ReplayTime } from './replay-controls'; - -function getEventOffsetMs(event: IServiceEvent, startTime: number): number { - const t = - typeof event.createdAt === 'object' && event.createdAt instanceof Date - ? event.createdAt.getTime() - : new Date(event.createdAt).getTime(); - return t - startTime; -} function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { - const { currentTime, startTime } = useReplayContext(); + const { startTime } = useReplayContext(); + const currentTime = useCurrentTime(250); const currentUrl = useMemo(() => { - if (startTime == null || !events.length) return ''; + if (startTime == null || !events.length) { + return ''; + } const withOffset = events .map((ev) => ({ @@ -37,14 +33,102 @@ function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { .sort((a, b) => a.offsetMs - b.offsetMs); const latest = withOffset[withOffset.length - 1]; - if (!latest) return ''; + if (!latest) { + return ''; + } const { origin = '', path = '/' } = latest.event; - const pathPart = path.startsWith('/') ? path : `/${path}`; - return `${origin}${pathPart}`; + return `${origin}${path}`; }, [events, currentTime, startTime]); - return {currentUrl}; + return {currentUrl}; +} + +/** + * Feeds remaining chunks into the player after it's ready. + * Receives already-fetched chunks from the initial batch, then pages + * through the rest using replayChunksFrom. + */ +function ReplayChunkLoader({ + sessionId, + projectId, + fromIndex, +}: { + sessionId: string; + projectId: string; + fromIndex: number; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { addEvent, refreshDuration } = useReplayContext(); + + useEffect(() => { + function recursive(fromIndex: number) { + queryClient + .fetchQuery( + trpc.session.replayChunksFrom.queryOptions({ + sessionId, + projectId, + fromIndex, + }) + ) + .then((res) => { + res.data.forEach((row) => { + row.events.forEach((event) => { + addEvent(event); + }); + }); + refreshDuration(); + if (res.hasMore) { + recursive(fromIndex + res.data.length); + } + }); + } + + recursive(fromIndex); + }, []); + + return null; +} + +function FullscreenButton({ + containerRef, +}: { + containerRef: React.RefObject; +}) { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggle = useCallback(() => { + if (!containerRef.current) { + return; + } + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + containerRef.current.requestFullscreen(); + } + }, [containerRef]); + + return ( + + ); } function ReplayContent({ @@ -55,75 +139,82 @@ function ReplayContent({ projectId: string; }) { const trpc = useTRPC(); - const { - data: replayData, - isLoading: replayLoading, - isError: replayError, - } = useQuery(trpc.session.replay.queryOptions({ sessionId, projectId })); + const containerRef = useRef(null); + const { data: eventsData } = useQuery( trpc.event.events.queryOptions({ projectId, sessionId, filters: [], columnVisibility: {}, - }), + }) + ); + + // Fetch first batch of chunks (includes chunk 0 for player init + more) + const { data: firstBatch, isLoading: replayLoading } = useQuery( + trpc.session.replayChunksFrom.queryOptions({ + sessionId, + projectId, + fromIndex: 0, + }) ); const events = eventsData?.data ?? []; - const replayEvents = replayData?.events as - | Array<{ type: number; data: unknown; timestamp: number }> - | undefined; - - if (replayLoading) { - return ( -
-
- -
-
-
- -
-
-
- ); - } - - if (replayError || !replayEvents?.length) { - return ( -
-
- about:blank} - > -
- No replay data available for this session. -
-
-
-
-
- ); - } + const playerEvents = firstBatch?.data.flatMap((row) => row.events) ?? []; + const hasMore = firstBatch?.hasMore ?? false; + const hasReplay = playerEvents.length !== 0; return ( -
+
} - right={} + right={ +
+ {hasReplay && } + +
+ } + url={ + hasReplay ? ( + + ) : ( + about:blank + ) + } > - - + {replayLoading ? ( +
+
+
Loading session replay
+
+ ) : hasReplay ? ( + + ) : ( +
+ No replay data available for this session. +
+ )} + {hasReplay && }
-
+
- +
+ {hasReplay && hasMore && ( + + )} ); } @@ -135,5 +226,5 @@ export function ReplayShell({ sessionId: string; projectId: string; }) { - return ; + return ; } diff --git a/apps/start/src/components/sessions/replay/replay-context.tsx b/apps/start/src/components/sessions/replay/replay-context.tsx index c9ee94a6..d60b5776 100644 --- a/apps/start/src/components/sessions/replay/replay-context.tsx +++ b/apps/start/src/components/sessions/replay/replay-context.tsx @@ -1,5 +1,3 @@ -'use client'; - import { type ReactNode, createContext, @@ -18,23 +16,40 @@ export interface ReplayPlayerInstance { setSpeed: (speed: number) => void; getMetaData: () => { startTime: number; endTime: number; totalTime: number }; getReplayer: () => { getCurrentTime: () => number }; + addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + addEventListener: (event: string, handler: (e: { payload: unknown }) => void) => void; + $set?: (props: Record) => void; $destroy?: () => void; } +type CurrentTimeListener = (t: number) => void; + interface ReplayContextValue { - currentTime: number; + // High-frequency value — read via ref, not state. Use subscribeToCurrentTime + // or useCurrentTime() to get updates without causing 60fps re-renders. + currentTimeRef: React.MutableRefObject; + subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void; + // Low-frequency state (safe to consume directly) isPlaying: boolean; - speed: number; duration: number; startTime: number | null; isReady: boolean; + // Playback controls play: () => void; pause: () => void; toggle: () => void; - seek: (timeOffset: number, play?: boolean) => void; + seek: (timeMs: number) => void; setSpeed: (speed: number) => void; - registerPlayer: (player: ReplayPlayerInstance) => void; - unregisterPlayer: () => void; + // Lazy chunk loading + addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + refreshDuration: () => void; + // Called by ReplayPlayer to register/unregister the rrweb instance + onPlayerReady: (player: ReplayPlayerInstance, playerStartTime: number) => void; + onPlayerDestroy: () => void; + // State setters exposed so ReplayPlayer can wire rrweb event listeners + setCurrentTime: (t: number) => void; + setIsPlaying: (p: boolean) => void; + setDuration: (d: number) => void; } const ReplayContext = createContext(null); @@ -49,147 +64,122 @@ export function useReplayContext() { return ctx; } +/** + * Subscribe to currentTime updates at a throttled rate. + * intervalMs=0 means every tick (use for the progress bar DOM writes). + * intervalMs=250 means 4 updates/second (use for text displays). + */ +export function useCurrentTime(intervalMs = 0): number { + const { currentTimeRef, subscribeToCurrentTime } = useReplayContext(); + const [time, setTime] = useState(currentTimeRef.current); + const lastUpdateRef = useRef(0); + + useEffect(() => { + return subscribeToCurrentTime((t) => { + if (intervalMs === 0) { + setTime(t); + return; + } + const now = performance.now(); + if (now - lastUpdateRef.current >= intervalMs) { + lastUpdateRef.current = now; + setTime(t); + } + }); + }, [subscribeToCurrentTime, intervalMs]); + + return time; +} + export function ReplayProvider({ children }: { children: ReactNode }) { const playerRef = useRef(null); - const [currentTime, setCurrentTime] = useState(0); + const isPlayingRef = useRef(false); + const currentTimeRef = useRef(0); + const listenersRef = useRef>(new Set()); + const [isPlaying, setIsPlaying] = useState(false); - const [speed, setSpeedState] = useState(1); const [duration, setDuration] = useState(0); const [startTime, setStartTime] = useState(null); const [isReady, setIsReady] = useState(false); - const rafIdRef = useRef(null); - const lastUpdateRef = useRef(0); - // Refs so stable callbacks can read latest values - const isPlayingRef = useRef(false); - const durationRef = useRef(0); - const currentTimeRef = useRef(0); - const registerPlayer = useCallback((player: ReplayPlayerInstance) => { - playerRef.current = player; - try { - const meta = player.getMetaData(); - durationRef.current = meta.totalTime; - setDuration(meta.totalTime); - setStartTime(meta.startTime); - setCurrentTime(0); - currentTimeRef.current = 0; - setIsReady(true); - } catch { - setIsReady(false); + const setIsPlayingWithRef = useCallback((playing: boolean) => { + isPlayingRef.current = playing; + setIsPlaying(playing); + }, []); + + const subscribeToCurrentTime = useCallback((fn: CurrentTimeListener) => { + listenersRef.current.add(fn); + return () => { + listenersRef.current.delete(fn); + }; + }, []); + + // Called by ReplayPlayer on every ui-update-current-time tick. + // Updates the ref and notifies subscribers — no React state update here. + const setCurrentTime = useCallback((t: number) => { + currentTimeRef.current = t; + for (const fn of listenersRef.current) { + fn(t); } }, []); - const unregisterPlayer = useCallback(() => { - if (rafIdRef.current != null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } + const onPlayerReady = useCallback( + (player: ReplayPlayerInstance, playerStartTime: number) => { + playerRef.current = player; + setStartTime(playerStartTime); + currentTimeRef.current = 0; + setIsPlayingWithRef(false); + setIsReady(true); + }, + [setIsPlayingWithRef], + ); + + const onPlayerDestroy = useCallback(() => { playerRef.current = null; setIsReady(false); - setCurrentTime(0); currentTimeRef.current = 0; setDuration(0); - durationRef.current = 0; setStartTime(null); - setIsPlaying(false); - isPlayingRef.current = false; - }, []); + setIsPlayingWithRef(false); + }, [setIsPlayingWithRef]); const play = useCallback(() => { playerRef.current?.play(); - setIsPlaying(true); - isPlayingRef.current = true; }, []); const pause = useCallback(() => { playerRef.current?.pause(); - setIsPlaying(false); - isPlayingRef.current = false; }, []); const toggle = useCallback(() => { - const player = playerRef.current; - if (!player) return; - - // If at the end, reset to start and play - const atEnd = currentTimeRef.current >= durationRef.current - 100; - if (atEnd && !isPlayingRef.current) { - player.goto(0, true); - setCurrentTime(0); - currentTimeRef.current = 0; - setIsPlaying(true); - isPlayingRef.current = true; - return; - } - - player.toggle(); - const next = !isPlayingRef.current; - setIsPlaying(next); - isPlayingRef.current = next; + playerRef.current?.toggle(); }, []); - const seek = useCallback((timeOffset: number, play?: boolean) => { - const player = playerRef.current; - if (!player) return; - const shouldPlay = play ?? isPlayingRef.current; - player.goto(timeOffset, shouldPlay); - setCurrentTime(timeOffset); - currentTimeRef.current = timeOffset; - setIsPlaying(shouldPlay); - isPlayingRef.current = shouldPlay; + const seek = useCallback((timeMs: number) => { + playerRef.current?.goto(timeMs, isPlayingRef.current); }, []); const setSpeed = useCallback((s: number) => { if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return; playerRef.current?.setSpeed(s); - setSpeedState(s); }, []); - useEffect(() => { - if (!isReady || !playerRef.current) return; + const addEvent = useCallback( + (event: { type: number; data: unknown; timestamp: number }) => { + playerRef.current?.addEvent(event); + }, + [], + ); - const tick = () => { - const player = playerRef.current; - if (!player) return; - try { - const replayer = player.getReplayer(); - const now = replayer.getCurrentTime(); - // Throttle state updates to ~10fps (every 100ms) to avoid excessive re-renders - const t = Math.floor(now / 100); - if (t !== lastUpdateRef.current) { - lastUpdateRef.current = t; - setCurrentTime(now); - currentTimeRef.current = now; - } - - // Detect end of replay - if ( - now >= durationRef.current - 50 && - durationRef.current > 0 && - isPlayingRef.current - ) { - setIsPlaying(false); - isPlayingRef.current = false; - } - } catch { - // Player may be destroyed - } - rafIdRef.current = requestAnimationFrame(tick); - }; - - rafIdRef.current = requestAnimationFrame(tick); - return () => { - if (rafIdRef.current != null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } - }; - }, [isReady]); + const refreshDuration = useCallback(() => { + const total = playerRef.current?.getMetaData().totalTime ?? 0; + if (total > 0) setDuration(total); + }, []); const value: ReplayContextValue = { - currentTime, + currentTimeRef, + subscribeToCurrentTime, isPlaying, - speed, duration, startTime, isReady, @@ -198,8 +188,13 @@ export function ReplayProvider({ children }: { children: ReactNode }) { toggle, seek, setSpeed, - registerPlayer, - unregisterPlayer, + addEvent, + refreshDuration, + onPlayerReady, + onPlayerDestroy, + setCurrentTime, + setIsPlaying: setIsPlayingWithRef, + setDuration, }; return ( diff --git a/apps/start/src/components/sessions/replay/replay-controls.tsx b/apps/start/src/components/sessions/replay/replay-controls.tsx index c0230a46..c6a6e1c2 100644 --- a/apps/start/src/components/sessions/replay/replay-controls.tsx +++ b/apps/start/src/components/sessions/replay/replay-controls.tsx @@ -1,37 +1,21 @@ -'use client'; - -import { - SPEED_OPTIONS, - useReplayContext, -} from '@/components/sessions/replay/replay-context'; +import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ChevronDown, Pause, Play, SkipBack, SkipForward } from 'lucide-react'; - -function formatTime(ms: number): string { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const m = Math.floor(totalSeconds / 60); - const s = totalSeconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} +import { Pause, Play } from 'lucide-react'; +import { formatDuration } from './replay-utils'; export function ReplayTime() { - const { currentTime, duration } = useReplayContext(); + const { duration } = useReplayContext(); + const currentTime = useCurrentTime(250); return ( - {formatTime(currentTime)} / {formatTime(duration)} + {formatDuration(currentTime)} / {formatDuration(duration)} ); } export function ReplayPlayPauseButton() { - const { isPlaying, isReady, toggle, seek } = useReplayContext(); + const { isPlaying, isReady, toggle } = useReplayContext(); if (!isReady) return null; @@ -47,23 +31,3 @@ export function ReplayPlayPauseButton() { ); } - -// {/* -// -// -// -// -// {SPEED_OPTIONS.map((s) => ( -// setSpeed(s)} -// className={speed === s ? 'bg-accent' : ''} -// > -// {s}x -// -// ))} -// -// */} diff --git a/apps/start/src/components/sessions/replay/replay-event-feed.tsx b/apps/start/src/components/sessions/replay/replay-event-feed.tsx index 11610fc1..03fcf4da 100644 --- a/apps/start/src/components/sessions/replay/replay-event-feed.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-feed.tsx @@ -1,49 +1,47 @@ -'use client'; - -import { useReplayContext } from '@/components/sessions/replay/replay-context'; +import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context'; import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { IServiceEvent } from '@openpanel/db'; import { useEffect, useMemo, useRef } from 'react'; import { BrowserChrome } from './browser-chrome'; +import { getEventOffsetMs } from './replay-utils'; -function getEventOffsetMs(event: IServiceEvent, startTime: number): number { - const t = - typeof event.createdAt === 'object' && event.createdAt instanceof Date - ? event.createdAt.getTime() - : new Date(event.createdAt).getTime(); - return t - startTime; -} +type EventWithOffset = { event: IServiceEvent; offsetMs: number }; -export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) { - const { currentTime, startTime, isReady, seek } = useReplayContext(); +export function ReplayEventFeed({ events, replayLoading }: { events: IServiceEvent[]; replayLoading: boolean }) { + const { startTime, isReady, seek } = useReplayContext(); + const currentTime = useCurrentTime(100); const viewportRef = useRef(null); const prevCountRef = useRef(0); - const { visibleEvents, currentEventId } = useMemo(() => { - if (startTime == null || !isReady) { - return { visibleEvents: [], currentEventId: null as string | null }; - } - const withOffset = events - .map((ev) => ({ - event: ev, - offsetMs: getEventOffsetMs(ev, startTime), - })) - // Include events up to 10s before recording started (e.g. screen views) - .filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime) + // Pre-sort events by offset once when events/startTime changes. + // This is the expensive part — done once, not on every tick. + const sortedEvents = useMemo(() => { + if (startTime == null || !isReady) return []; + return events + .map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) })) + .filter(({ offsetMs }) => offsetMs >= -10_000) .sort((a, b) => a.offsetMs - b.offsetMs); + }, [events, startTime, isReady]); - const visibleEvents = withOffset.map(({ event, offsetMs }) => ({ - event, - offsetMs, - })); + // Binary search to find how many events are visible at currentTime. + // O(log n) instead of O(n) filter on every tick. + const visibleCount = useMemo(() => { + let lo = 0; + let hi = sortedEvents.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + }, [sortedEvents, currentTime]); - const current = - visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null; - const currentEventId = current?.event.id ?? null; - - return { visibleEvents, currentEventId }; - }, [events, startTime, isReady, currentTime]); + const visibleEvents = sortedEvents.slice(0, visibleCount); + const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null; useEffect(() => { const viewport = viewportRef.current; @@ -60,8 +58,6 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) { }); }, [visibleEvents.length]); - if (!isReady) return null; - return ( seek(Math.max(0, offsetMs))} />
))} - {visibleEvents.length === 0 && ( + {!replayLoading && visibleEvents.length === 0 && (
Events will appear as the replay plays.
)} + {replayLoading && + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
diff --git a/apps/start/src/components/sessions/replay/replay-event-item.tsx b/apps/start/src/components/sessions/replay/replay-event-item.tsx index e6ad5a84..28a0102b 100644 --- a/apps/start/src/components/sessions/replay/replay-event-item.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-item.tsx @@ -16,7 +16,6 @@ export function ReplayEventItem({ onClick, }: { event: IServiceEvent; - offsetMs: number; isCurrent: boolean; onClick: () => void; }) { @@ -30,7 +29,7 @@ export function ReplayEventItem({ type="button" onClick={onClick} className={cn( - 'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent bg-card', + 'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent', isCurrent ? 'bg-accent/10' : 'bg-card', )} > diff --git a/apps/start/src/components/sessions/replay/replay-player.tsx b/apps/start/src/components/sessions/replay/replay-player.tsx index fe2f3075..a9ba3e7e 100644 --- a/apps/start/src/components/sessions/replay/replay-player.tsx +++ b/apps/start/src/components/sessions/replay/replay-player.tsx @@ -1,8 +1,6 @@ -'use client'; - import { useReplayContext } from '@/components/sessions/replay/replay-context'; import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import 'rrweb-player/dist/style.css'; @@ -24,6 +22,16 @@ function getRecordedDimensions( return null; } +function calcDimensions( + containerWidth: number, + aspectRatio: number, +): { width: number; height: number } { + const maxHeight = window.innerHeight * 0.7; + const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight); + const width = Math.min(containerWidth, Math.round(height * aspectRatio)); + return { width, height }; +} + export function ReplayPlayer({ events, }: { @@ -31,7 +39,14 @@ export function ReplayPlayer({ }) { const containerRef = useRef(null); const playerRef = useRef(null); - const { registerPlayer, unregisterPlayer } = useReplayContext(); + const { + onPlayerReady, + onPlayerDestroy, + setCurrentTime, + setIsPlaying, + setDuration, + } = useReplayContext(); + const [importError, setImportError] = useState(false); const recordedDimensions = useMemo( () => getRecordedDimensions(events), @@ -41,51 +56,115 @@ export function ReplayPlayer({ useEffect(() => { if (!events.length || !containerRef.current) return; + // Clear any previous player DOM + containerRef.current.innerHTML = ''; + let mounted = true; + let player: ReplayPlayerInstance | null = null; + let handleVisibilityChange: (() => void) | null = null; - import('rrweb-player').then((module) => { - const PlayerConstructor = module.default; - if (!containerRef.current || !mounted) return; - containerRef.current.innerHTML = ''; + const aspectRatio = recordedDimensions + ? recordedDimensions.width / recordedDimensions.height + : 16 / 9; - const maxHeight = window.innerHeight * 0.7; - const containerWidth = containerRef.current.offsetWidth; - const aspectRatio = recordedDimensions - ? recordedDimensions.width / recordedDimensions.height - : 16 / 9; - const height = Math.min( - Math.round(containerWidth / aspectRatio), - maxHeight, + const { width, height } = calcDimensions( + containerRef.current.offsetWidth, + aspectRatio, + ); + + import('rrweb-player') + .then((module) => { + if (!containerRef.current || !mounted) return; + + const PlayerConstructor = module.default; + player = new PlayerConstructor({ + target: containerRef.current, + props: { + events, + width, + height, + autoPlay: false, + showController: false, + speedOption: [0.5, 1, 2, 4, 8], + UNSAFE_replayCanvas: true, + skipInactive: false, + }, + }) as ReplayPlayerInstance; + + playerRef.current = player; + + // Wire rrweb's built-in event emitter — no RAF loop needed. + // Note: rrweb-player does NOT emit ui-update-duration; duration is + // read from getMetaData() on init and after each addEvent batch. + player.addEventListener('ui-update-current-time', (e) => { + const t = e.payload as number; + setCurrentTime(t); + }); + + player.addEventListener('ui-update-player-state', (e) => { + setIsPlaying(e.payload === 'playing'); + }); + + // Pause on tab hide; resume on show (prevents timer drift) + let wasPlaying = false; + handleVisibilityChange = () => { + if (!player) return; + if (document.hidden) { + const meta = player.getMetaData() as { isPlaying?: boolean }; + wasPlaying = meta.isPlaying ?? false; + if (wasPlaying) player.pause(); + } else { + if (wasPlaying) { + player.play(); + wasPlaying = false; + } + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Notify context — marks isReady = true and sets initial duration + const meta = player.getMetaData(); + if (meta.totalTime > 0) setDuration(meta.totalTime); + onPlayerReady(player, meta.startTime); + }) + .catch(() => { + if (mounted) setImportError(true); + }); + + const onWindowResize = () => { + if (!containerRef.current || !mounted || !playerRef.current?.$set) return; + const { width: w, height: h } = calcDimensions( + containerRef.current.offsetWidth, + aspectRatio, ); - const width = Math.min( - containerWidth, - Math.round(height * aspectRatio), - ); - - const player = new PlayerConstructor({ - target: containerRef.current, - props: { - events, - width, - height, - autoPlay: false, - showController: false, - speedOption: [0.5, 1, 2, 4, 8], - }, - }) as ReplayPlayerInstance; - playerRef.current = player; - registerPlayer(player); - }); + playerRef.current.$set({ width: w, height: h }); + }; + window.addEventListener('resize', onWindowResize); return () => { mounted = false; - unregisterPlayer(); - if (playerRef.current?.$destroy) { - playerRef.current.$destroy(); - playerRef.current = null; + window.removeEventListener('resize', onWindowResize); + if (handleVisibilityChange) { + document.removeEventListener('visibilitychange', handleVisibilityChange); } + if (player) { + player.pause(); + } + if (containerRef.current) { + containerRef.current.innerHTML = ''; + } + playerRef.current = null; + onPlayerDestroy(); }; - }, [events, registerPlayer, unregisterPlayer, recordedDimensions]); + }, [events, recordedDimensions, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, setDuration]); + + if (importError) { + return ( +
+ Failed to load replay player. +
+ ); + } return (
diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index d9982a56..1df4dfda 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -1,6 +1,4 @@ -'use client'; - -import { useReplayContext } from '@/components/sessions/replay/replay-context'; +import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context'; import { Tooltip, TooltipContent, @@ -9,36 +7,50 @@ import { } from '@/components/ui/tooltip'; import type { IServiceEvent } from '@openpanel/db'; import { AnimatePresence, motion } from 'framer-motion'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EventIcon } from '@/components/events/event-icon'; import { cn } from '@/lib/utils'; import { ReplayPlayPauseButton } from './replay-controls'; - -function formatTime(ms: number): string { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const m = Math.floor(totalSeconds / 60); - const s = totalSeconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - -function getEventOffsetMs(event: IServiceEvent, startTime: number): number { - const t = - typeof event.createdAt === 'object' && event.createdAt instanceof Date - ? event.createdAt.getTime() - : new Date(event.createdAt).getTime(); - return t - startTime; -} +import { formatDuration, getEventOffsetMs } from './replay-utils'; export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { - const { currentTime, duration, startTime, isReady, seek } = + const { currentTimeRef, duration, startTime, isReady, seek, subscribeToCurrentTime } = useReplayContext(); + // currentTime as React state is only needed for keyboard seeks (low frequency). + // The progress bar and thumb are updated directly via DOM refs to avoid re-renders. + const currentTime = useCurrentTime(250); const trackRef = useRef(null); + const progressBarRef = useRef(null); + const thumbRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [hoverInfo, setHoverInfo] = useState<{ pct: number; timeMs: number; } | null>(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + const rafDragRef = useRef(null); + + // Clean up any in-progress drag listeners when the component unmounts + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + }; + }, []); + + // Update progress bar and thumb directly via DOM on every tick — no React re-render. + useEffect(() => { + if (duration <= 0) return; + return subscribeToCurrentTime((t) => { + const pct = Math.max(0, Math.min(100, (t / duration) * 100)); + if (progressBarRef.current) { + progressBarRef.current.style.width = `${pct}%`; + } + if (thumbRef.current) { + thumbRef.current.style.left = `calc(${pct}% - 8px)`; + } + }); + }, [subscribeToCurrentTime, duration]); const getTimeFromClientX = useCallback( (clientX: number) => { @@ -67,14 +79,6 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { if (!isDragging) setHoverInfo(null); }, [isDragging]); - const seekToPosition = useCallback( - (clientX: number) => { - const info = getTimeFromClientX(clientX); - if (info) seek(info.timeMs); - }, - [getTimeFromClientX, seek], - ); - const handleTrackMouseDown = useCallback( (e: React.MouseEvent) => { // Only handle direct clicks on the track, not on child elements like the thumb @@ -83,46 +87,57 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { !(e.target as HTMLElement).closest('.replay-track-bg') ) return; - seekToPosition(e.clientX); + const info = getTimeFromClientX(e.clientX); + if (info) seek(info.timeMs); }, - [seekToPosition], + [getTimeFromClientX, seek], ); - const handleThumbMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - const onMouseMove = (moveEvent: MouseEvent) => { - seekToPosition(moveEvent.clientX); - const info = getTimeFromClientX(moveEvent.clientX); - if (info) setHoverInfo(info); - }; - const onMouseUp = () => { - setIsDragging(false); - setHoverInfo(null); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }, - [seekToPosition, getTimeFromClientX], + const eventsWithOffset = useMemo( + () => + events + .map((ev) => ({ + event: ev, + offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0, + })) + .filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration), + [events, startTime, duration], ); + // Group events that are within 24px of each other on the track. + // We need the track width for pixel math — use a stable ref-based calculation. + const groupedEvents = useMemo(() => { + if (!eventsWithOffset.length || duration <= 0) return []; + + // Sort by offsetMs so we sweep left-to-right + const sorted = [...eventsWithOffset].sort((a, b) => a.offsetMs - b.offsetMs); + + // 24px in ms — recalculated from container width; fall back to 2% of duration + const trackWidth = trackRef.current?.offsetWidth ?? 600; + const thresholdMs = (24 / trackWidth) * duration; + + const groups: { items: typeof sorted; pct: number }[] = []; + for (const item of sorted) { + const last = groups[groups.length - 1]; + const lastPct = last ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 : -Infinity; + const thisPct = (item.offsetMs / duration) * 100; + + if (last && item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= thresholdMs) { + last.items.push(item); + // Anchor the group at its first item's position + } else { + groups.push({ items: [item], pct: thisPct }); + } + // keep pct pointing at the first item (already set on push) + void lastPct; + } + + return groups; + }, [eventsWithOffset, duration]); + if (!isReady || duration <= 0) return null; - const progressPct = - duration > 0 - ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) - : 0; - - const eventsWithOffset = events - .map((ev) => ({ - event: ev, - offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0, - })) - .filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration); + const progressPct = Math.max(0, Math.min(100, (currentTimeRef.current / duration) * 100)); return ( @@ -136,7 +151,7 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { aria-valuemax={duration} aria-valuenow={currentTime} tabIndex={0} - className="relative flex h-8 cursor-pointer items-center" + className="relative flex h-8 cursor-pointer items-center outline-0" onMouseDown={handleTrackMouseDown} onMouseMove={handleTrackMouseMove} onMouseLeave={handleTrackMouseLeave} @@ -153,14 +168,15 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { >
{/* Hover timestamp tooltip */} @@ -188,40 +204,48 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { exit={{ opacity: 0, y: 16, scale: 0.5 }} transition={{ duration: 0.2 }} > - {formatTime(hoverInfo.timeMs)} + {formatDuration(hoverInfo.timeMs)} )} - {eventsWithOffset.map(({ event: ev, offsetMs }) => { - const pct = (offsetMs / duration) * 100; + {groupedEvents.map((group) => { + const first = group.items[0]!; + const isGroup = group.items.length > 1; return ( - + - -
- - {ev.name === 'screen_view' ? ev.path : ev.name} -
-
- {formatTime(offsetMs)} -
+ + {group.items.map(({ event: ev, offsetMs }) => ( +
+ + + {ev.name === 'screen_view' ? ev.path : ev.name} + + + {formatDuration(offsetMs)} + +
+ ))}
); diff --git a/apps/start/src/components/sessions/replay/replay-utils.ts b/apps/start/src/components/sessions/replay/replay-utils.ts new file mode 100644 index 00000000..415ebae2 --- /dev/null +++ b/apps/start/src/components/sessions/replay/replay-utils.ts @@ -0,0 +1,20 @@ +import type { IServiceEvent } from '@openpanel/db'; + +export function getEventOffsetMs( + event: IServiceEvent, + startTime: number, +): number { + const t = + typeof event.createdAt === 'object' && event.createdAt instanceof Date + ? event.createdAt.getTime() + : new Date(event.createdAt).getTime(); + return t - startTime; +} + +/** Format a duration in milliseconds as M:SS */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} diff --git a/apps/start/src/types/rrweb-player.d.ts b/apps/start/src/types/rrweb-player.d.ts index bdffb9a4..2cae9bf9 100644 --- a/apps/start/src/types/rrweb-player.d.ts +++ b/apps/start/src/types/rrweb-player.d.ts @@ -6,6 +6,8 @@ declare module 'rrweb-player' { autoPlay?: boolean; showController?: boolean; speedOption?: number[]; + UNSAFE_replayCanvas?: boolean; + skipInactive?: boolean; } interface RrwebPlayerOptions { @@ -31,10 +33,12 @@ declare module 'rrweb-player' { setSpeed: (speed: number) => void; getMetaData: () => RrwebPlayerMetaData; getReplayer: () => RrwebReplayer; + addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; addEventListener?: ( event: string, handler: (...args: unknown[]) => void, ) => void; + $set?: (props: Partial) => void; $destroy?: () => void; } diff --git a/packages/db/src/buffers/replay-buffer.ts b/packages/db/src/buffers/replay-buffer.ts index a78414ff..587985f3 100644 --- a/packages/db/src/buffers/replay-buffer.ts +++ b/packages/db/src/buffers/replay-buffer.ts @@ -61,9 +61,9 @@ export class ReplayBuffer extends BaseBuffer { return; } - const chunks = items.map((item) => - getSafeJson(item), - ); + const chunks = items + .map((item) => getSafeJson(item)) + .filter((item): item is IClickhouseSessionReplayChunk => item != null); for (const chunk of this.chunks(chunks, this.chunkSize)) { await ch.insert({ diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index 2cbaa2fd..7ba2c783 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -2,17 +2,17 @@ import { cacheable } from '@openpanel/redis'; import type { IChartEventFilter } from '@openpanel/validation'; import sqlstring from 'sqlstring'; import { - TABLE_NAMES, ch, chQuery, convertClickhouseDateToJs, formatClickhouseDate, + TABLE_NAMES, } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; import { createSqlBuilder } from '../sql-builder'; import { getEventFiltersWhereClause } from './chart.service'; import { getOrganizationByProjectIdCached } from './organization.service'; -import { type IServiceProfile, getProfilesCached } from './profile.service'; +import { getProfilesCached, type IServiceProfile } from './profile.service'; export type IClickhouseSession = { id: string; @@ -180,8 +180,9 @@ export async function getSessionList({ sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`; } - if (profileId) + if (profileId) { sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`; + } if (search) { const s = sqlstring.escape(`%${search}%`); sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`; @@ -237,7 +238,8 @@ export async function getSessionList({ sb.select[column] = column; }); - sb.select.has_replay = `exists(SELECT 1 FROM ${TABLE_NAMES.session_replay_chunks} WHERE session_id = id AND project_id = ${sqlstring.escape(projectId)}) as has_replay`; + sb.select.has_replay = `toBool(src.session_id != '') as has_replay`; + sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`; const sql = getSql(); const data = await chQuery< @@ -325,40 +327,42 @@ export async function getSessionsCount({ export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10); -export async function getSessionReplayEvents( +export interface ISessionReplayChunkMeta { + chunk_index: number; + started_at: string; + ended_at: string; + events_count: number; + is_full_snapshot: boolean; +} + +const REPLAY_CHUNKS_PAGE_SIZE = 40; + +export async function getSessionReplayChunksFrom( sessionId: string, projectId: string, -): Promise<{ events: unknown[] }> { - const chunks = await clix(ch) - .select<{ chunk_index: number; payload: string }>([ - 'chunk_index', - 'payload', - ]) - .from(TABLE_NAMES.session_replay_chunks) - .where('session_id', '=', sessionId) - .where('project_id', '=', projectId) - .orderBy('chunk_index', 'ASC') - .execute(); - - const allEvents = chunks.flatMap( - (chunk) => JSON.parse(chunk.payload) as unknown[], + fromIndex: number +) { + const rows = await chQuery<{ chunk_index: number; payload: string }>( + `SELECT chunk_index, payload + FROM ${TABLE_NAMES.session_replay_chunks} + WHERE session_id = ${sqlstring.escape(sessionId)} + AND project_id = ${sqlstring.escape(projectId)} + ORDER BY started_at, ended_at + LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1} + OFFSET ${fromIndex}` ); - // rrweb event types: 2 = FullSnapshot, 4 = Meta - // Incremental snapshots (type 3) before the first FullSnapshot are orphaned - // and cause the player to fast-forward through empty time. Strip them but - // keep Meta events (type 4) since rrweb needs them for viewport dimensions. - const firstFullSnapshotIdx = allEvents.findIndex((e: any) => e.type === 2); - - let events = allEvents; - if (firstFullSnapshotIdx > 0) { - const metaEvents = allEvents - .slice(0, firstFullSnapshotIdx) - .filter((e: any) => e.type === 4); - events = [...metaEvents, ...allEvents.slice(firstFullSnapshotIdx)]; - } - - return { events }; + return { + data: rows.slice(0, REPLAY_CHUNKS_PAGE_SIZE).map((row, index) => ({ + chunkIndex: index + fromIndex, + events: JSON.parse(row.payload) as { + type: number; + data: unknown; + timestamp: number; + }[], + })), + hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE, + }; } class SessionService { diff --git a/packages/sdks/web/src/replay/recorder.ts b/packages/sdks/web/src/replay/recorder.ts index 3be0a8db..55312abe 100644 --- a/packages/sdks/web/src/replay/recorder.ts +++ b/packages/sdks/web/src/replay/recorder.ts @@ -31,6 +31,11 @@ export function startReplayRecorder( 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 diff --git a/packages/trpc/src/routers/session.ts b/packages/trpc/src/routers/session.ts index 5ccbf577..a444d047 100644 --- a/packages/trpc/src/routers/session.ts +++ b/packages/trpc/src/routers/session.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { getSessionList, - getSessionReplayEvents, + getSessionReplayChunksFrom, sessionService, } from '@openpanel/db'; import { zChartEventFilter } from '@openpanel/validation'; @@ -66,9 +66,15 @@ export const sessionRouter = createTRPCRouter({ return sessionService.byId(sessionId, projectId); }), - replay: protectedProcedure - .input(z.object({ sessionId: z.string(), projectId: z.string() })) - .query(async ({ input: { sessionId, projectId } }) => { - return getSessionReplayEvents(sessionId, projectId); + replayChunksFrom: protectedProcedure + .input( + z.object({ + sessionId: z.string(), + projectId: z.string(), + fromIndex: z.number().int().min(0).default(0), + }), + ) + .query(async ({ input: { sessionId, projectId, fromIndex } }) => { + return getSessionReplayChunksFrom(sessionId, projectId, fromIndex); }), });