Progress check-in. Updated tracker and recorder scripts.
This commit is contained in:
+13
-4
@@ -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");
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user