From 0e1d6cef6b9b70017dcbb514675dbdfbda6bef6d Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 3 Mar 2026 22:59:25 -0800 Subject: [PATCH] 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,