Progress check-in. Updated tracker and recorder scripts.

This commit is contained in:
Mike Cao
2026-03-04 12:21:36 -08:00
parent e9ac09ec5d
commit 8c1ee0b3ce
25 changed files with 350 additions and 74 deletions
+13 -4
View File
@@ -6,6 +6,8 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const TRACKER_SCRIPT = '/script.js';
const isProd = process.env.NODE_ENV === 'production';
const basePath = process.env.BASE_PATH || '';
const cloudMode = process.env.CLOUD_MODE || '';
const cloudUrl = process.env.CLOUD_URL || '';
@@ -17,6 +19,8 @@ const forceSSL = process.env.FORCE_SSL || '';
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || '';
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || '';
const selfTrack = process.env.UMAMI_SELF_TRACK || '';
const selfRecord = process.env.UMAMI_SELF_RECORD || '';
const contentSecurityPolicy = `
default-src 'self';
@@ -88,11 +92,14 @@ const headers = [
source: '/:path*',
headers: defaultHeaders,
},
{
];
if (isProd) {
headers.push({
source: TRACKER_SCRIPT,
headers: trackerHeaders,
},
];
});
}
const rewrites = [];
@@ -169,7 +176,7 @@ if (trackerScriptName) {
}
}
if (cloudMode) {
if (isProd && cloudMode) {
rewrites.push({
source: '/script.js',
destination: 'https://cloud.umami.is/script.js',
@@ -186,6 +193,8 @@ export default withNextIntl({
currentVersion: pkg.version,
defaultCurrency,
defaultLocale,
selfTrack,
selfRecord,
},
basePath,
output: 'standalone',
@@ -0,0 +1,36 @@
ALTER TABLE "session_replay"
ADD COLUMN IF NOT EXISTS "visit_id" UUID;
UPDATE "session_replay" AS sr
SET "visit_id" = we."visit_id"
FROM (
SELECT DISTINCT ON ("website_id", "session_id")
"website_id",
"session_id",
"visit_id"
FROM "website_event"
WHERE "visit_id" IS NOT NULL
ORDER BY "website_id", "session_id", "created_at" DESC
) AS we
WHERE sr."visit_id" IS NULL
AND sr."website_id" = we."website_id"
AND sr."session_id" = we."session_id";
UPDATE "session_replay"
SET "visit_id" = (
substr(md5("session_id"::text || ':' || "chunk_index"::text), 1, 8) ||
'-' ||
substr(md5("session_id"::text || ':' || "chunk_index"::text), 9, 4) ||
'-' ||
substr(md5("session_id"::text || ':' || "chunk_index"::text), 13, 4) ||
'-' ||
substr(md5("session_id"::text || ':' || "chunk_index"::text), 17, 4) ||
'-' ||
substr(md5("session_id"::text || ':' || "chunk_index"::text), 21, 12)
)::uuid
WHERE "visit_id" IS NULL;
ALTER TABLE "session_replay"
ALTER COLUMN "visit_id" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "session_replay_visit_id_idx" ON "session_replay"("visit_id");
+2
View File
@@ -385,6 +385,7 @@ model SessionReplay {
id String @id() @map("replay_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
chunkIndex Int @map("chunk_index") @db.Integer
events Bytes @map("events")
eventCount Int @map("event_count") @db.Integer
@@ -396,6 +397,7 @@ model SessionReplay {
@@index([websiteId])
@@index([sessionId])
@@index([visitId])
@@index([websiteId, sessionId])
@@index([websiteId, createdAt])
@@index([sessionId, chunkIndex])
+17
View File
@@ -58,6 +58,23 @@ export function App({ children }) {
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
<Script src={`${process.env.basePath || ''}/telemetry.js`} />
)}
{process.env.selfTrack && (
<Script
async
data-website-id={process.env.selfTrack}
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
data-performance="true"
/>
)}
{process.env.selfRecord && (
<Script
async
data-website-id={process.env.selfRecord}
data-sample-rate="1"
src={`${process.env.basePath || ''}/recorder.js`}
/>
)}
</Grid>
);
}
@@ -1,9 +1,9 @@
import { Text } from '@umami/react-zen';
import { Icon, Row, Text } from '@umami/react-zen';
import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation, useWebsiteQuery } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { Edit, Globe } from '@/components/icons';
export function BoardViewHeader() {
const { board } = useBoard();
@@ -13,10 +13,19 @@ export function BoardViewHeader() {
return (
<PageHeader title={board?.name} description={board?.description}>
{website?.name && <Text>{website.name}</Text>}
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
<Row alignItems="center" gap>
{website?.name && (
<Row padding borderRadius="full" backgroundColor="surface-base" border gap="2">
<Icon>
<Globe />
</Icon>
<Text size="sm">{website.name}</Text>
</Row>
)}
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
</Row>
</PageHeader>
);
}
@@ -115,6 +115,7 @@ export function TestConsolePage({ websiteId }: { websiteId: string }) {
data-website-id={websiteId}
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
data-performance="true"
/>
<Script
async
@@ -0,0 +1,11 @@
import { ReplayModal } from '@/app/(main)/websites/[websiteId]/replays/ReplayModal';
export default async function ({
params,
}: {
params: Promise<{ websiteId: string; sessionId: string }>;
}) {
const { websiteId, sessionId } = await params;
return <ReplayModal websiteId={websiteId} sessionId={sessionId} />;
}
@@ -0,0 +1,11 @@
import { SessionProfileModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfileModal';
export default async function ({
params,
}: {
params: Promise<{ websiteId: string; sessionId: string }>;
}) {
const { websiteId, sessionId } = await params;
return <SessionProfileModal websiteId={websiteId} sessionId={sessionId} />;
}
@@ -0,0 +1,3 @@
export default function CatchAll() {
return null;
}
@@ -0,0 +1,3 @@
export default function Default() {
return null;
}
@@ -4,9 +4,11 @@ import { getWebsite } from '@/queries/prisma';
export default async function ({
children,
modal,
params,
}: {
children: any;
modal: React.ReactNode;
params: Promise<{ websiteId: string }>;
}) {
const { websiteId } = await params;
@@ -16,7 +18,12 @@ export default async function ({
return null;
}
return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
return (
<WebsiteLayout websiteId={websiteId}>
{children}
{modal}
</WebsiteLayout>
);
}
export const metadata: Metadata = {
@@ -0,0 +1,43 @@
'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';
export function ReplayModal({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const closeModal = () => {
const query = Object.fromEntries(searchParams.entries());
delete query.session;
router.push(buildPath(`/websites/${websiteId}/replays`, query));
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
closeModal();
}
};
return (
<Modal placement="bottom" offset="80px" isOpen onOpenChange={handleOpenChange} isDismissable>
<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>
</Column>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import { Play } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Avatar } from '@/components/common/Avatar';
import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon';
@@ -16,9 +16,22 @@ function formatDuration(ms: number) {
export function ReplaysTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
const { t, labels } = useMessages();
const { formatValue } = useFormat();
const router = useRouter();
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} />}
</DataColumn>
@@ -50,17 +63,6 @@ export function ReplaysTable({ websiteId, ...props }: DataTableProps & { website
<DataColumn id="createdAt" label={t(labels.recordedAt)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="play" label="" width="80px">
{(row: any) => (
<Link href={`/websites/${websiteId}/replays/${row.id}`}>
<Button variant="quiet">
<Icon>
<Play />
</Icon>
</Button>
</Link>
)}
</DataColumn>
</DataTable>
);
}
@@ -3,6 +3,7 @@ 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';
@@ -12,16 +13,16 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se
const { t, labels } = useMessages();
return (
<LoadingPanel
data={replay}
isLoading={isLoading}
error={error}
loadingIcon="spinner"
loadingPlacement="absolute"
>
{replay && (
<Column gap="6">
{session && (
<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>
@@ -31,11 +32,13 @@ export function ReplayPlayback({ websiteId, sessionId }: { websiteId: string; se
</Text>
</Column>
</Row>
)}
<ReplayPlayer events={replay.events} />
{session && <SessionInfo data={session} />}
</Column>
)}
</LoadingPanel>
<SessionInfo data={session} />
<Column paddingY="20">
<ReplayPlayer events={replay.events} />
</Column>
</>
)}
</LoadingPanel>
</Column>
);
}
@@ -1,3 +1,4 @@
'use client';
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
import { useNavigation } from '@/components/hooks';
@@ -28,9 +29,9 @@ export function SessionModal({ websiteId, ...props }: SessionModalProps) {
{...props}
>
<Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
<Dialog variant="sheet">
<Dialog variant="sheet" className="rounded-lg">
{({ close }) => (
<Column padding="6">
<Column padding="10">
<SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} />
</Column>
)}
@@ -1,3 +1,4 @@
'use client';
import {
Button,
Column,
@@ -0,0 +1,41 @@
'use client';
import { Column, Dialog, Modal } from '@umami/react-zen';
import { useRouter, useSearchParams } from 'next/navigation';
import { buildPath } from '@/lib/url';
import { SessionProfile } from './SessionProfile';
export function SessionProfileModal({
websiteId,
sessionId,
}: {
websiteId: string;
sessionId: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const closeModal = () => {
const query = Object.fromEntries(searchParams.entries());
delete query.session;
router.push(buildPath(`/websites/${websiteId}/sessions`, query));
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
closeModal();
}
};
return (
<Modal placement="bottom" offset="80px" isOpen onOpenChange={handleOpenChange} isDismissable>
<Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
<Dialog variant="sheet" className="rounded-lg">
<Column padding="10">
<SessionProfile websiteId={websiteId} sessionId={sessionId} onClose={closeModal} />
</Column>
</Dialog>
</Column>
</Modal>
);
}
@@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useWebsiteSessionsQuery } from '@/components/hooks';
import { SessionsTable } from './SessionsTable';
export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
export function SessionsDataTable({ websiteId }: { websiteId: string }) {
const queryResult = useWebsiteSessionsQuery(websiteId);
return (
<DataGrid query={queryResult} allowPaging allowSearch>
{({ data }) => {
return <SessionsTable data={data} />;
return <SessionsTable data={data} websiteId={websiteId} />;
}}
</DataGrid>
);
@@ -1,7 +1,6 @@
'use client';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
@@ -37,7 +36,6 @@ export function SessionsPage({ websiteId }) {
</TabPanel>
</Tabs>
</Panel>
<SessionModal websiteId={websiteId} />
</Column>
);
}
@@ -3,18 +3,17 @@ 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, useNavigation } from '@/components/hooks';
import { useFormat, useMessages } from '@/components/hooks';
export function SessionsTable(props: DataTableProps) {
export function SessionsTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
const { t, labels } = useMessages();
const { formatValue } = useFormat();
const { updateParams } = useNavigation();
return (
<DataTable {...props}>
<DataColumn id="id" label={t(labels.session)} width="100px">
{(row: any) => (
<Link href={updateParams({ session: row.id })}>
<Link href={`/websites/${websiteId}/sessions/${row.id}`}>
<Avatar seed={row.id} size={32} />
</Link>
)}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
export default async function ({
params,
}: {
params: Promise<{ websiteId: string; sessionId: string }>;
}) {
const { websiteId, sessionId } = await params;
return <SessionProfile websiteId={websiteId} sessionId={sessionId} />;
}
export const metadata: Metadata = {
title: 'Session',
};
+17 -7
View File
@@ -10,6 +10,11 @@ import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
import { saveReplayChunk } from '@/queries/sql';
interface Cache {
sessionId: string;
visitId: string;
}
const schema = z.object({
website: z.uuid(),
events: z.array(z.any()).max(200),
@@ -37,13 +42,13 @@ export async function POST(request: Request) {
return badRequest({ message: 'Missing session token.' });
}
const cache = await parseToken(cacheHeader, secret());
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
if (!cache || !cache.sessionId) {
if (!cache?.sessionId || !cache?.visitId) {
return badRequest({ message: 'Invalid session token.' });
}
const { sessionId } = cache;
const { sessionId, visitId } = cache;
// Query directly to avoid stale Redis cache for recordingEnabled
const website = await getWebsite(websiteId);
@@ -69,11 +74,15 @@ export async function POST(request: Request) {
// Compute timestamps from events
const eventTimestamps = events
.map((e: any) => e.timestamp)
.filter((t: any) => typeof t === 'number');
.map((e: any) => Number(e?.timestamp))
.filter((t: number) => Number.isFinite(t) && t > 0);
const startedAt = new Date(Math.min(...eventTimestamps));
const endedAt = new Date(Math.max(...eventTimestamps));
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
const minTimestamp = eventTimestamps.length ? Math.min(...eventTimestamps) : fallbackMs;
const maxTimestamp = eventTimestamps.length ? Math.max(...eventTimestamps) : fallbackMs;
const startedAt = new Date(minTimestamp);
const endedAt = new Date(maxTimestamp);
// Compress events
const eventsJson = JSON.stringify(events);
@@ -85,6 +94,7 @@ export async function POST(request: Request) {
await saveReplayChunk({
websiteId,
sessionId,
visitId,
chunkIndex,
events: compressed,
eventCount: events.length,
@@ -4,6 +4,7 @@ import prisma from '@/lib/prisma';
export interface SaveReplayChunkArgs {
websiteId: string;
sessionId: string;
visitId: string;
chunkIndex: number;
events: Uint8Array;
eventCount: number;
@@ -18,6 +19,7 @@ export async function saveReplayChunk(args: SaveReplayChunkArgs) {
async function relationalQuery({
websiteId,
sessionId,
visitId,
chunkIndex,
events,
eventCount,
@@ -29,6 +31,7 @@ async function relationalQuery({
id: uuid(),
websiteId,
sessionId,
visitId,
chunkIndex,
events: new Uint8Array(events) as any,
eventCount,
+7 -6
View File
@@ -7,13 +7,14 @@ import { record } from 'rrweb';
const _data = 'data-';
const attr = currentScript.getAttribute.bind(currentScript);
const config = value => attr(`${_data}${value}`);
const website = attr(`${_data}website-id`);
const hostUrl = attr(`${_data}host-url`);
const sampleRate = parseFloat(attr(`${_data}sample-rate`) || '0.15');
const maskLevel = attr(`${_data}mask-level`) || 'strict';
const maxDuration = parseInt(attr(`${_data}max-duration`) || '300000', 10);
const blockSelector = attr(`${_data}block-selector`) || '';
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 maxDuration = parseInt(config(`max-duration`) || '300000', 10);
const blockSelector = config(`block-selector`) || '';
if (!website) return;
+61 -12
View File
@@ -24,18 +24,19 @@
const _false = 'false';
const _true = 'true';
const attr = currentScript.getAttribute.bind(currentScript);
const config = value => attr(`${_data}${value}`);
const website = attr(`${_data}website-id`);
const hostUrl = attr(`${_data}host-url`);
const beforeSend = attr(`${_data}before-send`);
const tag = attr(`${_data}tag`) || undefined;
const autoTrack = attr(`${_data}auto-track`) !== _false;
const dnt = attr(`${_data}do-not-track`) === _true;
const excludeSearch = attr(`${_data}exclude-search`) === _true;
const excludeHash = attr(`${_data}exclude-hash`) === _true;
const domain = attr(`${_data}domains`) || '';
const credentials = attr(`${_data}fetch-credentials`) || 'omit';
const perf = attr(`${_data}perf`) === _true;
const website = config('website-id');
const hostUrl = config('host-url');
const beforeSend = config('before-send');
const tag = config('tag') || undefined;
const autoTrack = config('auto-track') !== _false;
const dnt = config('do-not-track') === _true;
const excludeSearch = config('exclude-search') === _true;
const excludeHash = config('exclude-hash') === _true;
const domain = config('domains') || '';
const credentials = config('fetch-credentials') || 'omit';
const perf = config('performance') === _true;
const domains = domain.split(',').map(n => n.trim());
const host =
@@ -82,6 +83,10 @@
const handlePush = (_state, _title, url) => {
if (!url) return;
if (typeof flushPerformance === 'function') {
flushPerformance();
}
currentRef = currentUrl;
currentUrl = normalize(new URL(url, location.href).toString());
@@ -226,6 +231,7 @@
const initPerformance = () => {
const metrics = {};
let sent = false;
let timeoutId;
const observe = (type, callback) => {
try {
@@ -280,12 +286,54 @@
/* not supported */
}
const getEntriesByType = type => {
try {
return window.performance?.getEntriesByType?.(type) || [];
} catch {
return [];
}
};
const applyFallbackMetrics = () => {
if (metrics.ttfb === undefined) {
const navigation = getEntriesByType('navigation')?.[0];
if (navigation) {
metrics.ttfb = Math.max(navigation.responseStart - navigation.requestStart, 0);
}
}
if (metrics.fcp === undefined) {
const fcpEntry = getEntriesByType('paint')?.find(
entry => entry.name === 'first-contentful-paint',
);
if (fcpEntry) {
metrics.fcp = fcpEntry.startTime;
}
}
if (metrics.lcp === undefined) {
const lcpEntries = getEntriesByType('largest-contentful-paint');
const lcpEntry = lcpEntries?.[lcpEntries.length - 1];
if (lcpEntry) {
metrics.lcp = lcpEntry.startTime;
}
}
};
const sendPerformance = () => {
if (sent || !Object.keys(metrics).length) return;
if (sent) return;
applyFallbackMetrics();
if (!Object.keys(metrics).length) return;
sent = true;
if (timeoutId) clearTimeout(timeoutId);
send({ ...getPayload(), ...metrics }, 'performance');
};
flushPerformance = sendPerformance;
timeoutId = setTimeout(sendPerformance, 10000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sendPerformance();
});
@@ -309,6 +357,7 @@
let disabled = false;
let cache;
let identity;
let flushPerformance;
if (autoTrack && !trackingDisabled()) {
if (document.readyState === 'complete') {