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) {
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,