Merge branch 'dev' of https://github.com/umami-software/umami into dev
# Conflicts: # src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx # src/app/(main)/websites/[websiteId]/replays/ReplaysTable.tsx # src/app/(main)/websites/[websiteId]/replays/[sessionId]/ReplayPlayback.tsx # src/queries/sql/replays/saveReplayChunk.ts # src/recorder/index.js
This commit is contained in:
commit
b389c417de
48 files changed
+724
-303
No files matched your search
@@ -4,6 +4,7 @@ CREATE TABLE umami.session_replay
|
||||
replay_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
chunk_index UInt32,
|
||||
events String CODEC(ZSTD(3)),
|
||||
event_count UInt32,
|
||||
@@ -13,5 +14,5 @@ CREATE TABLE umami.session_replay
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (replay_id, website_id, session_id, chunk_index)
|
||||
ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index)
|
||||
SETTINGS index_granularity = 8192;
|
||||
@@ -361,6 +361,7 @@ CREATE TABLE umami.session_replay
|
||||
replay_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
chunk_index UInt32,
|
||||
events String CODEC(ZSTD(3)),
|
||||
event_count UInt32,
|
||||
@@ -370,5 +371,5 @@ CREATE TABLE umami.session_replay
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (replay_id, website_id, session_id, chunk_index)
|
||||
ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index)
|
||||
SETTINGS index_granularity = 8192;
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE "session_replay" (
|
||||
"replay_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"chunk_index" INTEGER NOT NULL,
|
||||
"events" BYTEA NOT NULL,
|
||||
"event_count" INTEGER NOT NULL,
|
||||
@@ -21,5 +22,6 @@ CREATE TABLE "session_replay" (
|
||||
CREATE INDEX "session_replay_website_id_idx" ON "session_replay"("website_id");
|
||||
CREATE INDEX "session_replay_session_id_idx" ON "session_replay"("session_id");
|
||||
CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("website_id", "session_id");
|
||||
CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id");
|
||||
CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at");
|
||||
CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index");
|
||||
@@ -399,6 +399,7 @@ model SessionReplay {
|
||||
@@index([sessionId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, sessionId])
|
||||
@@index([websiteId, visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@index([sessionId, chunkIndex])
|
||||
@@map("session_replay")
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"remove": "Remove",
|
||||
"remove-member": "Remove member",
|
||||
"replay": "Replay",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-enabled": "Replay enabled",
|
||||
"replays": "Replays",
|
||||
"reports": "Reports",
|
||||
@@ -332,6 +333,8 @@
|
||||
"unique": "Unique",
|
||||
"unique-events": "Unique events",
|
||||
"unique-visitors": "Unique visitors",
|
||||
"aov": "AOV",
|
||||
"arpu": "ARPU",
|
||||
"uniqueCustomers": "Unique Customers",
|
||||
"unknown": "Unknown",
|
||||
"untitled": "Untitled",
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||
const metrics = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const { sum, count, average, unique_count, comparison } = data.total;
|
||||
const { sum, count, average, unique_count, arpu, comparison } = data.total;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -55,10 +55,16 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||
},
|
||||
{
|
||||
value: average,
|
||||
label: t(labels.average),
|
||||
label: t(labels.aov),
|
||||
change: comparison ? average - comparison.average : 0,
|
||||
formatValue: (n: number) => formatLongCurrency(n, currency),
|
||||
},
|
||||
{
|
||||
value: arpu,
|
||||
label: t(labels.arpu),
|
||||
change: comparison ? arpu - (comparison.arpu ?? 0) : 0,
|
||||
formatValue: (n: number) => formatLongCurrency(n, currency),
|
||||
},
|
||||
{
|
||||
value: count,
|
||||
label: t(labels.transactions),
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { DataColumn, DataTable } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { formatLongCurrency } from '@/lib/format';
|
||||
|
||||
export function RevenueTable({ data = [] }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="currency" label={t(labels.currency)} align="end" />
|
||||
<DataColumn id="total" label={t(labels.total)} align="end">
|
||||
{(row: any) => formatLongCurrency(row.sum, row.currency)}
|
||||
</DataColumn>
|
||||
<DataColumn id="average" label={t(labels.average)} align="end">
|
||||
{(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
|
||||
</DataColumn>
|
||||
<DataColumn id="count" label={t(labels.transactions)} align="end" />
|
||||
<DataColumn id="unique_count" label={t(labels.uniqueCustomers)} align="end" />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -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,41 +1,41 @@
|
||||
'use client';
|
||||
import { Button, Column, Dialog, Icon, Modal, Row } from '@umami/react-zen';
|
||||
import { X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { buildPath } from '@/lib/url';
|
||||
import { ReplayPlayback } from './[sessionId]/ReplayPlayback';
|
||||
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
|
||||
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
|
||||
export function ReplayModal({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
export interface ReplayModalProps extends ModalProps {
|
||||
websiteId: string;
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
const query = Object.fromEntries(searchParams.entries());
|
||||
delete query.session;
|
||||
|
||||
router.push(buildPath(`/websites/${websiteId}/replays`, query));
|
||||
};
|
||||
export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
|
||||
const {
|
||||
router,
|
||||
query: { replay },
|
||||
updateParams,
|
||||
} = useNavigation();
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
closeModal();
|
||||
router.push(updateParams({ replay: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal placement="bottom" offset="80px" isOpen onOpenChange={handleOpenChange} isDismissable>
|
||||
<Modal
|
||||
placement="bottom"
|
||||
offset="80px"
|
||||
isOpen={!!replay}
|
||||
onOpenChange={handleOpenChange}
|
||||
isDismissable
|
||||
{...props}
|
||||
>
|
||||
<Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
|
||||
<Dialog variant="sheet" className="rounded-lg">
|
||||
<Column padding="10">
|
||||
<Row justifyContent="flex-end">
|
||||
<Button onPress={closeModal} variant="quiet">
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
<ReplayPlayback websiteId={websiteId} sessionId={sessionId} />
|
||||
</Column>
|
||||
<Dialog variant="sheet">
|
||||
{({ close }) => (
|
||||
<Column padding="6">
|
||||
<ReplayPlayback websiteId={websiteId} replayId={replay} onClose={close} />
|
||||
</Column>
|
||||
)}
|
||||
</Dialog>
|
||||
</Column>
|
||||
</Modal>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function ReplaysDataTable({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<DataGrid query={queryResult} allowPaging allowSearch>
|
||||
{({ data }) => {
|
||||
return <ReplaysTable data={data} websiteId={websiteId} />;
|
||||
return <ReplaysTable data={data} />;
|
||||
}}
|
||||
</DataGrid>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { ReplayModal } from './ReplayModal';
|
||||
import { ReplaysDataTable } from './ReplaysDataTable';
|
||||
|
||||
export function ReplaysPage({ websiteId }: { websiteId: string }) {
|
||||
@@ -11,6 +13,8 @@ export function ReplaysPage({ websiteId }: { websiteId: string }) {
|
||||
<Panel>
|
||||
<ReplaysDataTable websiteId={websiteId} />
|
||||
</Panel>
|
||||
<SessionModal websiteId={websiteId} />
|
||||
<ReplayModal websiteId={websiteId} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
|
||||
import { Play } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import { useFormat, useMessages } from '@/components/hooks';
|
||||
import { useFormat, useMessages, useNavigation } from '@/components/hooks';
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
@@ -13,56 +13,65 @@ function formatDuration(ms: number) {
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReplaysTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
|
||||
export function ReplaysTable({ ...props }: DataTableProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
const router = useRouter();
|
||||
const { router, updateParams } = useNavigation();
|
||||
|
||||
return (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="play" label="" width="80px">
|
||||
{(row: any) => (
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => router.push(`/websites/${websiteId}/replays/${row.id}`)}
|
||||
>
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="id" label={t(labels.session)} width="100px">
|
||||
{(row: any) => <Avatar seed={row.id} size={32} />}
|
||||
{(row: any) => (
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} 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">
|
||||
{(row: any) => (
|
||||
<Button variant="quiet" onClick={() => router.push(updateParams({ replay: row.id }))}>
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { Button, Column, Icon, Row, Text } from '@umami/react-zen';
|
||||
import { X } from 'lucide-react';
|
||||
import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useReplayQuery, useWebsiteSessionQuery } from '@/components/hooks';
|
||||
import { ReplayPlayer } from './ReplayPlayer';
|
||||
|
||||
export function ReplayPlayback({
|
||||
websiteId,
|
||||
replayId,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
replayId: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId);
|
||||
const { data: session } = useWebsiteSessionQuery(websiteId, replay?.sessionId);
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
data={replay}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
loadingIcon="spinner"
|
||||
loadingPlacement="absolute"
|
||||
>
|
||||
{replay && (
|
||||
<Column gap="6">
|
||||
{session && (
|
||||
<Row justifyContent="space-between" alignItems="flex-start">
|
||||
<Row alignItems="center" gap="4">
|
||||
<Avatar seed={replay.sessionId} size={48} />
|
||||
<Column>
|
||||
<Text weight="bold">{t(labels.replay)}</Text>
|
||||
<Text color="muted">
|
||||
{replay.eventCount} {t(labels.actions).toLowerCase()}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
{onClose && (
|
||||
<Button onPress={onClose} variant="quiet">
|
||||
<Icon>
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
<ReplayPlayer events={replay.events} />
|
||||
{session && <SessionInfo data={session} />}
|
||||
</Column>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
+12
-24
@@ -1,46 +1,34 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMobile } from '@/components/hooks';
|
||||
import 'rrweb-player/dist/style.css';
|
||||
|
||||
export function ReplayPlayer({ events }: { events: any[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<any>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const { isMobile, isPhone } = useMobile();
|
||||
|
||||
const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
|
||||
const playerHeight = isPhone ? 202 : isMobile ? 360 : 576;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !events?.length) return;
|
||||
|
||||
// Debug: log event info
|
||||
const typeCounts: Record<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;
|
||||
|
||||
// Clear any previous player
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
|
||||
playerRef.current = new RRWebPlayer({
|
||||
target: containerRef.current!,
|
||||
target: containerRef.current,
|
||||
props: {
|
||||
events,
|
||||
width: 1024,
|
||||
height: 576,
|
||||
events: events,
|
||||
width: playerWidth,
|
||||
height: playerHeight,
|
||||
autoPlay: false,
|
||||
showController: true,
|
||||
speedOption: [1, 2, 4, 8],
|
||||
@@ -56,15 +44,15 @@ export function ReplayPlayer({ events }: { events: any[] }) {
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [events]);
|
||||
}, [events, playerWidth, playerHeight]);
|
||||
|
||||
return (
|
||||
<Column alignItems="center">
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
minWidth: 1024,
|
||||
minHeight: loaded ? undefined : 576,
|
||||
width: playerWidth,
|
||||
minHeight: loaded ? undefined : playerHeight,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
+3
-3
@@ -4,11 +4,11 @@ import { ReplayPlayback } from './ReplayPlayback';
|
||||
export default async function ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ websiteId: string; sessionId: string }>;
|
||||
params: Promise<{ websiteId: string; replayId: string }>;
|
||||
}) {
|
||||
const { websiteId, sessionId } = await params;
|
||||
const { websiteId, replayId } = await params;
|
||||
|
||||
return <ReplayPlayback websiteId={websiteId} sessionId={sessionId} />;
|
||||
return <ReplayPlayback websiteId={websiteId} replayId={replayId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -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 (
|
||||
<Column gap="6" minHeight="800px">
|
||||
<LoadingPanel
|
||||
data={replay}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
loadingIcon="spinner"
|
||||
loadingPlacement="absolute"
|
||||
>
|
||||
{replay && (
|
||||
<>
|
||||
<Row alignItems="center" gap="4">
|
||||
<Avatar seed={sessionId} size={48} />
|
||||
<Column>
|
||||
<Text weight="bold">{t(labels.replay)}</Text>
|
||||
<Text color="muted">
|
||||
{replay.eventCount} {t(labels.events).toLowerCase()}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<SessionInfo data={session} />
|
||||
<Column paddingY="20">
|
||||
<ReplayPlayer events={replay.events} />
|
||||
</Column>
|
||||
</>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<TabList>
|
||||
<Tab id="activity">{t(labels.activity)}</Tab>
|
||||
<Tab id="properties">{t(labels.properties)}</Tab>
|
||||
{replay?.events?.length > 0 && <Tab id="replay">{t(labels.replay)}</Tab>}
|
||||
<Tab id="replays">{t(labels.replay)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="activity">
|
||||
<SessionActivity
|
||||
@@ -79,11 +78,9 @@ export function SessionProfile({
|
||||
<TabPanel id="properties">
|
||||
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
{replay?.events?.length > 0 && (
|
||||
<TabPanel id="replay">
|
||||
<ReplayPlayer events={replay.events} />
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel id="replays">
|
||||
<SessionReplaysDataTable websiteId={websiteId} sessionId={sessionId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
</Column>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ padding: '1.5rem 0' }}>
|
||||
<ReplayPlayer events={replay.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReplaysDataTable({
|
||||
websiteId,
|
||||
sessionId,
|
||||
}: {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const queryResult = useSessionReplaysQuery(websiteId, sessionId);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
const handlePlay = (id: string) => {
|
||||
setSelectedId(prev => (prev === id ? undefined : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Column>
|
||||
{selectedId && <InlinePlayer websiteId={websiteId} replayId={selectedId} />}
|
||||
<DataGrid query={queryResult} allowPaging>
|
||||
{({ data }) => (
|
||||
<SessionReplaysTable data={data} onPlay={handlePlay} selectedId={selectedId} />
|
||||
)}
|
||||
</DataGrid>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="id" label={t(labels.replayId)} />
|
||||
<DataColumn id="duration" label={t(labels.duration)} width="100px">
|
||||
{(row: any) => formatDuration(row.duration || 0)}
|
||||
</DataColumn>
|
||||
<DataColumn id="eventCount" label={t(labels.actions)} width="80px" />
|
||||
<DataColumn id="createdAt" label={t(labels.recordedAt)} width="140px">
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
<DataColumn id="play" label="" width="80px">
|
||||
{(row: any) => (
|
||||
<Button
|
||||
variant={row.id === selectedId ? 'primary' : 'quiet'}
|
||||
onClick={() => onPlay(row.id)}
|
||||
>
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -20,36 +20,37 @@ export function SessionsTable({ websiteId, ...props }: DataTableProps & { websit
|
||||
</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>
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-10
@@ -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,
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -28,7 +28,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
||||
const [currentMatch, setCurrentMatch] = useState<string>(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([]);
|
||||
|
||||
@@ -255,6 +255,8 @@ export const labels: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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<ReplayChunk[]> {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(websiteId, visitId),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(websiteId, visitId),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, visitId: string): Promise<ReplayChunk[]> {
|
||||
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<ReplayChunk[]> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './deleteReplaysByWebsite';
|
||||
export * from './getReplayChunks';
|
||||
export * from './getSessionReplays';
|
||||
export * from './saveReplayChunk';
|
||||
@@ -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),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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`) || '';
|
||||
|
||||
|
||||
Reference in new issue
Block a user