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 92cdc649b..2783fe229 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -399,6 +399,7 @@ model SessionReplay { @@index([sessionId]) @@index([visitId]) @@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 d455978f5..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", @@ -332,6 +333,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/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/ReplayModal.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx index f161b0021..7127a613d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx @@ -1,41 +1,41 @@ 'use client'; -import { Button, Column, Dialog, Icon, Modal, Row } from '@umami/react-zen'; -import { X } from 'lucide-react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { buildPath } from '@/lib/url'; -import { ReplayPlayback } from './[sessionId]/ReplayPlayback'; +import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen'; +import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback'; +import { useNavigation } from '@/components/hooks'; -export function ReplayModal({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { - const router = useRouter(); - const searchParams = useSearchParams(); +export interface ReplayModalProps extends ModalProps { + websiteId: string; +} - const closeModal = () => { - const query = Object.fromEntries(searchParams.entries()); - delete query.session; - - router.push(buildPath(`/websites/${websiteId}/replays`, query)); - }; +export function ReplayModal({ websiteId, ...props }: ReplayModalProps) { + const { + router, + query: { replay }, + updateParams, + } = useNavigation(); const handleOpenChange = (isOpen: boolean) => { if (!isOpen) { - closeModal(); + 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 65836e769..414fae0fb 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,7 +1,9 @@ '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 { ReplayModal } from './ReplayModal'; import { ReplaysDataTable } from './ReplaysDataTable'; export function ReplaysPage({ websiteId }: { websiteId: string }) { @@ -11,6 +13,8 @@ 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 77acbe6ab..3a39cea1e 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -1,10 +1,10 @@ import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen'; import { Play } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +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); @@ -13,56 +13,65 @@ 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 router = useRouter(); + const { router, updateParams } = useNavigation(); return ( - - {(row: any) => ( - - )} - - {(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) => } + + {(row: any) => ( + + )} + ); } diff --git a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx new file mode 100644 index 000000000..3331bbbc0 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback.tsx @@ -0,0 +1,59 @@ +'use client'; +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, + replayId, + onClose, +}: { + websiteId: string; + replayId: string; + onClose?: () => void; +}) { + const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId); + const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId); + const { t, labels } = useMessages(); + + return ( + + {replay && ( + + {session && ( + + + + + {t(labels.replay)} + + {replay.eventCount} {t(labels.actions).toLowerCase()} + + + + {onClose && ( + + )} + + )} + + {session && } + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx similarity index 57% rename from src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx rename to src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx index 0b85f1409..04bb2345a 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx @@ -1,46 +1,34 @@ '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; - // 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; - // Clear any previous player if (containerRef.current) { containerRef.current.innerHTML = ''; } playerRef.current = new RRWebPlayer({ - target: containerRef.current!, + target: containerRef.current, props: { - events, - width: 1024, - height: 576, + events: events, + width: playerWidth, + height: playerHeight, autoPlay: false, showController: true, speedOption: [1, 2, 4, 8], @@ -56,15 +44,15 @@ export function ReplayPlayer({ events }: { events: any[] }) { playerRef.current = null; } }; - }, [events]); + }, [events, playerWidth, playerHeight]); return (
; + 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]/replays/[sessionId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx deleted file mode 100644 index 29f86befd..000000000 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; -import { Column, Row, Text } from '@umami/react-zen'; -import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo'; -import { Avatar } from '@/components/common/Avatar'; -import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { Panel } from '@/components/common/Panel'; -import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks'; -import { ReplayPlayer } from './ReplayPlayer'; - -export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { - const { data: replay, isLoading, error } = useReplayQuery(websiteId, sessionId); - const { data: session } = useWebsiteSessionQuery(websiteId, sessionId); - const { t, labels } = useMessages(); - - return ( - - - {replay && ( - <> - - - - {t(labels.replay)} - - {replay.eventCount} {t(labels.events).toLowerCase()} - - - - - - - - - )} - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx index 20ed2b5cf..743a9b88d 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -11,13 +11,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({ @@ -30,7 +30,6 @@ export function SessionProfile({ onClose?: () => void; }) { const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); - const { data: replay } = useReplayQuery(websiteId, sessionId); const { t, labels } = useMessages(); return ( @@ -66,7 +65,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/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx index e4f76edc0..4180db130 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -20,36 +20,37 @@ export function SessionsTable({ websiteId, ...props }: DataTableProps & { websit - + + {(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]/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/app/api/record/route.ts b/src/app/api/record/route.ts index b47e95f0d..390b25b49 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'; interface Cache { sessionId: string; @@ -84,19 +83,15 @@ export async function POST(request: Request) { const startedAt = new Date(minTimestamp); const endedAt = new Date(maxTimestamp); - // 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, visitId, 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/[replayId]/route.ts similarity index 61% rename from src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts rename to src/app/api/websites/[websiteId]/replays/[replayId]/route.ts index 58a6e9dd2..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 { gunzipSync } from 'node:zlib'; 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,24 +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); - - // 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 chunks = await getReplayChunks(websiteId, replayId); + 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]/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/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/app/global.css b/src/app/global.css index eb62995a3..678c7e9ea 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -55,3 +55,16 @@ select:-webkit-autofill:focus { -webkit-box-shadow: 0 0 0 1000px var(--background-color) inset !important; transition: color 5000s ease-in-out 0s; } + +/* rrweb-player overrides */ +.rr-controller__btns { + gap: 8px; +} + +[data-theme="dark"] .rr-controller__btns button { + color: black; +} + +[data-theme="dark"] .rr-controller__btns .label { + color: black; +} 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/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/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/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([]); diff --git a/src/components/messages.ts b/src/components/messages.ts index 9b403b43b..6bd5b3fc1 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', @@ -357,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/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..f102ac4aa --- /dev/null +++ b/src/queries/prisma/sessionReplay.ts @@ -0,0 +1,64 @@ +import { uuid } from '@/lib/crypto'; +import prisma from '@/lib/prisma'; + +export interface CreateReplayChunkArgs { + websiteId: string; + sessionId: string; + visitId: string; + chunkIndex: number; + events: Uint8Array; + eventCount: number; + startedAt: Date; + endedAt: Date; +} + +export async function getReplayChunks(websiteId: string, visitId: string) { + return prisma.client.sessionReplay.findMany({ + where: { + websiteId, + visitId, + }, + orderBy: { + chunkIndex: 'asc', + }, + select: { + events: true, + sessionId: true, + chunkIndex: true, + eventCount: true, + startedAt: true, + endedAt: true, + }, + }); +} + +export async function createReplayChunk({ + websiteId, + sessionId, + visitId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: CreateReplayChunkArgs) { + return prisma.client.sessionReplay.create({ + data: { + id: uuid(), + websiteId, + sessionId, + visitId, + 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..5c192172e 100644 --- a/src/queries/sql/replays/getReplayChunks.ts +++ b/src/queries/sql/replays/getReplayChunks.ts @@ -1,24 +1,102 @@ +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 { + sessionId: string; + visitId: string; + 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, visitId: string): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(websiteId, visitId), + [CLICKHOUSE]: () => clickhouseQuery(websiteId, visitId), }); } + +async function relationalQuery(websiteId: string, visitId: string): Promise { + const { rawQuery } = prisma; + + const chunks: { + sessionId: string; + visitId: string; + events: Buffer; + chunkIndex: number; + eventCount: number; + startedAt: Date; + endedAt: Date; + }[] = await rawQuery( + ` + select + session_id as "sessionId", + visit_id as "visitId", + 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 visit_id = {{visitId::uuid}} + order by chunk_index asc + `, + { websiteId, visitId }, + FUNCTION_NAME, + ); + + return chunks.map(chunk => ({ + ...chunk, + events: JSON.parse(gunzipSync(Buffer.from(chunk.events)).toString('utf-8')), + })); +} + +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; + started_at: string; + ended_at: string; + }[] + >( + ` + select + session_id as sessionId, + visit_id as visitId, + events, + chunk_index, + event_count, + started_at, + ended_at + from session_replay + where website_id = {websiteId:UUID} + and visit_id = {visitId:UUID} + order by chunk_index asc + `, + { websiteId, visitId }, + FUNCTION_NAME, + ); + + return results.map(row => ({ + sessionId: row.sessionId, + visitId: row.visitId, + 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..ee9775b62 100644 --- a/src/queries/sql/replays/getSessionReplays.ts +++ b/src/queries/sql/replays/getSessionReplays.ts @@ -1,38 +1,56 @@ +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, 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, 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 distinct website_id, session_id, visit_id + from website_event + ${cohortQuery} + where website_id = {{websiteId::uuid}} + 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.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}} - or session.browser ilike {{search}})` + or session.browser ilike {{search}} + or session.os ilike {{search}} + or session.device ilike {{search}})` : ''; 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, @@ -43,15 +61,18 @@ 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 - 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 + ${joinQuery} where sr.website_id = {{websiteId::uuid}} - ${dateQuery} + and sr.created_at between {{startDate}} and {{endDate}} + ${sessionFilter} ${searchQuery} - group by sr.session_id, + group by sr.visit_id, + sr.session_id, sr.website_id, session.browser, session.os, @@ -60,7 +81,67 @@ 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, sessionId?: string) { + const { pagedRawQuery, parseFilters } = clickhouse; + const { search } = filters; + const { queryParams, cohortQuery, filterQuery } = parseFilters({ + ...filters, + 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) + or (positionCaseInsensitive(browser, {search:String}) > 0) + or (positionCaseInsensitive(os, {search:String}) > 0) + or (positionCaseInsensitive(device, {search:String}) > 0))` + : ''; + + return pagedRawQuery( + ` + select + session_replay.visit_id as id, + session_replay.session_id as sessionId, + 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(sum(dateDiff('millisecond', session_replay.started_at, session_replay.ended_at))) as duration, + max(session_replay.created_at) as createdAt + from session_replay + join ( + 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 + 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} + ${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, sessionId }, 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..73d2f9f58 --- /dev/null +++ b/src/queries/sql/replays/saveRecording.ts @@ -0,0 +1,77 @@ +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; + visitId: 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, + visitId, + 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, + visitId, + chunkIndex, + events: compressed as any, + eventCount, + startedAt, + endedAt, + }, + }); +} + +async function clickhouseQuery({ + websiteId, + sessionId, + visitId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: SaveRecordingArgs) { + const { insert, getUTCString } = clickhouse; + + return insert('session_replay', [ + { + replay_id: uuid(), + website_id: websiteId, + session_id: sessionId, + visit_id: visitId, + 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 eb6ceb4b2..000000000 --- a/src/queries/sql/replays/saveReplayChunk.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { uuid } from '@/lib/crypto'; -import prisma from '@/lib/prisma'; - -export interface SaveReplayChunkArgs { - websiteId: string; - sessionId: string; - visitId: 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, - visitId, - chunkIndex, - events, - eventCount, - startedAt, - endedAt, -}: SaveReplayChunkArgs) { - return prisma.client.sessionReplay.create({ - data: { - id: uuid(), - websiteId, - sessionId, - visitId, - chunkIndex, - events: new Uint8Array(events) as any, - eventCount, - startedAt, - endedAt, - }, - }); -} 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; } 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} diff --git a/src/recorder/index.js b/src/recorder/index.js index ad964a858..4d670ac4b 100644 --- a/src/recorder/index.js +++ b/src/recorder/index.js @@ -12,7 +12,7 @@ import { record } from 'rrweb'; const website = config(`website-id`); const hostUrl = config(`host-url`); const sampleRate = parseFloat(config(`sample-rate`) || '0.15'); - const maskLevel = config(`mask-level`) || 'strict'; + const maskLevel = config(`mask-level`) || 'moderate'; const maxDuration = parseInt(config(`max-duration`) || '300000', 10); const blockSelector = config(`block-selector`) || '';