;
+ 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`) || '';