This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-20 19:08:44 +01:00
parent 41993d3463
commit b193ccb7d0
15 changed files with 647 additions and 433 deletions

View File

@@ -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
*/

View File

@@ -89,7 +89,7 @@ export function useColumns() {
projectId: row.original.projectId,
});
}}
className="font-medium"
className="font-medium hover:underline"
>
{renderName()}
</button>
@@ -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 (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0,6)}
</ProjectLink>
);
},
},
{
accessorKey: 'deviceId',

View File

@@ -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 <span className="text-muted-foreground truncate">{currentUrl}</span>;
return <span className="truncate text-muted-foreground">{currentUrl}</span>;
}
/**
* 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<HTMLDivElement | null>;
}) {
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 (
<button
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground"
onClick={toggle}
type="button"
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
);
}
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<HTMLDivElement>(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 (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome>
<div className="flex h-[320px] items-center justify-center bg-black">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted-foreground/20" />
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
if (replayError || !replayEvents?.length) {
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<span className="text-muted-foreground">about:blank</span>}
>
<div className="flex h-[320px] items-center justify-center bg-black text-muted-foreground">
No replay data available for this session.
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
const playerEvents = firstBatch?.data.flatMap((row) => row.events) ?? [];
const hasMore = firstBatch?.hasMore ?? false;
const hasReplay = playerEvents.length !== 0;
return (
<ReplayProvider>
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div
className="grid gap-4 lg:grid-cols-[1fr_380px] [&:fullscreen]:flex [&:fullscreen]:flex-col [&:fullscreen]:bg-background [&:fullscreen]:p-4"
id="replay"
ref={containerRef}
>
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<BrowserUrlBar events={events} />}
right={<ReplayTime />}
right={
<div className="flex items-center gap-2">
{hasReplay && <ReplayTime />}
<FullscreenButton containerRef={containerRef} />
</div>
}
url={
hasReplay ? (
<BrowserUrlBar events={events} />
) : (
<span className="text-muted-foreground">about:blank</span>
)
}
>
<ReplayPlayer events={replayEvents} />
<ReplayTimeline events={events} />
{replayLoading ? (
<div className="col h-[320px] items-center justify-center gap-4 bg-background">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted" />
<div>Loading session replay</div>
</div>
) : hasReplay ? (
<ReplayPlayer events={playerEvents} />
) : (
<div className="flex h-[320px] items-center justify-center bg-background text-muted-foreground text-sm">
No replay data available for this session.
</div>
)}
{hasReplay && <ReplayTimeline events={events} />}
</BrowserChrome>
</div>
<div className="hidden lg:block relative">
<div className="relative hidden lg:block">
<div className="absolute inset-0">
<ReplayEventFeed events={events} />
<ReplayEventFeed events={events} replayLoading={replayLoading} />
</div>
</div>
</div>
{hasReplay && hasMore && (
<ReplayChunkLoader
fromIndex={firstBatch?.data?.length ?? 0}
projectId={projectId}
sessionId={sessionId}
/>
)}
</ReplayProvider>
);
}
@@ -135,5 +226,5 @@ export function ReplayShell({
sessionId: string;
projectId: string;
}) {
return <ReplayContent sessionId={sessionId} projectId={projectId} />;
return <ReplayContent projectId={projectId} sessionId={sessionId} />;
}

View File

@@ -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<string, unknown>) => 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<number>;
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<ReplayContextValue | null>(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<ReplayPlayerInstance | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const isPlayingRef = useRef(false);
const currentTimeRef = useRef(0);
const listenersRef = useRef<Set<CurrentTimeListener>>(new Set());
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeedState] = useState(1);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
const rafIdRef = useRef<number | null>(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 (

View File

@@ -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 (
<span className="text-sm tabular-nums text-muted-foreground font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
{formatDuration(currentTime)} / {formatDuration(duration)}
</span>
);
}
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() {
</Button>
);
}
// {/* <DropdownMenu>
// <DropdownMenuTrigger asChild>
// <Button variant="outline" size="sm" className="h-8 gap-1">
// {speed}x
// <ChevronDown className="h-3.5 w-3.5" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end">
// {SPEED_OPTIONS.map((s) => (
// <DropdownMenuItem
// key={s}
// onClick={() => setSpeed(s)}
// className={speed === s ? 'bg-accent' : ''}
// >
// {s}x
// </DropdownMenuItem>
// ))}
// </DropdownMenuContent>
// </DropdownMenu> */}

