From 5e68661c5ddf059fdc2d2aec53f556538feeb312 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 12:47:27 -0800 Subject: [PATCH 1/8] session replay updates. add clickhouse implementation, filters, tables --- .../[websiteId]/events/EventsTable.tsx | 4 +- .../[websiteId]/replays/ReplaysPage.tsx | 2 + .../[websiteId]/replays/ReplaysTable.tsx | 27 +++-- .../replays/[sessionId]/ReplayPlayback.tsx | 2 +- .../replays/[sessionId]/ReplayPlayer.tsx | 17 +-- .../[websiteId]/sessions/SessionsTable.tsx | 13 ++- src/app/api/record/route.ts | 11 +- .../api/websites/[websiteId]/events/route.ts | 7 +- .../[websiteId]/replays/[sessionId]/route.ts | 8 +- .../api/websites/[websiteId]/replays/route.ts | 7 +- .../hooks/queries/useReplaysQuery.ts | 8 +- src/queries/prisma/index.ts | 1 + src/queries/prisma/sessionReplay.ts | 60 ++++++++++ src/queries/sql/events/getEventStats.ts | 9 +- src/queries/sql/index.ts | 3 +- .../sql/replays/deleteReplaysByWebsite.ts | 11 -- src/queries/sql/replays/getReplayChunks.ts | 105 +++++++++++++++--- src/queries/sql/replays/getSessionReplays.ts | 94 +++++++++++++--- src/queries/sql/replays/index.ts | 4 - src/queries/sql/replays/saveRecording.ts | 72 ++++++++++++ src/queries/sql/replays/saveReplayChunk.ts | 39 ------- .../sql/sessions/getWebsiteSessionStats.ts | 2 +- .../sql/sessions/getWebsiteSessions.ts | 3 + 23 files changed, 359 insertions(+), 150 deletions(-) create mode 100644 src/queries/prisma/sessionReplay.ts delete mode 100644 src/queries/sql/replays/deleteReplaysByWebsite.ts delete mode 100644 src/queries/sql/replays/index.ts create mode 100644 src/queries/sql/replays/saveRecording.ts delete mode 100644 src/queries/sql/replays/saveReplayChunk.ts diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index d44b74ba0..59310518e 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -86,14 +86,14 @@ export function EventsTable(props: DataTableProps) { )} - + {(row: any) => ( {formatValue(row.device, 'device')} )} - + {(row: any) => } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index 65836e769..d4127fc6d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,5 +1,6 @@ 'use client'; import { Column } from '@umami/react-zen'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; import { ReplaysDataTable } from './ReplaysDataTable'; @@ -11,6 +12,7 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) { + ); } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx index 1082cf6a0..5549d3ddc 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Avatar } from '@/components/common/Avatar'; import { DateDistance } from '@/components/common/DateDistance'; import { TypeIcon } from '@/components/common/TypeIcon'; -import { useFormat, useMessages } from '@/components/hooks'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; function formatDuration(ms: number) { const seconds = Math.floor(ms / 1000); @@ -16,38 +16,51 @@ function formatDuration(ms: number) { export function ReplaysTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) { const { t, labels } = useMessages(); const { formatValue } = useFormat(); + const { updateParams } = useNavigation(); return ( - {(row: any) => } + {(row: any) => ( + + + + )} {(row: any) => formatDuration(row.duration || 0)} - - + + {(row: any) => ( + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} )} - + {(row: any) => ( {formatValue(row.browser, 'browser')} )} - + {(row: any) => ( {formatValue(row.os, 'os')} )} - + + {(row: any) => ( + + {formatValue(row.device, 'device')} + + )} + + {(row: any) => } diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx index 60ea97ffb..9dfcf3793 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx @@ -27,7 +27,7 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se {t(labels.replay)} - {replay.eventCount} {t(labels.events).toLowerCase()} + {replay.eventCount} {t(labels.actions).toLowerCase()} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx index 0b85f1409..bb2a99a35 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx @@ -11,21 +11,6 @@ export function ReplayPlayer({ events }: { events: any[] }) { useEffect(() => { if (!containerRef.current || !events?.length) return; - // Debug: log event info - const typeCounts: Record = {}; - events.forEach((e: any) => { - typeCounts[e.type] = (typeCounts[e.type] || 0) + 1; - }); - const timestamps = events.map((e: any) => e.timestamp).filter(Boolean); - console.log('[ReplayPlayer] Events:', events.length, 'Types:', typeCounts); - console.log( - '[ReplayPlayer] Time range:', - timestamps.length - ? `${Math.min(...timestamps)} - ${Math.max(...timestamps)} (${Math.max(...timestamps) - Math.min(...timestamps)}ms)` - : 'no timestamps', - ); - console.log('[ReplayPlayer] First 3 events:', events.slice(0, 3)); - // Dynamically import rrweb-player to avoid SSR issues import('rrweb-player').then(mod => { const RRWebPlayer = mod.default; @@ -36,7 +21,7 @@ export function ReplayPlayer({ events }: { events: any[] }) { } playerRef.current = new RRWebPlayer({ - target: containerRef.current!, + target: containerRef.current, props: { events, width: 1024, diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx index 78a41359b..69eb9991e 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -21,36 +21,37 @@ export function SessionsTable(props: DataTableProps) { - + + {(row: any) => ( + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} )} - - + {(row: any) => ( {formatValue(row.browser, 'browser')} )} - + {(row: any) => ( {formatValue(row.os, 'os')} )} - + {(row: any) => ( {formatValue(row.device, 'device')} )} - + {(row: any) => } diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 96554869d..25b3924c1 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -1,4 +1,3 @@ -import { gzipSync } from 'node:zlib'; import { isbot } from 'isbot'; import { serializeError } from 'serialize-error'; import { z } from 'zod'; @@ -8,7 +7,7 @@ import { parseToken } from '@/lib/jwt'; import { parseRequest } from '@/lib/request'; import { badRequest, forbidden, json, serverError } from '@/lib/response'; import { getWebsite } from '@/queries/prisma'; -import { saveReplayChunk } from '@/queries/sql'; +import { saveRecording } from '@/queries/sql'; const schema = z.object({ website: z.uuid(), @@ -75,18 +74,14 @@ export async function POST(request: Request) { const startedAt = new Date(Math.min(...eventTimestamps)); const endedAt = new Date(Math.max(...eventTimestamps)); - // Compress events - const eventsJson = JSON.stringify(events); - const compressed = gzipSync(Buffer.from(eventsJson, 'utf-8')); - // Use timestamp-based chunk index for ordering const chunkIndex = timestamp || Math.floor(Date.now() / 1000); - await saveReplayChunk({ + await saveRecording({ websiteId, sessionId, chunkIndex, - events: compressed, + events, eventCount: events.length, startedAt, endedAt, diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts index 74ec3ece4..583e4abbf 100644 --- a/src/app/api/websites/[websiteId]/events/route.ts +++ b/src/app/api/websites/[websiteId]/events/route.ts @@ -1,7 +1,6 @@ -import { z } from 'zod'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; import { getWebsiteEvents } from '@/queries/sql'; @@ -9,9 +8,7 @@ export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const schema = z.object({ - startAt: z.coerce.number().optional(), - endAt: z.coerce.number().optional(), + const schema = withDateRange({ ...filterParams, ...pagingParams, ...searchParams, diff --git a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts index 58a6e9dd2..01e3a91aa 100644 --- a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts @@ -1,4 +1,3 @@ -import { gunzipSync } from 'node:zlib'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; @@ -22,12 +21,7 @@ export async function GET( const chunks = await getReplayChunks(websiteId, sessionId); - // Decompress and concatenate all chunks - const allEvents = chunks.flatMap(chunk => { - const decompressed = gunzipSync(Buffer.from(chunk.events)); - return JSON.parse(decompressed.toString('utf-8')); - }); - + const allEvents = chunks.flatMap(chunk => chunk.events); const startedAt = chunks.length > 0 ? chunks[0].startedAt : null; const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null; diff --git a/src/app/api/websites/[websiteId]/replays/route.ts b/src/app/api/websites/[websiteId]/replays/route.ts index 7fed95d21..fb38a0178 100644 --- a/src/app/api/websites/[websiteId]/replays/route.ts +++ b/src/app/api/websites/[websiteId]/replays/route.ts @@ -1,7 +1,6 @@ -import { z } from 'zod'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { dateRangeParams, pagingParams, searchParams } from '@/lib/schema'; +import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; import { getSessionReplays } from '@/queries/sql'; @@ -9,8 +8,8 @@ export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const schema = z.object({ - ...dateRangeParams, + const schema = withDateRange({ + ...filterParams, ...pagingParams, ...searchParams, }); diff --git a/src/components/hooks/queries/useReplaysQuery.ts b/src/components/hooks/queries/useReplaysQuery.ts index ab0d6f821..79c1a04bf 100644 --- a/src/components/hooks/queries/useReplaysQuery.ts +++ b/src/components/hooks/queries/useReplaysQuery.ts @@ -1,5 +1,6 @@ import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; import { useModified } from '../useModified'; import { usePagedQuery } from '../usePagedQuery'; @@ -7,15 +8,20 @@ export function useReplaysQuery(websiteId: string, params?: Record { return get(`/websites/${websiteId}/replays`, { startAt, endAt, unit, timezone, + ...filters, ...pageParams, ...params, pageSize: 20, diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index 5e975d013..27288c31c 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -3,6 +3,7 @@ export * from './link'; export * from './pixel'; export * from './report'; export * from './segment'; +export * from './sessionReplay'; export * from './share'; export * from './team'; export * from './teamUser'; diff --git a/src/queries/prisma/sessionReplay.ts b/src/queries/prisma/sessionReplay.ts new file mode 100644 index 000000000..868c4a50e --- /dev/null +++ b/src/queries/prisma/sessionReplay.ts @@ -0,0 +1,60 @@ +import { uuid } from '@/lib/crypto'; +import prisma from '@/lib/prisma'; + +export interface CreateReplayChunkArgs { + websiteId: string; + sessionId: string; + chunkIndex: number; + events: Uint8Array; + eventCount: number; + startedAt: Date; + endedAt: Date; +} + +export async function getReplayChunks(websiteId: string, sessionId: string) { + return prisma.client.sessionReplay.findMany({ + where: { + websiteId, + sessionId, + }, + orderBy: { + chunkIndex: 'asc', + }, + select: { + events: true, + chunkIndex: true, + eventCount: true, + startedAt: true, + endedAt: true, + }, + }); +} + +export async function createReplayChunk({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: CreateReplayChunkArgs) { + return prisma.client.sessionReplay.create({ + data: { + id: uuid(), + websiteId, + sessionId, + chunkIndex, + events: new Uint8Array(events) as any, + eventCount, + startedAt, + endedAt, + }, + }); +} + +export async function deleteReplaysByWebsite(websiteId: string) { + return prisma.client.sessionReplay.deleteMany({ + where: { websiteId }, + }); +} diff --git a/src/queries/sql/events/getEventStats.ts b/src/queries/sql/events/getEventStats.ts index 2caba3268..9c1f5c126 100644 --- a/src/queries/sql/events/getEventStats.ts +++ b/src/queries/sql/events/getEventStats.ts @@ -1,5 +1,4 @@ import clickhouse from '@/lib/clickhouse'; -import { EVENT_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -36,7 +35,6 @@ async function relationalQuery( const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, - eventType: EVENT_TYPE.customEvent, }); const limitQuery = limit @@ -63,6 +61,7 @@ async function relationalQuery( ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = 2 ${filterQuery} ${limitQuery} group by 1, 2 @@ -84,7 +83,6 @@ async function clickhouseQuery( const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, - eventType: EVENT_TYPE.customEvent, }); const limitQuery = limit @@ -93,7 +91,7 @@ async function clickhouseQuery( from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = {eventType:UInt32} + and event_type = 2 group by event_name order by count(*) desc limit ${limit} @@ -112,6 +110,7 @@ async function clickhouseQuery( ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2 ${filterQuery} ${limitQuery} group by x, t @@ -129,7 +128,7 @@ async function clickhouseQuery( from website_event_stats_hourly website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = {eventType:UInt32} + and event_type = 2 ${limitQuery} ) as g group by x, t diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index dc4cb20c5..45a6149d5 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -25,10 +25,9 @@ export * from './pageviews/getPageviewMetrics'; export * from './pageviews/getPageviewStats'; export * from './performance/getPerformanceStats'; export * from './performance/savePerformance'; -export * from './replays/deleteReplaysByWebsite'; export * from './replays/getReplayChunks'; export * from './replays/getSessionReplays'; -export * from './replays/saveReplayChunk'; +export * from './replays/saveRecording'; export * from './reports/getBreakdown'; export * from './reports/getFunnel'; export * from './reports/getJourney'; diff --git a/src/queries/sql/replays/deleteReplaysByWebsite.ts b/src/queries/sql/replays/deleteReplaysByWebsite.ts deleted file mode 100644 index c3e37376c..000000000 --- a/src/queries/sql/replays/deleteReplaysByWebsite.ts +++ /dev/null @@ -1,11 +0,0 @@ -import prisma from '@/lib/prisma'; - -export async function deleteReplaysByWebsite(websiteId: string) { - return relationalQuery(websiteId); -} - -async function relationalQuery(websiteId: string) { - return prisma.client.sessionReplay.deleteMany({ - where: { websiteId }, - }); -} diff --git a/src/queries/sql/replays/getReplayChunks.ts b/src/queries/sql/replays/getReplayChunks.ts index 0c7739d54..5bca92d3e 100644 --- a/src/queries/sql/replays/getReplayChunks.ts +++ b/src/queries/sql/replays/getReplayChunks.ts @@ -1,24 +1,93 @@ +import { gunzipSync } from 'node:zlib'; +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -export async function getReplayChunks(websiteId: string, sessionId: string) { - return relationalQuery(websiteId, sessionId); +const FUNCTION_NAME = 'getReplayChunks'; + +export interface ReplayChunk { + events: any[]; + chunkIndex: number; + eventCount: number; + startedAt: Date; + endedAt: Date; } -async function relationalQuery(websiteId: string, sessionId: string) { - return prisma.client.sessionReplay.findMany({ - where: { - websiteId, - sessionId, - }, - orderBy: { - chunkIndex: 'asc', - }, - select: { - events: true, - chunkIndex: true, - eventCount: true, - startedAt: true, - endedAt: true, - }, +export async function getReplayChunks( + websiteId: string, + sessionId: string, +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(websiteId, sessionId), + [CLICKHOUSE]: () => clickhouseQuery(websiteId, sessionId), }); } + +async function relationalQuery(websiteId: string, sessionId: string): Promise { + const { rawQuery } = prisma; + + const chunks: { + events: Buffer; + chunkIndex: number; + eventCount: number; + startedAt: Date; + endedAt: Date; + }[] = await rawQuery( + ` + select + events, + chunk_index as "chunkIndex", + event_count as "eventCount", + started_at as "startedAt", + ended_at as "endedAt" + from session_replay + where website_id = {{websiteId::uuid}} + and session_id = {{sessionId::uuid}} + order by chunk_index asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); + + return chunks.map(chunk => ({ + ...chunk, + events: JSON.parse(gunzipSync(Buffer.from(chunk.events)).toString('utf-8')), + })); +} + +async function clickhouseQuery(websiteId: string, sessionId: string): Promise { + const { rawQuery } = clickhouse; + + const results = await rawQuery< + { + events: string; + chunk_index: number; + event_count: number; + started_at: string; + ended_at: string; + }[] + >( + ` + select + events, + chunk_index, + event_count, + started_at, + ended_at + from session_replay + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + order by chunk_index asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); + + return results.map(row => ({ + events: JSON.parse(row.events), + chunkIndex: row.chunk_index, + eventCount: row.event_count, + startedAt: new Date(row.started_at), + endedAt: new Date(row.ended_at), + })); +} diff --git a/src/queries/sql/replays/getSessionReplays.ts b/src/queries/sql/replays/getSessionReplays.ts index 7cd403da5..7f650acff 100644 --- a/src/queries/sql/replays/getSessionReplays.ts +++ b/src/queries/sql/replays/getSessionReplays.ts @@ -1,32 +1,42 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionReplays'; -export async function getSessionReplays(...args: [websiteId: string, filters: QueryFilters]) { - return relationalQuery(...args); +export function getSessionReplays(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); } async function relationalQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters } = prisma; - const { search, startDate, endDate } = filters; - const { queryParams } = parseFilters({ + const { search } = filters; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, search: search ? `%${search}%` : undefined, }); - let dateQuery = ''; - if (startDate && endDate) { - dateQuery = `and sr.created_at between {{startDate}} and {{endDate}}`; - } else if (startDate) { - dateQuery = `and sr.created_at >= {{startDate}}`; - } + const joinQuery = + filterQuery || cohortQuery + ? `join (select * + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) website_event + on website_event.website_id = sr.website_id + and website_event.session_id = sr.session_id` + : ''; const searchQuery = search ? `and (session.distinct_id ilike {{search}} or session.city ilike {{search}} - or session.browser ilike {{search}})` + or session.browser ilike {{search}} + or session.os ilike {{search}} + or session.device ilike {{search}})` : ''; return pagedRawQuery( @@ -46,10 +56,13 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { (extract(epoch from max(sr.ended_at) - min(sr.started_at)) * 1000)::bigint as "duration", max(sr.created_at) as "createdAt" from session_replay sr - left join session on session.session_id = sr.session_id + join session on session.session_id = sr.session_id and session.website_id = sr.website_id + ${cohortQuery} + ${joinQuery} where sr.website_id = {{websiteId::uuid}} - ${dateQuery} + and sr.created_at between {{startDate}} and {{endDate}} + ${filterQuery} ${searchQuery} group by sr.session_id, sr.website_id, @@ -65,3 +78,58 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { FUNCTION_NAME, ); } + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters } = clickhouse; + const { search } = filters; + const { queryParams, cohortQuery, filterQuery } = parseFilters({ + ...filters, + websiteId, + }); + + const searchQuery = search + ? `and ((positionCaseInsensitive(distinct_id, {search:String}) > 0) + or (positionCaseInsensitive(city, {search:String}) > 0) + or (positionCaseInsensitive(browser, {search:String}) > 0) + or (positionCaseInsensitive(os, {search:String}) > 0) + or (positionCaseInsensitive(device, {search:String}) > 0))` + : ''; + + return pagedRawQuery( + ` + select + session_replay.session_id as id, + session_replay.website_id as websiteId, + website_event.browser, + website_event.os, + website_event.device, + website_event.country, + website_event.city, + sum(session_replay.event_count) as eventCount, + count(session_replay.replay_id) as chunkCount, + min(session_replay.started_at) as startedAt, + max(session_replay.ended_at) as endedAt, + toInt64(dateDiff('millisecond', min(session_replay.started_at), max(session_replay.ended_at))) as duration, + max(session_replay.created_at) as createdAt + from session_replay + join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ) website_event + on website_event.session_id = session_replay.session_id + and website_event.website_id = session_replay.website_id + ${cohortQuery} + where session_replay.website_id = {websiteId:UUID} + and session_replay.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${searchQuery} + group by session_replay.session_id, session_replay.website_id, website_event.browser, website_event.os, website_event.device, website_event.country, website_event.city + order by max(created_at) desc + `, + queryParams, + filters, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/replays/index.ts b/src/queries/sql/replays/index.ts deleted file mode 100644 index d35828122..000000000 --- a/src/queries/sql/replays/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './deleteReplaysByWebsite'; -export * from './getReplayChunks'; -export * from './getSessionReplays'; -export * from './saveReplayChunk'; diff --git a/src/queries/sql/replays/saveRecording.ts b/src/queries/sql/replays/saveRecording.ts new file mode 100644 index 000000000..5c3b520b0 --- /dev/null +++ b/src/queries/sql/replays/saveRecording.ts @@ -0,0 +1,72 @@ +import { gzipSync } from 'node:zlib'; +import clickhouse from '@/lib/clickhouse'; +import { uuid } from '@/lib/crypto'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +export interface SaveRecordingArgs { + websiteId: string; + sessionId: string; + chunkIndex: number; + events: any[]; + eventCount: number; + startedAt: Date; + endedAt: Date; +} + +export async function saveRecording(args: SaveRecordingArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(args), + [CLICKHOUSE]: () => clickhouseQuery(args), + }); +} + +async function relationalQuery({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: SaveRecordingArgs) { + const compressed = gzipSync(Buffer.from(JSON.stringify(events), 'utf-8')); + + return prisma.client.sessionReplay.create({ + data: { + id: uuid(), + websiteId, + sessionId, + chunkIndex, + events: compressed as any, + eventCount, + startedAt, + endedAt, + }, + }); +} + +async function clickhouseQuery({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: SaveRecordingArgs) { + const { insert, getUTCString } = clickhouse; + + return insert('session_replay', [ + { + replay_id: uuid(), + website_id: websiteId, + session_id: sessionId, + chunk_index: chunkIndex, + events: JSON.stringify(events), + event_count: eventCount, + started_at: getUTCString(startedAt), + ended_at: getUTCString(endedAt), + }, + ]); +} diff --git a/src/queries/sql/replays/saveReplayChunk.ts b/src/queries/sql/replays/saveReplayChunk.ts deleted file mode 100644 index 9f34491bb..000000000 --- a/src/queries/sql/replays/saveReplayChunk.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { uuid } from '@/lib/crypto'; -import prisma from '@/lib/prisma'; - -export interface SaveReplayChunkArgs { - websiteId: string; - sessionId: string; - chunkIndex: number; - events: Uint8Array; - eventCount: number; - startedAt: Date; - endedAt: Date; -} - -export async function saveReplayChunk(args: SaveReplayChunkArgs) { - return relationalQuery(args); -} - -async function relationalQuery({ - websiteId, - sessionId, - chunkIndex, - events, - eventCount, - startedAt, - endedAt, -}: SaveReplayChunkArgs) { - return prisma.client.sessionReplay.create({ - data: { - id: uuid(), - websiteId, - sessionId, - chunkIndex, - events: new Uint8Array(events) as any, - eventCount, - startedAt, - endedAt, - }, - }); -} diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts index a12e6c614..a4615ee40 100644 --- a/src/queries/sql/sessions/getWebsiteSessionStats.ts +++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts @@ -70,7 +70,7 @@ async function clickhouseQuery( uniq(session_id) as "visitors", uniq(visit_id) as "visits", uniq(country) as "countries", - sum(length(event_name)) as "events" + sumIf(1, event_type = 2) as "events" from website_event ${cohortQuery} where website_id = {websiteId:UUID} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index df640d6d8..8efea80df 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -48,6 +48,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { max(website_event.created_at) as "lastAt", count(distinct website_event.visit_id) as "visits", sum(case when website_event.event_type = 1 then 1 else 0 end) as "views", + sum(case when website_event.event_type = 2 then 1 else 0 end) as "events", max(website_event.created_at) as "createdAt" from website_event ${cohortQuery} @@ -112,6 +113,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('max(created_at)')} as lastAt, uniq(visit_id) as visits, sumIf(1, event_type = 1) as views, + sumIf(1, event_type = 2) as events, lastAt as createdAt from website_event ${cohortQuery} @@ -140,6 +142,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('max(max_time)')} as lastAt, uniq(visit_id) as visits, sumIf(views, event_type = 1) as views, + sum(length(event_name)) as events, lastAt as createdAt from website_event_stats_hourly as website_event ${cohortQuery} From 103f9321095a7bcd21bfbf25f37092964fb248e9 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 13:48:31 -0800 Subject: [PATCH 2/8] add ReplayModal, polish player css and dark mode bug, mobile-friendly --- .../[websiteId]/replays/ReplayModal.tsx | 43 +++++++++++++++++++ .../[websiteId]/replays/ReplaysDataTable.tsx | 2 +- .../[websiteId]/replays/ReplaysPage.tsx | 2 + .../[websiteId]/replays/ReplaysTable.tsx | 16 +++---- .../replays/[sessionId]/ReplayPlayback.tsx | 38 +++++++++++----- .../replays/[sessionId]/ReplayPlayer.tsx | 17 +++++--- src/app/global.css | 13 ++++++ 7 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx new file mode 100644 index 000000000..362724bf5 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx @@ -0,0 +1,43 @@ +'use client'; +import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen'; +import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback'; +import { useNavigation } from '@/components/hooks'; + +export interface ReplayModalProps extends ModalProps { + websiteId: string; +} + +export function ReplayModal({ websiteId, ...props }: ReplayModalProps) { + const { + router, + query: { replay }, + updateParams, + } = useNavigation(); + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + router.push(updateParams({ replay: undefined })); + } + }; + + return ( + + + + {({ close }) => ( + + + + )} + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysDataTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysDataTable.tsx index 19e195d80..6d047081d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysDataTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysDataTable.tsx @@ -8,7 +8,7 @@ export function ReplaysDataTable({ websiteId }: { websiteId: string }) { return ( {({ data }) => { - return ; + return ; }} ); diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index d4127fc6d..414fae0fb 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -3,6 +3,7 @@ import { Column } from '@umami/react-zen'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; +import { ReplayModal } from './ReplayModal'; import { ReplaysDataTable } from './ReplaysDataTable'; export function ReplaysPage({ websiteId }: { websiteId: string }) { @@ -13,6 +14,7 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) { + ); } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx index 5549d3ddc..2ab8f860d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -13,10 +13,10 @@ function formatDuration(ms: number) { return `${minutes}:${secs.toString().padStart(2, '0')}`; } -export function ReplaysTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) { +export function ReplaysTable({ ...props }: DataTableProps) { const { t, labels } = useMessages(); const { formatValue } = useFormat(); - const { updateParams } = useNavigation(); + const { router, updateParams } = useNavigation(); return ( @@ -65,13 +65,11 @@ export function ReplaysTable({ websiteId, ...props }: DataTableProps & { website {(row: any) => ( - - - + )} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx index 9dfcf3793..5046f144a 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx @@ -1,12 +1,21 @@ 'use client'; -import { Column, Row, Text } from '@umami/react-zen'; +import { Button, Column, Icon, Row, Text } from '@umami/react-zen'; +import { X } from 'lucide-react'; import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo'; import { Avatar } from '@/components/common/Avatar'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks'; import { ReplayPlayer } from './ReplayPlayer'; -export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { +export function ReplayPlayback({ + websiteId, + sessionId, + onClose, +}: { + websiteId: string; + sessionId: string; + onClose?: () => void; +}) { const { data: replay, isLoading, error } = useReplayQuery(websiteId, sessionId); const { data: session } = useWebsiteSessionQuery(websiteId, sessionId); const { t, labels } = useMessages(); @@ -22,14 +31,23 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se {replay && ( {session && ( - - - - {t(labels.replay)} - - {replay.eventCount} {t(labels.actions).toLowerCase()} - - + + + + + {t(labels.replay)} + + {replay.eventCount} {t(labels.actions).toLowerCase()} + + + + {onClose && ( + + )} )} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx index bb2a99a35..dfc3a12f0 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx @@ -1,21 +1,24 @@ 'use client'; import { Column } from '@umami/react-zen'; import { useEffect, useRef, useState } from 'react'; +import { useMobile } from '@/components/hooks'; import 'rrweb-player/dist/style.css'; export function ReplayPlayer({ events }: { events: any[] }) { const containerRef = useRef(null); const playerRef = useRef(null); const [loaded, setLoaded] = useState(false); + const { isMobile, isPhone } = useMobile(); + + const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024; + const playerHeight = isPhone ? 202 : isMobile ? 360 : 576; useEffect(() => { if (!containerRef.current || !events?.length) return; - // Dynamically import rrweb-player to avoid SSR issues import('rrweb-player').then(mod => { const RRWebPlayer = mod.default; - // Clear any previous player if (containerRef.current) { containerRef.current.innerHTML = ''; } @@ -24,8 +27,8 @@ export function ReplayPlayer({ events }: { events: any[] }) { target: containerRef.current, props: { events, - width: 1024, - height: 576, + width: playerWidth, + height: playerHeight, autoPlay: false, showController: true, speedOption: [1, 2, 4, 8], @@ -41,15 +44,15 @@ export function ReplayPlayer({ events }: { events: any[] }) { playerRef.current = null; } }; - }, [events]); + }, [events, playerWidth, playerHeight]); return (
Date: Tue, 3 Mar 2026 14:30:51 -0800 Subject: [PATCH 3/8] remove large gaps from same session recording --- .../replays/[sessionId]/ReplayPlayer.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx index dfc3a12f0..ba5d66983 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx @@ -4,6 +4,24 @@ import { useEffect, useRef, useState } from 'react'; import { useMobile } from '@/components/hooks'; import 'rrweb-player/dist/style.css'; +function normalizeEvents(events: any[], maxGapMs = 60_000): any[] { + if (events.length < 2) return events; + + const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); + let shift = 0; + const result: any[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i].timestamp - sorted[i - 1].timestamp; + if (gap > maxGapMs) { + shift += gap - 1000; + } + result.push(shift > 0 ? { ...sorted[i], timestamp: sorted[i].timestamp - shift } : sorted[i]); + } + + return result; +} + export function ReplayPlayer({ events }: { events: any[] }) { const containerRef = useRef(null); const playerRef = useRef(null); @@ -26,7 +44,7 @@ export function ReplayPlayer({ events }: { events: any[] }) { playerRef.current = new RRWebPlayer({ target: containerRef.current, props: { - events, + events: normalizeEvents(events), width: playerWidth, height: playerHeight, autoPlay: false, From 4ecef2e290a9b81d86b880981e2a80d6a631ee24 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 16:00:39 -0800 Subject: [PATCH 4/8] add better metrics to revenue page AOV/ARPU --- public/intl/messages/en-US.json | 2 ++ .../[websiteId]/(reports)/revenue/Revenue.tsx | 10 +++++++-- .../(reports)/revenue/RevenueTable.tsx | 21 ------------------ src/components/messages.ts | 2 ++ src/queries/sql/reports/getRevenueStats.ts | 22 ++++++++++++++++--- 5 files changed, 31 insertions(+), 26 deletions(-) delete mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index d455978f5..e68c428a5 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -332,6 +332,8 @@ "unique": "Unique", "unique-events": "Unique events", "unique-visitors": "Unique visitors", + "aov": "AOV", + "arpu": "ARPU", "uniqueCustomers": "Unique Customers", "unknown": "Unknown", "untitled": "Untitled", diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx index abf06aead..c45d0b8b4 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -44,7 +44,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { const metrics = useMemo(() => { if (!data) return []; - const { sum, count, average, unique_count, comparison } = data.total; + const { sum, count, average, unique_count, arpu, comparison } = data.total; return [ { @@ -55,10 +55,16 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { }, { value: average, - label: t(labels.average), + label: t(labels.aov), change: comparison ? average - comparison.average : 0, formatValue: (n: number) => formatLongCurrency(n, currency), }, + { + value: arpu, + label: t(labels.arpu), + change: comparison ? arpu - (comparison.arpu ?? 0) : 0, + formatValue: (n: number) => formatLongCurrency(n, currency), + }, { value: count, label: t(labels.transactions), diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx deleted file mode 100644 index f8164147f..000000000 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { DataColumn, DataTable } from '@umami/react-zen'; -import { useMessages } from '@/components/hooks'; -import { formatLongCurrency } from '@/lib/format'; - -export function RevenueTable({ data = [] }) { - const { t, labels } = useMessages(); - - return ( - - - - {(row: any) => formatLongCurrency(row.sum, row.currency)} - - - {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)} - - - - - ); -} diff --git a/src/components/messages.ts b/src/components/messages.ts index 9b403b43b..da1c81aac 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -255,6 +255,8 @@ export const labels: Record = { myAccount: 'label.my-account', transfer: 'label.transfer', transactions: 'label.transactions', + aov: 'label.aov', + arpu: 'label.arpu', uniqueCustomers: 'label.uniqueCustomers', viewedPage: 'message.viewed-page', collectedData: 'message.collected-data', diff --git a/src/queries/sql/reports/getRevenueStats.ts b/src/queries/sql/reports/getRevenueStats.ts index c0eac90fe..116166133 100644 --- a/src/queries/sql/reports/getRevenueStats.ts +++ b/src/queries/sql/reports/getRevenueStats.ts @@ -9,6 +9,7 @@ export interface RevenueStatsResult { count: number; average: number; unique_count: number; + arpu: number; } export async function getRevenueStats( @@ -52,7 +53,11 @@ async function relationalQuery( select sum(revenue.revenue) as sum, count(distinct revenue.event_id) as count, - count(distinct revenue.session_id) as unique_count + count(distinct revenue.session_id) as unique_count, + (select count(distinct session_id) + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) as total_sessions from revenue ${joinQuery} ${cohortQuery} @@ -66,6 +71,7 @@ async function relationalQuery( ).then(result => result?.[0]); total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; + total.arpu = total.total_sessions > 0 ? Number(total.sum) / Number(total.total_sessions) : 0; return total; } @@ -97,12 +103,21 @@ async function clickhouseQuery( and website_event.event_id = website_revenue.event_id` : ''; - const total = await rawQuery<{ sum: number; count: number; unique_count: number }>( + const total = await rawQuery<{ + sum: number; + count: number; + unique_count: number; + total_sessions: number; + }>( ` select sum(website_revenue.revenue) as sum, uniqExact(website_revenue.event_id) as count, - uniqExact(website_revenue.session_id) as unique_count + uniqExact(website_revenue.session_id) as unique_count, + (select uniqExact(session_id) + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}) as total_sessions from website_revenue ${joinQuery} ${cohortQuery} @@ -115,6 +130,7 @@ async function clickhouseQuery( ).then(result => result?.[0]); total.average = total.count > 0 ? total.sum / total.count : 0; + total.arpu = total.total_sessions > 0 ? total.sum / total.total_sessions : 0; return total; } From b2d45613b5847e45f8fc76dc17233f0de5fd8253 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 16:28:09 -0800 Subject: [PATCH 5/8] move stitching calculation to server side --- .../replays/[sessionId]/ReplayPlayer.tsx | 20 +------------------ .../[websiteId]/replays/[sessionId]/route.ts | 3 ++- src/lib/replay.ts | 17 ++++++++++++++++ src/queries/sql/replays/getSessionReplays.ts | 4 ++-- 4 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 src/lib/replay.ts diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx index ba5d66983..04bb2345a 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx @@ -4,24 +4,6 @@ import { useEffect, useRef, useState } from 'react'; import { useMobile } from '@/components/hooks'; import 'rrweb-player/dist/style.css'; -function normalizeEvents(events: any[], maxGapMs = 60_000): any[] { - if (events.length < 2) return events; - - const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); - let shift = 0; - const result: any[] = [sorted[0]]; - - for (let i = 1; i < sorted.length; i++) { - const gap = sorted[i].timestamp - sorted[i - 1].timestamp; - if (gap > maxGapMs) { - shift += gap - 1000; - } - result.push(shift > 0 ? { ...sorted[i], timestamp: sorted[i].timestamp - shift } : sorted[i]); - } - - return result; -} - export function ReplayPlayer({ events }: { events: any[] }) { const containerRef = useRef(null); const playerRef = useRef(null); @@ -44,7 +26,7 @@ export function ReplayPlayer({ events }: { events: any[] }) { playerRef.current = new RRWebPlayer({ target: containerRef.current, props: { - events: normalizeEvents(events), + events: events, width: playerWidth, height: playerHeight, autoPlay: false, diff --git a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts index 01e3a91aa..1cca77510 100644 --- a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts @@ -1,3 +1,4 @@ +import { stitchChunkEvents } from '@/lib/replay'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; @@ -21,7 +22,7 @@ export async function GET( const chunks = await getReplayChunks(websiteId, sessionId); - const allEvents = chunks.flatMap(chunk => chunk.events); + const allEvents = stitchChunkEvents(chunks); const startedAt = chunks.length > 0 ? chunks[0].startedAt : null; const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null; diff --git a/src/lib/replay.ts b/src/lib/replay.ts new file mode 100644 index 000000000..8a3c7574c --- /dev/null +++ b/src/lib/replay.ts @@ -0,0 +1,17 @@ +import type { ReplayChunk } from '@/queries/sql'; + +export function stitchChunkEvents(chunks: ReplayChunk[]): any[] { + if (!chunks.length) return []; + const sorted = [...chunks].sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); + const result: any[] = []; + let timeOffset = 0; + for (const chunk of sorted) { + const chunkStartMs = chunk.startedAt.getTime(); + const events = [...chunk.events].sort((a, b) => a.timestamp - b.timestamp); + for (const event of events) { + result.push({ ...event, timestamp: timeOffset + (event.timestamp - chunkStartMs) }); + } + timeOffset += chunk.endedAt.getTime() - chunkStartMs + 1000; + } + return result; +} diff --git a/src/queries/sql/replays/getSessionReplays.ts b/src/queries/sql/replays/getSessionReplays.ts index 7f650acff..ce4c49e4c 100644 --- a/src/queries/sql/replays/getSessionReplays.ts +++ b/src/queries/sql/replays/getSessionReplays.ts @@ -53,7 +53,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { count(sr.replay_id) as "chunkCount", min(sr.started_at) as "startedAt", max(sr.ended_at) as "endedAt", - (extract(epoch from max(sr.ended_at) - min(sr.started_at)) * 1000)::bigint as "duration", + sum(extract(epoch from sr.ended_at - sr.started_at) * 1000)::bigint as "duration", max(sr.created_at) as "createdAt" from session_replay sr join session on session.session_id = sr.session_id @@ -109,7 +109,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { count(session_replay.replay_id) as chunkCount, min(session_replay.started_at) as startedAt, max(session_replay.ended_at) as endedAt, - toInt64(dateDiff('millisecond', min(session_replay.started_at), max(session_replay.ended_at))) as duration, + toInt64(sum(dateDiff('millisecond', session_replay.started_at, session_replay.ended_at))) as duration, max(session_replay.created_at) as createdAt from session_replay join ( From 0e1d6cef6b9b70017dcbb514675dbdfbda6bef6d Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 22:59:25 -0800 Subject: [PATCH 6/8] refactor session replays to be per visit. sessions persist too long resulting in low-quality recordings --- .../migrations/10_add_session_recording.sql | 3 +- db/clickhouse/schema.sql | 3 +- .../19_add_session_recording/migration.sql | 2 + prisma/schema.prisma | 2 + public/intl/messages/en-US.json | 1 + .../[websiteId]/replays/ReplayModal.tsx | 4 +- .../[websiteId]/replays/ReplaysTable.tsx | 4 +- .../ReplayPlayback.tsx | 10 ++-- .../ReplayPlayer.tsx | 0 .../{[sessionId] => [replayId]}/page.tsx | 6 +-- .../[websiteId]/sessions/SessionProfile.tsx | 15 +++--- .../sessions/SessionReplaysDataTable.tsx | 45 +++++++++++++++++ .../sessions/SessionReplaysTable.tsx | 44 +++++++++++++++++ src/app/api/record/route.ts | 3 +- .../{[sessionId] => [replayId]}/route.ts | 11 +++-- .../sessions/[sessionId]/replays/route.ts | 33 +++++++++++++ src/components/hooks/index.ts | 1 + .../hooks/queries/useReplayQuery.ts | 8 +-- .../hooks/queries/useSessionReplaysQuery.ts | 21 ++++++++ .../hooks/queries/useWebsiteSessionQuery.ts | 2 +- src/components/messages.ts | 1 + src/lib/replay.ts | 17 ------- src/queries/prisma/sessionReplay.ts | 8 ++- src/queries/sql/replays/getReplayChunks.ts | 33 ++++++++----- src/queries/sql/replays/getSessionReplays.ts | 49 ++++++++++++------- src/queries/sql/replays/saveRecording.ts | 5 ++ 26 files changed, 248 insertions(+), 83 deletions(-) rename src/app/(main)/websites/[websiteId]/replays/{[sessionId] => [replayId]}/ReplayPlayback.tsx (89%) rename src/app/(main)/websites/[websiteId]/replays/{[sessionId] => [replayId]}/ReplayPlayer.tsx (100%) rename src/app/(main)/websites/[websiteId]/replays/{[sessionId] => [replayId]}/page.tsx (53%) create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionReplaysDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx rename src/app/api/websites/[websiteId]/replays/{[sessionId] => [replayId]}/route.ts (68%) create mode 100644 src/app/api/websites/[websiteId]/sessions/[sessionId]/replays/route.ts create mode 100644 src/components/hooks/queries/useSessionReplaysQuery.ts delete mode 100644 src/lib/replay.ts diff --git a/db/clickhouse/migrations/10_add_session_recording.sql b/db/clickhouse/migrations/10_add_session_recording.sql index b873bfc5f..c6df19c53 100644 --- a/db/clickhouse/migrations/10_add_session_recording.sql +++ b/db/clickhouse/migrations/10_add_session_recording.sql @@ -4,6 +4,7 @@ CREATE TABLE umami.session_replay replay_id UUID, website_id UUID, session_id UUID, + visit_id UUID, chunk_index UInt32, events String CODEC(ZSTD(3)), event_count UInt32, @@ -13,5 +14,5 @@ CREATE TABLE umami.session_replay ) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at) -ORDER BY (replay_id, website_id, session_id, chunk_index) +ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index) SETTINGS index_granularity = 8192; \ No newline at end of file diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 1050cdf8a..ae08bb7b3 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -361,6 +361,7 @@ CREATE TABLE umami.session_replay replay_id UUID, website_id UUID, session_id UUID, + visit_id UUID, chunk_index UInt32, events String CODEC(ZSTD(3)), event_count UInt32, @@ -370,5 +371,5 @@ CREATE TABLE umami.session_replay ) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at) -ORDER BY (replay_id, website_id, session_id, chunk_index) +ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index) SETTINGS index_granularity = 8192; \ No newline at end of file diff --git a/prisma/migrations/19_add_session_recording/migration.sql b/prisma/migrations/19_add_session_recording/migration.sql index 088a58095..c8720c0ee 100644 --- a/prisma/migrations/19_add_session_recording/migration.sql +++ b/prisma/migrations/19_add_session_recording/migration.sql @@ -7,6 +7,7 @@ CREATE TABLE "session_replay" ( "replay_id" UUID NOT NULL, "website_id" UUID NOT NULL, "session_id" UUID NOT NULL, + "visit_id" UUID NOT NULL, "chunk_index" INTEGER NOT NULL, "events" BYTEA NOT NULL, "event_count" INTEGER NOT NULL, @@ -21,5 +22,6 @@ CREATE TABLE "session_replay" ( CREATE INDEX "session_replay_website_id_idx" ON "session_replay"("website_id"); CREATE INDEX "session_replay_session_id_idx" ON "session_replay"("session_id"); CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("website_id", "session_id"); +CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id"); CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at"); CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcb6a86b3..59fad07ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -385,6 +385,7 @@ model SessionReplay { id String @id() @map("replay_id") @db.Uuid websiteId String @map("website_id") @db.Uuid sessionId String @map("session_id") @db.Uuid + visitId String @map("visit_id") @db.Uuid chunkIndex Int @map("chunk_index") @db.Integer events Bytes @map("events") eventCount Int @map("event_count") @db.Integer @@ -397,6 +398,7 @@ model SessionReplay { @@index([websiteId]) @@index([sessionId]) @@index([websiteId, sessionId]) + @@index([websiteId, visitId]) @@index([websiteId, createdAt]) @@index([sessionId, chunkIndex]) @@map("session_replay") diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index e68c428a5..25a94f3b4 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -251,6 +251,7 @@ "remove": "Remove", "remove-member": "Remove member", "replay": "Replay", + "replay-id": "Replay ID", "replay-enabled": "Replay enabled", "replays": "Replays", "reports": "Reports", diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx index 362724bf5..7127a613d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx @@ -1,6 +1,6 @@ 'use client'; import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen'; -import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback'; +import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback'; import { useNavigation } from '@/components/hooks'; export interface ReplayModalProps extends ModalProps { @@ -33,7 +33,7 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) { {({ close }) => ( - + )} diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx index 2ab8f860d..3a39cea1e 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -22,8 +22,8 @@ export function ReplaysTable({ ...props }: DataTableProps) { {(row: any) => ( - - + + )} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx similarity index 89% rename from src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx rename to src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx index 5046f144a..3331bbbc0 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx @@ -9,15 +9,15 @@ import { ReplayPlayer } from './ReplayPlayer'; export function ReplayPlayback({ websiteId, - sessionId, + replayId, onClose, }: { websiteId: string; - sessionId: string; + replayId: string; onClose?: () => void; }) { - const { data: replay, isLoading, error } = useReplayQuery(websiteId, sessionId); - const { data: session } = useWebsiteSessionQuery(websiteId, sessionId); + const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId); + const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId); const { t, labels } = useMessages(); return ( @@ -33,7 +33,7 @@ export function ReplayPlayback({ {session && ( - + {t(labels.replay)} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx similarity index 100% rename from src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx rename to src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/page.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/page.tsx similarity index 53% rename from src/app/(main)/websites/[websiteId]/replays/[sessionId]/page.tsx rename to src/app/(main)/websites/[websiteId]/replays/[replayId]/page.tsx index c92528135..7a6502e5d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/page.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/page.tsx @@ -4,11 +4,11 @@ import { ReplayPlayback } from './ReplayPlayback'; export default async function ({ params, }: { - params: Promise<{ websiteId: string; sessionId: string }>; + params: Promise<{ websiteId: string; replayId: string }>; }) { - const { websiteId, sessionId } = await params; + const { websiteId, replayId } = await params; - return ; + return ; } export const metadata: Metadata = { diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx index 146dbc6b3..809a90178 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -10,13 +10,13 @@ import { TextField, } from '@umami/react-zen'; import { X } from 'lucide-react'; -import { ReplayPlayer } from '@/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer'; import { Avatar } from '@/components/common/Avatar'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks'; +import { useMessages, useWebsiteSessionQuery } from '@/components/hooks'; import { SessionActivity } from './SessionActivity'; import { SessionData } from './SessionData'; import { SessionInfo } from './SessionInfo'; +import { SessionReplaysDataTable } from './SessionReplaysDataTable'; import { SessionStats } from './SessionStats'; export function SessionProfile({ @@ -29,7 +29,6 @@ export function SessionProfile({ onClose?: () => void; }) { const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); - const { data: replay } = useReplayQuery(websiteId, sessionId); const { t, labels } = useMessages(); return ( @@ -65,7 +64,7 @@ export function SessionProfile({ {t(labels.activity)} {t(labels.properties)} - {replay?.events?.length > 0 && {t(labels.replay)}} + {t(labels.replay)} - {replay?.events?.length > 0 && ( - - - - )} + + + diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysDataTable.tsx new file mode 100644 index 000000000..6ce2b0449 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysDataTable.tsx @@ -0,0 +1,45 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { useState } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useReplayQuery, useSessionReplaysQuery } from '@/components/hooks'; +import { ReplayPlayer } from '../replays/[replayId]/ReplayPlayer'; +import { SessionReplaysTable } from './SessionReplaysTable'; + +function InlinePlayer({ websiteId, replayId }: { websiteId: string; replayId: string }) { + const { data: replay } = useReplayQuery(websiteId, replayId); + + if (!replay?.events?.length) return null; + + return ( +
+ +
+ ); +} + +export function SessionReplaysDataTable({ + websiteId, + sessionId, +}: { + websiteId: string; + sessionId: string; +}) { + const queryResult = useSessionReplaysQuery(websiteId, sessionId); + const [selectedId, setSelectedId] = useState(); + + const handlePlay = (id: string) => { + setSelectedId(prev => (prev === id ? undefined : id)); + }; + + return ( + + {selectedId && } + + {({ data }) => ( + + )} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx new file mode 100644 index 000000000..a2cf3ef7b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionReplaysTable.tsx @@ -0,0 +1,44 @@ +import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen'; +import { Play } from 'lucide-react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages } from '@/components/hooks'; + +function formatDuration(ms: number) { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +export function SessionReplaysTable({ + onPlay, + selectedId, + ...props +}: DataTableProps & { onPlay: (id: string) => void; selectedId?: string }) { + const { t, labels } = useMessages(); + + return ( + + + + {(row: any) => formatDuration(row.duration || 0)} + + + + {(row: any) => } + + + {(row: any) => ( + + )} + + + ); +} diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 25b3924c1..9ab25f1c4 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -42,7 +42,7 @@ export async function POST(request: Request) { return badRequest({ message: 'Invalid session token.' }); } - const { sessionId } = cache; + const { sessionId, visitId } = cache; // Query directly to avoid stale Redis cache for recordingEnabled const website = await getWebsite(websiteId); @@ -80,6 +80,7 @@ export async function POST(request: Request) { await saveRecording({ websiteId, sessionId, + visitId, chunkIndex, events, eventCount: events.length, diff --git a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts similarity index 68% rename from src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts rename to src/app/api/websites/[websiteId]/replays/[replayId]/route.ts index 1cca77510..eb030c163 100644 --- a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts @@ -1,4 +1,3 @@ -import { stitchChunkEvents } from '@/lib/replay'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; @@ -6,7 +5,7 @@ import { getReplayChunks } from '@/queries/sql'; export async function GET( request: Request, - { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, + { params }: { params: Promise<{ websiteId: string; replayId: string }> }, ) { const { auth, error } = await parseRequest(request); @@ -14,19 +13,21 @@ export async function GET( return error(); } - const { websiteId, sessionId } = await params; + const { websiteId, replayId } = await params; if (!(await canViewWebsite(auth, websiteId))) { return unauthorized(); } - const chunks = await getReplayChunks(websiteId, sessionId); + const chunks = await getReplayChunks(websiteId, replayId); - const allEvents = stitchChunkEvents(chunks); + const allEvents = chunks.flatMap(chunk => chunk.events); + const sessionId = chunks.length > 0 ? chunks[0].sessionId : null; const startedAt = chunks.length > 0 ? chunks[0].startedAt : null; const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null; return json({ + sessionId, events: allEvents, startedAt, endedAt, diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/replays/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/replays/route.ts new file mode 100644 index 000000000..b71ff46a9 --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/replays/route.ts @@ -0,0 +1,33 @@ +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams, withDateRange } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionReplays } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const schema = withDateRange({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionReplays(websiteId, filters, sessionId); + + return json(data); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 47cb3efb0..4365a0cef 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -35,6 +35,7 @@ export * from './queries/useSessionActivityQuery'; export * from './queries/useSessionDataPropertiesQuery'; export * from './queries/useSessionDataQuery'; export * from './queries/useSessionDataValuesQuery'; +export * from './queries/useSessionReplaysQuery'; export * from './queries/useShareTokenQuery'; export * from './queries/useTeamMembersQuery'; export * from './queries/useTeamQuery'; diff --git a/src/components/hooks/queries/useReplayQuery.ts b/src/components/hooks/queries/useReplayQuery.ts index 8ed48bbfb..03d2ab747 100644 --- a/src/components/hooks/queries/useReplayQuery.ts +++ b/src/components/hooks/queries/useReplayQuery.ts @@ -1,13 +1,13 @@ import { useApi } from '../useApi'; -export function useReplayQuery(websiteId: string, sessionId: string) { +export function useReplayQuery(websiteId: string, replayId: string) { const { get, useQuery } = useApi(); return useQuery({ - queryKey: ['replay', { websiteId, sessionId }], + queryKey: ['replay', { websiteId, replayId }], queryFn: () => { - return get(`/websites/${websiteId}/replays/${sessionId}`); + return get(`/websites/${websiteId}/replays/${replayId}`); }, - enabled: Boolean(websiteId && sessionId), + enabled: Boolean(websiteId && replayId), }); } diff --git a/src/components/hooks/queries/useSessionReplaysQuery.ts b/src/components/hooks/queries/useSessionReplaysQuery.ts new file mode 100644 index 000000000..df4613ed5 --- /dev/null +++ b/src/components/hooks/queries/useSessionReplaysQuery.ts @@ -0,0 +1,21 @@ +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useSessionReplaysQuery(websiteId: string, sessionId: string) { + const { get } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + + return usePagedQuery({ + queryKey: ['session-replays', { websiteId, sessionId, startAt, endAt, unit, timezone }], + queryFn: pageParams => { + return get(`/websites/${websiteId}/sessions/${sessionId}/replays`, { + startAt, + endAt, + unit, + timezone, + ...pageParams, + }); + }, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionQuery.ts b/src/components/hooks/queries/useWebsiteSessionQuery.ts index 21e949114..b175662db 100644 --- a/src/components/hooks/queries/useWebsiteSessionQuery.ts +++ b/src/components/hooks/queries/useWebsiteSessionQuery.ts @@ -1,6 +1,6 @@ import { useApi } from '../useApi'; -export function useWebsiteSessionQuery(websiteId: string, sessionId: string) { +export function useWebsiteSessionQuery(websiteId: string, sessionId: string | undefined) { const { get, useQuery } = useApi(); return useQuery({ diff --git a/src/components/messages.ts b/src/components/messages.ts index da1c81aac..6bd5b3fc1 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -359,6 +359,7 @@ export const labels: Record = { sampleSize: 'label.sample-size', replays: 'label.replays', replay: 'label.replay', + replayId: 'label.replay-id', replayEnabled: 'label.replay-enabled', sampleRate: 'label.sample-rate', maskLevel: 'label.mask-level', diff --git a/src/lib/replay.ts b/src/lib/replay.ts deleted file mode 100644 index 8a3c7574c..000000000 --- a/src/lib/replay.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReplayChunk } from '@/queries/sql'; - -export function stitchChunkEvents(chunks: ReplayChunk[]): any[] { - if (!chunks.length) return []; - const sorted = [...chunks].sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); - const result: any[] = []; - let timeOffset = 0; - for (const chunk of sorted) { - const chunkStartMs = chunk.startedAt.getTime(); - const events = [...chunk.events].sort((a, b) => a.timestamp - b.timestamp); - for (const event of events) { - result.push({ ...event, timestamp: timeOffset + (event.timestamp - chunkStartMs) }); - } - timeOffset += chunk.endedAt.getTime() - chunkStartMs + 1000; - } - return result; -} diff --git a/src/queries/prisma/sessionReplay.ts b/src/queries/prisma/sessionReplay.ts index 868c4a50e..f102ac4aa 100644 --- a/src/queries/prisma/sessionReplay.ts +++ b/src/queries/prisma/sessionReplay.ts @@ -4,6 +4,7 @@ import prisma from '@/lib/prisma'; export interface CreateReplayChunkArgs { websiteId: string; sessionId: string; + visitId: string; chunkIndex: number; events: Uint8Array; eventCount: number; @@ -11,17 +12,18 @@ export interface CreateReplayChunkArgs { endedAt: Date; } -export async function getReplayChunks(websiteId: string, sessionId: string) { +export async function getReplayChunks(websiteId: string, visitId: string) { return prisma.client.sessionReplay.findMany({ where: { websiteId, - sessionId, + visitId, }, orderBy: { chunkIndex: 'asc', }, select: { events: true, + sessionId: true, chunkIndex: true, eventCount: true, startedAt: true, @@ -33,6 +35,7 @@ export async function getReplayChunks(websiteId: string, sessionId: string) { export async function createReplayChunk({ websiteId, sessionId, + visitId, chunkIndex, events, eventCount, @@ -44,6 +47,7 @@ export async function createReplayChunk({ id: uuid(), websiteId, sessionId, + visitId, chunkIndex, events: new Uint8Array(events) as any, eventCount, diff --git a/src/queries/sql/replays/getReplayChunks.ts b/src/queries/sql/replays/getReplayChunks.ts index 5bca92d3e..5c192172e 100644 --- a/src/queries/sql/replays/getReplayChunks.ts +++ b/src/queries/sql/replays/getReplayChunks.ts @@ -6,6 +6,8 @@ import prisma from '@/lib/prisma'; const FUNCTION_NAME = 'getReplayChunks'; export interface ReplayChunk { + sessionId: string; + visitId: string; events: any[]; chunkIndex: number; eventCount: number; @@ -13,20 +15,19 @@ export interface ReplayChunk { endedAt: Date; } -export async function getReplayChunks( - websiteId: string, - sessionId: string, -): Promise { +export async function getReplayChunks(websiteId: string, visitId: string): Promise { return runQuery({ - [PRISMA]: () => relationalQuery(websiteId, sessionId), - [CLICKHOUSE]: () => clickhouseQuery(websiteId, sessionId), + [PRISMA]: () => relationalQuery(websiteId, visitId), + [CLICKHOUSE]: () => clickhouseQuery(websiteId, visitId), }); } -async function relationalQuery(websiteId: string, sessionId: string): Promise { +async function relationalQuery(websiteId: string, visitId: string): Promise { const { rawQuery } = prisma; const chunks: { + sessionId: string; + visitId: string; events: Buffer; chunkIndex: number; eventCount: number; @@ -35,6 +36,8 @@ async function relationalQuery(websiteId: string, sessionId: string): Promise { +async function clickhouseQuery(websiteId: string, visitId: string): Promise { const { rawQuery } = clickhouse; const results = await rawQuery< { + sessionId: string; + visitId: string; events: string; chunk_index: number; event_count: number; @@ -69,6 +74,8 @@ async function clickhouseQuery(websiteId: string, sessionId: string): Promise( ` select + session_id as sessionId, + visit_id as visitId, events, chunk_index, event_count, @@ -76,14 +83,16 @@ async function clickhouseQuery(websiteId: string, sessionId: string): Promise ({ + sessionId: row.sessionId, + visitId: row.visitId, events: JSON.parse(row.events), chunkIndex: row.chunk_index, eventCount: row.event_count, diff --git a/src/queries/sql/replays/getSessionReplays.ts b/src/queries/sql/replays/getSessionReplays.ts index ce4c49e4c..ee9775b62 100644 --- a/src/queries/sql/replays/getSessionReplays.ts +++ b/src/queries/sql/replays/getSessionReplays.ts @@ -5,14 +5,16 @@ import type { QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionReplays'; -export function getSessionReplays(...args: [websiteId: string, filters: QueryFilters]) { +export function getSessionReplays( + ...args: [websiteId: string, filters: QueryFilters, sessionId?: string] +) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), }); } -async function relationalQuery(websiteId: string, filters: QueryFilters) { +async function relationalQuery(websiteId: string, filters: QueryFilters, sessionId?: string) { const { pagedRawQuery, parseFilters } = prisma; const { search } = filters; const { filterQuery, cohortQuery, queryParams } = parseFilters({ @@ -23,14 +25,19 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { const joinQuery = filterQuery || cohortQuery - ? `join (select * + ? `join (select distinct website_id, session_id, visit_id from website_event + ${cohortQuery} where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}}) website_event + and created_at between {{startDate}} and {{endDate}} + ${filterQuery}) website_event on website_event.website_id = sr.website_id - and website_event.session_id = sr.session_id` + and website_event.session_id = sr.session_id + and website_event.visit_id = sr.visit_id` : ''; + const sessionFilter = sessionId ? 'and sr.session_id = {{sessionId::uuid}}' : ''; + const searchQuery = search ? `and (session.distinct_id ilike {{search}} or session.city ilike {{search}} @@ -42,7 +49,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { return pagedRawQuery( ` select - sr.session_id as "id", + sr.visit_id as "id", + sr.session_id as "sessionId", sr.website_id as "websiteId", session.browser, session.os, @@ -58,13 +66,13 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { from session_replay sr join session on session.session_id = sr.session_id and session.website_id = sr.website_id - ${cohortQuery} ${joinQuery} where sr.website_id = {{websiteId::uuid}} and sr.created_at between {{startDate}} and {{endDate}} - ${filterQuery} + ${sessionFilter} ${searchQuery} - group by sr.session_id, + group by sr.visit_id, + sr.session_id, sr.website_id, session.browser, session.os, @@ -73,13 +81,13 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { session.city order by max(sr.created_at) desc `, - queryParams, + { ...queryParams, sessionId }, filters, FUNCTION_NAME, ); } -async function clickhouseQuery(websiteId: string, filters: QueryFilters) { +async function clickhouseQuery(websiteId: string, filters: QueryFilters, sessionId?: string) { const { pagedRawQuery, parseFilters } = clickhouse; const { search } = filters; const { queryParams, cohortQuery, filterQuery } = parseFilters({ @@ -87,6 +95,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { websiteId, }); + const sessionFilter = sessionId ? 'and session_replay.session_id = {sessionId:UUID}' : ''; + const searchQuery = search ? `and ((positionCaseInsensitive(distinct_id, {search:String}) > 0) or (positionCaseInsensitive(city, {search:String}) > 0) @@ -98,7 +108,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { return pagedRawQuery( ` select - session_replay.session_id as id, + session_replay.visit_id as id, + session_replay.session_id as sessionId, session_replay.website_id as websiteId, website_event.browser, website_event.os, @@ -113,22 +124,24 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { max(session_replay.created_at) as createdAt from session_replay join ( - select * + select distinct website_id, session_id, visit_id, browser, os, device, country, city from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${searchQuery} ) website_event on website_event.session_id = session_replay.session_id and website_event.website_id = session_replay.website_id - ${cohortQuery} + and website_event.visit_id = session_replay.visit_id where session_replay.website_id = {websiteId:UUID} and session_replay.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${filterQuery} - ${searchQuery} - group by session_replay.session_id, session_replay.website_id, website_event.browser, website_event.os, website_event.device, website_event.country, website_event.city + ${sessionFilter} + group by session_replay.visit_id, session_replay.session_id, session_replay.website_id, website_event.browser, website_event.os, website_event.device, website_event.country, website_event.city order by max(created_at) desc `, - queryParams, + { ...queryParams, sessionId }, filters, FUNCTION_NAME, ); diff --git a/src/queries/sql/replays/saveRecording.ts b/src/queries/sql/replays/saveRecording.ts index 5c3b520b0..73d2f9f58 100644 --- a/src/queries/sql/replays/saveRecording.ts +++ b/src/queries/sql/replays/saveRecording.ts @@ -7,6 +7,7 @@ import prisma from '@/lib/prisma'; export interface SaveRecordingArgs { websiteId: string; sessionId: string; + visitId: string; chunkIndex: number; events: any[]; eventCount: number; @@ -24,6 +25,7 @@ export async function saveRecording(args: SaveRecordingArgs) { async function relationalQuery({ websiteId, sessionId, + visitId, chunkIndex, events, eventCount, @@ -37,6 +39,7 @@ async function relationalQuery({ id: uuid(), websiteId, sessionId, + visitId, chunkIndex, events: compressed as any, eventCount, @@ -49,6 +52,7 @@ async function relationalQuery({ async function clickhouseQuery({ websiteId, sessionId, + visitId, chunkIndex, events, eventCount, @@ -62,6 +66,7 @@ async function clickhouseQuery({ replay_id: uuid(), website_id: websiteId, session_id: sessionId, + visit_id: visitId, chunk_index: chunkIndex, events: JSON.stringify(events), event_count: eventCount, From 9375b9aa91c03e346d8dac4ec3d55ea5c52505a0 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 23:06:44 -0800 Subject: [PATCH 7/8] update default mask-level to moderate --- .../websites/[websiteId]/settings/WebsiteReplaySettings.tsx | 2 +- src/recorder/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx index bc57e0da7..042d8480d 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteReplaySettings.tsx @@ -28,7 +28,7 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) { const config = (website?.replayConfig as ReplayConfig) || {}; const [sampleRate, setSampleRate] = useState(config.sampleRate ?? 0.15); - const [maskLevel, setMaskLevel] = useState(config.maskLevel ?? 'strict'); + const [maskLevel, setMaskLevel] = useState(config.maskLevel ?? 'moderate'); const [maxDuration, setMaxDuration] = useState(String(config.maxDuration ?? 300000)); const [blockSelector, setBlockSelector] = useState(config.blockSelector ?? ''); diff --git a/src/recorder/index.js b/src/recorder/index.js index 3d47af472..ce40b0b87 100644 --- a/src/recorder/index.js +++ b/src/recorder/index.js @@ -11,7 +11,7 @@ import { record } from 'rrweb'; const website = attr(`${_data}website-id`); const hostUrl = attr(`${_data}host-url`); const sampleRate = parseFloat(attr(`${_data}sample-rate`) || '0.15'); - const maskLevel = attr(`${_data}mask-level`) || 'strict'; + const maskLevel = attr(`${_data}mask-level`) || 'moderate'; const maxDuration = parseInt(attr(`${_data}max-duration`) || '300000', 10); const blockSelector = attr(`${_data}block-selector`) || ''; From 5a0243a42b40172c20341e79a5a2e4b5662b56bf Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 23:20:25 -0800 Subject: [PATCH 8/8] allow event filter on Replays page --- src/components/input/FilterEditForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx index f9458f99e..d8650ec09 100644 --- a/src/components/input/FilterEditForm.tsx +++ b/src/components/input/FilterEditForm.tsx @@ -28,7 +28,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP const [currentMatch, setCurrentMatch] = useState(match || 'all'); const { isMobile } = useMobile(); const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); - const excludeEvent = !pathname.endsWith('/events'); + const excludeEvent = !pathname.endsWith('/events') && !pathname.endsWith('/replays'); const handleReset = () => { setCurrentFilters([]);