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