View File

@@ -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<HTMLDivElement | null>(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<EventWithOffset[]>(() => {
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 (
<BrowserChrome
url={false}
@@ -77,17 +73,33 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
>
<ReplayEventItem
event={event}
offsetMs={offsetMs}
isCurrent={event.id === currentEventId}
onClick={() => seek(Math.max(0, offsetMs))}
/>
</div>
))}
{visibleEvents.length === 0 && (
{!replayLoading && visibleEvents.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
Events will appear as the replay plays.
</div>
)}
{replayLoading &&
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 border-b px-3 py-2"
>
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div
className="h-3 animate-pulse rounded bg-muted"
style={{ width: `${50 + (i % 4) * 12}%` }}
/>
</div>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</ScrollArea>
</BrowserChrome>

View File

@@ -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',
)}
>

View File

@@ -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<HTMLDivElement>(null);
const playerRef = useRef<ReplayPlayerInstance | null>(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 (
<div className="flex h-[320px] items-center justify-center bg-black text-sm text-muted-foreground">
Failed to load replay player.
</div>
);
}
return (
<div className="relative flex w-full justify-center overflow-hidden">

View File

@@ -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<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(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<number | null>(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<HTMLDivElement>) => {
// 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 (
<TooltipProvider delayDuration={300}>
@@ -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[] }) {
>
<div className="replay-track-bg bg-muted h-1.5 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-75"
ref={progressBarRef}
className="bg-primary h-full rounded-full"
style={{ width: `${progressPct}%` }}
/>
</div>
<div
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm transition-[left] duration-75 active:cursor-grabbing"
ref={thumbRef}
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
style={{ left: `calc(${progressPct}% - 8px)` }}
onMouseDown={handleThumbMouseDown}
aria-hidden
/>
{/* 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)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{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 (
<Tooltip key={ev.id}>
<Tooltip key={first.event.id}>
<TooltipTrigger asChild>
<button
type="button"
data-timeline-event
className={cn(
'absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-125',
)}
style={{ left: `${pct}%`, marginLeft: -12 }}
className="absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-105"
style={{ left: `${group.pct}%`, marginLeft: -12 }}
onClick={(e) => {
e.stopPropagation();
seek(offsetMs);
seek(first.offsetMs);
}}
aria-label={`${ev.name} at ${formatTime(offsetMs)}`}
aria-label={isGroup ? `${group.items.length} events at ${formatDuration(first.offsetMs)}` : `${first.event.name} at ${formatDuration(first.offsetMs)}`}
>
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
<EventIcon name={first.event.name} meta={first.event.meta} size="sm" />
{isGroup && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold leading-none text-background">
{group.items.length}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="col gap-2">
<div className="font-medium row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
{ev.name === 'screen_view' ? ev.path : ev.name}
</div>
<div className="text-muted-foreground">
{formatTime(offsetMs)}
</div>
<TooltipContent side="top" className="col gap-1.5">
{group.items.map(({ event: ev, offsetMs }) => (
<div key={ev.id} className="row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
<span className="font-medium">
{ev.name === 'screen_view' ? ev.path : ev.name}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDuration(offsetMs)}
</span>
</div>
))}
</TooltipContent>
</Tooltip>
);

View File

@@ -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')}`;
}

View File

@@ -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<RrwebPlayerProps>) => void;
$destroy?: () => void;
}

View File

@@ -61,9 +61,9 @@ export class ReplayBuffer extends BaseBuffer {
return;
}
const chunks = items.map((item) =>
getSafeJson<IClickhouseSessionReplayChunk>(item),
);
const chunks = items
.map((item) => getSafeJson<IClickhouseSessionReplayChunk>(item))
.filter((item): item is IClickhouseSessionReplayChunk => item != null);
for (const chunk of this.chunks(chunks, this.chunkSize)) {
await ch.insert({

View File

@@ -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 {

View File

@@ -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

View File

@@ -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);
}),
});