diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index d44b74ba0..59310518e 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -86,14 +86,14 @@ export function EventsTable(props: DataTableProps) { )} - + {(row: any) => ( {formatValue(row.device, 'device')} )} - + {(row: any) => } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx index 65836e769..d4127fc6d 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysPage.tsx @@ -1,5 +1,6 @@ 'use client'; import { Column } from '@umami/react-zen'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; import { ReplaysDataTable } from './ReplaysDataTable'; @@ -11,6 +12,7 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) { + ); } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx index 1082cf6a0..5549d3ddc 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Avatar } from '@/components/common/Avatar'; import { DateDistance } from '@/components/common/DateDistance'; import { TypeIcon } from '@/components/common/TypeIcon'; -import { useFormat, useMessages } from '@/components/hooks'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; function formatDuration(ms: number) { const seconds = Math.floor(ms / 1000); @@ -16,38 +16,51 @@ function formatDuration(ms: number) { export function ReplaysTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) { const { t, labels } = useMessages(); const { formatValue } = useFormat(); + const { updateParams } = useNavigation(); return ( - {(row: any) => } + {(row: any) => ( + + + + )} {(row: any) => formatDuration(row.duration || 0)} - - + + {(row: any) => ( + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} )} - + {(row: any) => ( {formatValue(row.browser, 'browser')} )} - + {(row: any) => ( {formatValue(row.os, 'os')} )} - + + {(row: any) => ( + + {formatValue(row.device, 'device')} + + )} + + {(row: any) => } diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx index 60ea97ffb..9dfcf3793 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx @@ -27,7 +27,7 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se {t(labels.replay)} - {replay.eventCount} {t(labels.events).toLowerCase()} + {replay.eventCount} {t(labels.actions).toLowerCase()} diff --git a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx index 0b85f1409..bb2a99a35 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayer.tsx @@ -11,21 +11,6 @@ export function ReplayPlayer({ events }: { events: any[] }) { useEffect(() => { if (!containerRef.current || !events?.length) return; - // Debug: log event info - const typeCounts: Record = {}; - events.forEach((e: any) => { - typeCounts[e.type] = (typeCounts[e.type] || 0) + 1; - }); - const timestamps = events.map((e: any) => e.timestamp).filter(Boolean); - console.log('[ReplayPlayer] Events:', events.length, 'Types:', typeCounts); - console.log( - '[ReplayPlayer] Time range:', - timestamps.length - ? `${Math.min(...timestamps)} - ${Math.max(...timestamps)} (${Math.max(...timestamps) - Math.min(...timestamps)}ms)` - : 'no timestamps', - ); - console.log('[ReplayPlayer] First 3 events:', events.slice(0, 3)); - // Dynamically import rrweb-player to avoid SSR issues import('rrweb-player').then(mod => { const RRWebPlayer = mod.default; @@ -36,7 +21,7 @@ export function ReplayPlayer({ events }: { events: any[] }) { } playerRef.current = new RRWebPlayer({ - target: containerRef.current!, + target: containerRef.current, props: { events, width: 1024, diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx index 78a41359b..69eb9991e 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -21,36 +21,37 @@ export function SessionsTable(props: DataTableProps) { - + + {(row: any) => ( + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} )} - - + {(row: any) => ( {formatValue(row.browser, 'browser')} )} - + {(row: any) => ( {formatValue(row.os, 'os')} )} - + {(row: any) => ( {formatValue(row.device, 'device')} )} - + {(row: any) => } diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 96554869d..25b3924c1 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -1,4 +1,3 @@ -import { gzipSync } from 'node:zlib'; import { isbot } from 'isbot'; import { serializeError } from 'serialize-error'; import { z } from 'zod'; @@ -8,7 +7,7 @@ import { parseToken } from '@/lib/jwt'; import { parseRequest } from '@/lib/request'; import { badRequest, forbidden, json, serverError } from '@/lib/response'; import { getWebsite } from '@/queries/prisma'; -import { saveReplayChunk } from '@/queries/sql'; +import { saveRecording } from '@/queries/sql'; const schema = z.object({ website: z.uuid(), @@ -75,18 +74,14 @@ export async function POST(request: Request) { const startedAt = new Date(Math.min(...eventTimestamps)); const endedAt = new Date(Math.max(...eventTimestamps)); - // Compress events - const eventsJson = JSON.stringify(events); - const compressed = gzipSync(Buffer.from(eventsJson, 'utf-8')); - // Use timestamp-based chunk index for ordering const chunkIndex = timestamp || Math.floor(Date.now() / 1000); - await saveReplayChunk({ + await saveRecording({ websiteId, sessionId, chunkIndex, - events: compressed, + events, eventCount: events.length, startedAt, endedAt, diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts index 74ec3ece4..583e4abbf 100644 --- a/src/app/api/websites/[websiteId]/events/route.ts +++ b/src/app/api/websites/[websiteId]/events/route.ts @@ -1,7 +1,6 @@ -import { z } from 'zod'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; import { getWebsiteEvents } from '@/queries/sql'; @@ -9,9 +8,7 @@ export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const schema = z.object({ - startAt: z.coerce.number().optional(), - endAt: z.coerce.number().optional(), + const schema = withDateRange({ ...filterParams, ...pagingParams, ...searchParams, diff --git a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts index 58a6e9dd2..01e3a91aa 100644 --- a/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[sessionId]/route.ts @@ -1,4 +1,3 @@ -import { gunzipSync } from 'node:zlib'; import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; @@ -22,12 +21,7 @@ export async function GET( const chunks = await getReplayChunks(websiteId, sessionId); - // Decompress and concatenate all chunks - const allEvents = chunks.flatMap(chunk => { - const decompressed = gunzipSync(Buffer.from(chunk.events)); - return JSON.parse(decompressed.toString('utf-8')); - }); - + const allEvents = chunks.flatMap(chunk => chunk.events); const startedAt = chunks.length > 0 ? chunks[0].startedAt : null; const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null; diff --git a/src/app/api/websites/[websiteId]/replays/route.ts b/src/app/api/websites/[websiteId]/replays/route.ts index 7fed95d21..fb38a0178 100644 --- a/src/app/api/websites/[websiteId]/replays/route.ts +++ b/src/app/api/websites/[websiteId]/replays/route.ts @@ -1,7 +1,6 @@ -import { z } from 'zod'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { dateRangeParams, pagingParams, searchParams } from '@/lib/schema'; +import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; import { getSessionReplays } from '@/queries/sql'; @@ -9,8 +8,8 @@ export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const schema = z.object({ - ...dateRangeParams, + const schema = withDateRange({ + ...filterParams, ...pagingParams, ...searchParams, }); diff --git a/src/components/hooks/queries/useReplaysQuery.ts b/src/components/hooks/queries/useReplaysQuery.ts index ab0d6f821..79c1a04bf 100644 --- a/src/components/hooks/queries/useReplaysQuery.ts +++ b/src/components/hooks/queries/useReplaysQuery.ts @@ -1,5 +1,6 @@ import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; import { useModified } from '../useModified'; import { usePagedQuery } from '../usePagedQuery'; @@ -7,15 +8,20 @@ export function useReplaysQuery(websiteId: string, params?: Record { return get(`/websites/${websiteId}/replays`, { startAt, endAt, unit, timezone, + ...filters, ...pageParams, ...params, pageSize: 20, diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index 5e975d013..27288c31c 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -3,6 +3,7 @@ export * from './link'; export * from './pixel'; export * from './report'; export * from './segment'; +export * from './sessionReplay'; export * from './share'; export * from './team'; export * from './teamUser'; diff --git a/src/queries/prisma/sessionReplay.ts b/src/queries/prisma/sessionReplay.ts new file mode 100644 index 000000000..868c4a50e --- /dev/null +++ b/src/queries/prisma/sessionReplay.ts @@ -0,0 +1,60 @@ +import { uuid } from '@/lib/crypto'; +import prisma from '@/lib/prisma'; + +export interface CreateReplayChunkArgs { + websiteId: string; + sessionId: string; + chunkIndex: number; + events: Uint8Array; + eventCount: number; + startedAt: Date; + endedAt: Date; +} + +export async function getReplayChunks(websiteId: string, sessionId: string) { + return prisma.client.sessionReplay.findMany({ + where: { + websiteId, + sessionId, + }, + orderBy: { + chunkIndex: 'asc', + }, + select: { + events: true, + chunkIndex: true, + eventCount: true, + startedAt: true, + endedAt: true, + }, + }); +} + +export async function createReplayChunk({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: CreateReplayChunkArgs) { + return prisma.client.sessionReplay.create({ + data: { + id: uuid(), + websiteId, + sessionId, + chunkIndex, + events: new Uint8Array(events) as any, + eventCount, + startedAt, + endedAt, + }, + }); +} + +export async function deleteReplaysByWebsite(websiteId: string) { + return prisma.client.sessionReplay.deleteMany({ + where: { websiteId }, + }); +} diff --git a/src/queries/sql/events/getEventStats.ts b/src/queries/sql/events/getEventStats.ts index 2caba3268..9c1f5c126 100644 --- a/src/queries/sql/events/getEventStats.ts +++ b/src/queries/sql/events/getEventStats.ts @@ -1,5 +1,4 @@ import clickhouse from '@/lib/clickhouse'; -import { EVENT_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -36,7 +35,6 @@ async function relationalQuery( const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, - eventType: EVENT_TYPE.customEvent, }); const limitQuery = limit @@ -63,6 +61,7 @@ async function relationalQuery( ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = 2 ${filterQuery} ${limitQuery} group by 1, 2 @@ -84,7 +83,6 @@ async function clickhouseQuery( const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, - eventType: EVENT_TYPE.customEvent, }); const limitQuery = limit @@ -93,7 +91,7 @@ async function clickhouseQuery( from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = {eventType:UInt32} + and event_type = 2 group by event_name order by count(*) desc limit ${limit} @@ -112,6 +110,7 @@ async function clickhouseQuery( ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2 ${filterQuery} ${limitQuery} group by x, t @@ -129,7 +128,7 @@ async function clickhouseQuery( from website_event_stats_hourly website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = {eventType:UInt32} + and event_type = 2 ${limitQuery} ) as g group by x, t diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index dc4cb20c5..45a6149d5 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -25,10 +25,9 @@ export * from './pageviews/getPageviewMetrics'; export * from './pageviews/getPageviewStats'; export * from './performance/getPerformanceStats'; export * from './performance/savePerformance'; -export * from './replays/deleteReplaysByWebsite'; export * from './replays/getReplayChunks'; export * from './replays/getSessionReplays'; -export * from './replays/saveReplayChunk'; +export * from './replays/saveRecording'; export * from './reports/getBreakdown'; export * from './reports/getFunnel'; export * from './reports/getJourney'; diff --git a/src/queries/sql/replays/deleteReplaysByWebsite.ts b/src/queries/sql/replays/deleteReplaysByWebsite.ts deleted file mode 100644 index c3e37376c..000000000 --- a/src/queries/sql/replays/deleteReplaysByWebsite.ts +++ /dev/null @@ -1,11 +0,0 @@ -import prisma from '@/lib/prisma'; - -export async function deleteReplaysByWebsite(websiteId: string) { - return relationalQuery(websiteId); -} - -async function relationalQuery(websiteId: string) { - return prisma.client.sessionReplay.deleteMany({ - where: { websiteId }, - }); -} diff --git a/src/queries/sql/replays/getReplayChunks.ts b/src/queries/sql/replays/getReplayChunks.ts index 0c7739d54..5bca92d3e 100644 --- a/src/queries/sql/replays/getReplayChunks.ts +++ b/src/queries/sql/replays/getReplayChunks.ts @@ -1,24 +1,93 @@ +import { gunzipSync } from 'node:zlib'; +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -export async function getReplayChunks(websiteId: string, sessionId: string) { - return relationalQuery(websiteId, sessionId); +const FUNCTION_NAME = 'getReplayChunks'; + +export interface ReplayChunk { + events: any[]; + chunkIndex: number; + eventCount: number; + startedAt: Date; + endedAt: Date; } -async function relationalQuery(websiteId: string, sessionId: string) { - return prisma.client.sessionReplay.findMany({ - where: { - websiteId, - sessionId, - }, - orderBy: { - chunkIndex: 'asc', - }, - select: { - events: true, - chunkIndex: true, - eventCount: true, - startedAt: true, - endedAt: true, - }, +export async function getReplayChunks( + websiteId: string, + sessionId: string, +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(websiteId, sessionId), + [CLICKHOUSE]: () => clickhouseQuery(websiteId, sessionId), }); } + +async function relationalQuery(websiteId: string, sessionId: string): Promise { + const { rawQuery } = prisma; + + const chunks: { + events: Buffer; + chunkIndex: number; + eventCount: number; + startedAt: Date; + endedAt: Date; + }[] = await rawQuery( + ` + select + events, + chunk_index as "chunkIndex", + event_count as "eventCount", + started_at as "startedAt", + ended_at as "endedAt" + from session_replay + where website_id = {{websiteId::uuid}} + and session_id = {{sessionId::uuid}} + order by chunk_index asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); + + return chunks.map(chunk => ({ + ...chunk, + events: JSON.parse(gunzipSync(Buffer.from(chunk.events)).toString('utf-8')), + })); +} + +async function clickhouseQuery(websiteId: string, sessionId: string): Promise { + const { rawQuery } = clickhouse; + + const results = await rawQuery< + { + events: string; + chunk_index: number; + event_count: number; + started_at: string; + ended_at: string; + }[] + >( + ` + select + events, + chunk_index, + event_count, + started_at, + ended_at + from session_replay + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + order by chunk_index asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); + + return results.map(row => ({ + events: JSON.parse(row.events), + chunkIndex: row.chunk_index, + eventCount: row.event_count, + startedAt: new Date(row.started_at), + endedAt: new Date(row.ended_at), + })); +} diff --git a/src/queries/sql/replays/getSessionReplays.ts b/src/queries/sql/replays/getSessionReplays.ts index 7cd403da5..7f650acff 100644 --- a/src/queries/sql/replays/getSessionReplays.ts +++ b/src/queries/sql/replays/getSessionReplays.ts @@ -1,32 +1,42 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionReplays'; -export async function getSessionReplays(...args: [websiteId: string, filters: QueryFilters]) { - return relationalQuery(...args); +export function getSessionReplays(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); } async function relationalQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters } = prisma; - const { search, startDate, endDate } = filters; - const { queryParams } = parseFilters({ + const { search } = filters; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, search: search ? `%${search}%` : undefined, }); - let dateQuery = ''; - if (startDate && endDate) { - dateQuery = `and sr.created_at between {{startDate}} and {{endDate}}`; - } else if (startDate) { - dateQuery = `and sr.created_at >= {{startDate}}`; - } + const joinQuery = + filterQuery || cohortQuery + ? `join (select * + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) website_event + on website_event.website_id = sr.website_id + and website_event.session_id = sr.session_id` + : ''; const searchQuery = search ? `and (session.distinct_id ilike {{search}} or session.city ilike {{search}} - or session.browser ilike {{search}})` + or session.browser ilike {{search}} + or session.os ilike {{search}} + or session.device ilike {{search}})` : ''; return pagedRawQuery( @@ -46,10 +56,13 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { (extract(epoch from max(sr.ended_at) - min(sr.started_at)) * 1000)::bigint as "duration", max(sr.created_at) as "createdAt" from session_replay sr - left join session on session.session_id = sr.session_id + join session on session.session_id = sr.session_id and session.website_id = sr.website_id + ${cohortQuery} + ${joinQuery} where sr.website_id = {{websiteId::uuid}} - ${dateQuery} + and sr.created_at between {{startDate}} and {{endDate}} + ${filterQuery} ${searchQuery} group by sr.session_id, sr.website_id, @@ -65,3 +78,58 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { FUNCTION_NAME, ); } + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters } = clickhouse; + const { search } = filters; + const { queryParams, cohortQuery, filterQuery } = parseFilters({ + ...filters, + websiteId, + }); + + const searchQuery = search + ? `and ((positionCaseInsensitive(distinct_id, {search:String}) > 0) + or (positionCaseInsensitive(city, {search:String}) > 0) + or (positionCaseInsensitive(browser, {search:String}) > 0) + or (positionCaseInsensitive(os, {search:String}) > 0) + or (positionCaseInsensitive(device, {search:String}) > 0))` + : ''; + + return pagedRawQuery( + ` + select + session_replay.session_id as id, + session_replay.website_id as websiteId, + website_event.browser, + website_event.os, + website_event.device, + website_event.country, + website_event.city, + sum(session_replay.event_count) as eventCount, + count(session_replay.replay_id) as chunkCount, + min(session_replay.started_at) as startedAt, + max(session_replay.ended_at) as endedAt, + toInt64(dateDiff('millisecond', min(session_replay.started_at), max(session_replay.ended_at))) as duration, + max(session_replay.created_at) as createdAt + from session_replay + join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ) website_event + on website_event.session_id = session_replay.session_id + and website_event.website_id = session_replay.website_id + ${cohortQuery} + where session_replay.website_id = {websiteId:UUID} + and session_replay.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${searchQuery} + group by session_replay.session_id, session_replay.website_id, website_event.browser, website_event.os, website_event.device, website_event.country, website_event.city + order by max(created_at) desc + `, + queryParams, + filters, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/replays/index.ts b/src/queries/sql/replays/index.ts deleted file mode 100644 index d35828122..000000000 --- a/src/queries/sql/replays/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './deleteReplaysByWebsite'; -export * from './getReplayChunks'; -export * from './getSessionReplays'; -export * from './saveReplayChunk'; diff --git a/src/queries/sql/replays/saveRecording.ts b/src/queries/sql/replays/saveRecording.ts new file mode 100644 index 000000000..5c3b520b0 --- /dev/null +++ b/src/queries/sql/replays/saveRecording.ts @@ -0,0 +1,72 @@ +import { gzipSync } from 'node:zlib'; +import clickhouse from '@/lib/clickhouse'; +import { uuid } from '@/lib/crypto'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +export interface SaveRecordingArgs { + websiteId: string; + sessionId: string; + chunkIndex: number; + events: any[]; + eventCount: number; + startedAt: Date; + endedAt: Date; +} + +export async function saveRecording(args: SaveRecordingArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(args), + [CLICKHOUSE]: () => clickhouseQuery(args), + }); +} + +async function relationalQuery({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: SaveRecordingArgs) { + const compressed = gzipSync(Buffer.from(JSON.stringify(events), 'utf-8')); + + return prisma.client.sessionReplay.create({ + data: { + id: uuid(), + websiteId, + sessionId, + chunkIndex, + events: compressed as any, + eventCount, + startedAt, + endedAt, + }, + }); +} + +async function clickhouseQuery({ + websiteId, + sessionId, + chunkIndex, + events, + eventCount, + startedAt, + endedAt, +}: SaveRecordingArgs) { + const { insert, getUTCString } = clickhouse; + + return insert('session_replay', [ + { + replay_id: uuid(), + website_id: websiteId, + session_id: sessionId, + chunk_index: chunkIndex, + events: JSON.stringify(events), + event_count: eventCount, + started_at: getUTCString(startedAt), + ended_at: getUTCString(endedAt), + }, + ]); +} diff --git a/src/queries/sql/replays/saveReplayChunk.ts b/src/queries/sql/replays/saveReplayChunk.ts deleted file mode 100644 index 9f34491bb..000000000 --- a/src/queries/sql/replays/saveReplayChunk.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { uuid } from '@/lib/crypto'; -import prisma from '@/lib/prisma'; - -export interface SaveReplayChunkArgs { - websiteId: string; - sessionId: string; - chunkIndex: number; - events: Uint8Array; - eventCount: number; - startedAt: Date; - endedAt: Date; -} - -export async function saveReplayChunk(args: SaveReplayChunkArgs) { - return relationalQuery(args); -} - -async function relationalQuery({ - websiteId, - sessionId, - chunkIndex, - events, - eventCount, - startedAt, - endedAt, -}: SaveReplayChunkArgs) { - return prisma.client.sessionReplay.create({ - data: { - id: uuid(), - websiteId, - sessionId, - chunkIndex, - events: new Uint8Array(events) as any, - eventCount, - startedAt, - endedAt, - }, - }); -} diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts index a12e6c614..a4615ee40 100644 --- a/src/queries/sql/sessions/getWebsiteSessionStats.ts +++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts @@ -70,7 +70,7 @@ async function clickhouseQuery( uniq(session_id) as "visitors", uniq(visit_id) as "visits", uniq(country) as "countries", - sum(length(event_name)) as "events" + sumIf(1, event_type = 2) as "events" from website_event ${cohortQuery} where website_id = {websiteId:UUID} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index df640d6d8..8efea80df 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -48,6 +48,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { max(website_event.created_at) as "lastAt", count(distinct website_event.visit_id) as "visits", sum(case when website_event.event_type = 1 then 1 else 0 end) as "views", + sum(case when website_event.event_type = 2 then 1 else 0 end) as "events", max(website_event.created_at) as "createdAt" from website_event ${cohortQuery} @@ -112,6 +113,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('max(created_at)')} as lastAt, uniq(visit_id) as visits, sumIf(1, event_type = 1) as views, + sumIf(1, event_type = 2) as events, lastAt as createdAt from website_event ${cohortQuery} @@ -140,6 +142,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('max(max_time)')} as lastAt, uniq(visit_id) as visits, sumIf(views, event_type = 1) as views, + sum(length(event_name)) as events, lastAt as createdAt from website_event_stats_hourly as website_event ${cohortQuery}