session replay updates. add clickhouse implementation, filters, tables

This commit is contained in:
Francis Cao
2026-03-03 12:47:27 -08:00
parent e9ac09ec5d
commit 5e68661c5d
23 changed files with 359 additions and 150 deletions
@@ -86,14 +86,14 @@ export function EventsTable(props: DataTableProps) {
</TypeIcon>
)}
</DataColumn>
<DataColumn id="device" label={t(labels.device)} width="120px">
<DataColumn id="device" label={t(labels.device)} width="140px">
{(row: any) => (
<TypeIcon type="device" value={row.device}>
{formatValue(row.device, 'device')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="created" width="160px" align="end">
<DataColumn id="created" width="140px" label={t(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
</DataTable>
@@ -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 }) {
<Panel>
<ReplaysDataTable websiteId={websiteId} />
</Panel>
<SessionModal websiteId={websiteId} />
</Column>
);
}
@@ -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 (
<DataTable {...props}>
<DataColumn id="id" label={t(labels.session)} width="100px">
{(row: any) => <Avatar seed={row.id} size={32} />}
{(row: any) => (
<Link href={updateParams({ session: row.id })}>
<Avatar seed={row.id} size={32} />
</Link>
)}
</DataColumn>
<DataColumn id="duration" label={t(labels.duration)} width="100px">
{(row: any) => formatDuration(row.duration || 0)}
</DataColumn>
<DataColumn id="eventCount" label={t(labels.events)} width="80px" />
<DataColumn id="country" label={t(labels.country)}>
<DataColumn id="eventCount" label={t(labels.actions)} width="80px" />
<DataColumn id="location" label={t(labels.location)}>
{(row: any) => (
<TypeIcon type="country" value={row.country}>
{row.city ? `${row.city}, ` : ''}
{formatValue(row.country, 'country')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="browser" label={t(labels.browser)}>
<DataColumn id="browser" label={t(labels.browser)} width="140px">
{(row: any) => (
<TypeIcon type="browser" value={row.browser}>
{formatValue(row.browser, 'browser')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="os" label={t(labels.os)}>
<DataColumn id="os" label={t(labels.os)} width="140px">
{(row: any) => (
<TypeIcon type="os" value={row.os}>
{formatValue(row.os, 'os')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="createdAt" label={t(labels.recordedAt)}>
<DataColumn id="device" label={t(labels.device)} width="140px">
{(row: any) => (
<TypeIcon type="device" value={row.device}>
{formatValue(row.device, 'device')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="play" label="" width="80px">
@@ -27,7 +27,7 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se
<Column>
<Text weight="bold">{t(labels.replay)}</Text>
<Text color="muted">
{replay.eventCount} {t(labels.events).toLowerCase()}
{replay.eventCount} {t(labels.actions).toLowerCase()}
</Text>
</Column>
</Row>
@@ -11,21 +11,6 @@ export function ReplayPlayer({ events }: { events: any[] }) {
useEffect(() => {
if (!containerRef.current || !events?.length) return;
// Debug: log event info
const typeCounts: Record<number, number> = {};
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,
@@ -21,36 +21,37 @@ export function SessionsTable(props: DataTableProps) {
</DataColumn>
<DataColumn id="visits" label={t(labels.visits)} width="80px" />
<DataColumn id="views" label={t(labels.views)} width="80px" />
<DataColumn id="country" label={t(labels.country)}>
<DataColumn id="events" label={t(labels.events)} width="80px" />
<DataColumn id="location" label={t(labels.location)}>
{(row: any) => (
<TypeIcon type="country" value={row.country}>
{row.city ? `${row.city}, ` : ''}
{formatValue(row.country, 'country')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="city" label={t(labels.city)} />
<DataColumn id="browser" label={t(labels.browser)}>
<DataColumn id="browser" label={t(labels.browser)} width="140px">
{(row: any) => (
<TypeIcon type="browser" value={row.browser}>
{formatValue(row.browser, 'browser')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="os" label={t(labels.os)}>
<DataColumn id="os" label={t(labels.os)} width="140px">
{(row: any) => (
<TypeIcon type="os" value={row.os}>
{formatValue(row.os, 'os')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="device" label={t(labels.device)}>
<DataColumn id="device" label={t(labels.device)} width="140px">
{(row: any) => (
<TypeIcon type="device" value={row.device}>
{formatValue(row.device, 'device')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="lastAt" label={t(labels.lastSeen)}>
<DataColumn id="lastAt" label={t(labels.lastSeen)} width="140px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
</DataTable>
+3 -8
View File
@@ -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,
@@ -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,
@@ -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;
@@ -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,
});
@@ -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<string, strin
const { get } = useApi();
const { modified } = useModified('replays');
const { startAt, endAt, unit, timezone } = useDateParameters();
const filters = useFilterParameters();
return usePagedQuery({
queryKey: ['replays', { websiteId, modified, startAt, endAt, unit, timezone, ...params }],
queryKey: [
'replays',
{ websiteId, modified, startAt, endAt, unit, timezone, ...filters, ...params },
],
queryFn: pageParams => {
return get(`/websites/${websiteId}/replays`, {
startAt,
endAt,
unit,
timezone,
...filters,
...pageParams,
...params,
pageSize: 20,
+1
View File
@@ -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';
+60
View File
@@ -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 },
});
}
+4 -5
View File
@@ -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
+1 -2
View File
@@ -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';
@@ -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 },
});
}
+87 -18
View File
@@ -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<ReplayChunk[]> {
return runQuery({
[PRISMA]: () => relationalQuery(websiteId, sessionId),
[CLICKHOUSE]: () => clickhouseQuery(websiteId, sessionId),
});
}
async function relationalQuery(websiteId: string, sessionId: string): Promise<ReplayChunk[]> {
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<ReplayChunk[]> {
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),
}));
}
+81 -13
View File
@@ -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,
);
}
-4
View File
@@ -1,4 +0,0 @@
export * from './deleteReplaysByWebsite';
export * from './getReplayChunks';
export * from './getSessionReplays';
export * from './saveReplayChunk';
+72
View File
@@ -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),
},
]);
}
@@ -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,
},
});
}
@@ -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}
@@ -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}