Add Web Vitals performance tracking (LCP, INP, CLS, FCP, TTFB)

End-to-end performance metrics tracking: dedicated database table with
percentile aggregation, tracker collection behind data-perf attribute,
/api/send handling, report API endpoints, and Performance dashboard page
with threshold-based metric cards, time-series chart, and per-page breakdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-02-08 19:25:20 -08:00
parent 8cd3c03702
commit ce9e2416fb
24 changed files with 1028 additions and 8 deletions

View file

@ -0,0 +1,72 @@
-- Create Performance
CREATE TABLE umami.website_performance
(
website_id UUID,
session_id UUID,
visit_id UUID,
url_path String,
lcp Nullable(Decimal(10, 1)),
inp Nullable(Decimal(10, 1)),
cls Nullable(Decimal(10, 4)),
fcp Nullable(Decimal(10, 1)),
ttfb Nullable(Decimal(10, 1)),
created_at DateTime('UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (website_id, toStartOfHour(created_at), session_id)
SETTINGS index_granularity = 8192;
-- Performance hourly aggregation
CREATE TABLE umami.website_performance_hourly
(
website_id UUID,
url_path String,
lcp_p50 AggregateFunction(quantile(0.5), Nullable(Decimal(10, 1))),
lcp_p75 AggregateFunction(quantile(0.75), Nullable(Decimal(10, 1))),
lcp_p95 AggregateFunction(quantile(0.95), Nullable(Decimal(10, 1))),
inp_p50 AggregateFunction(quantile(0.5), Nullable(Decimal(10, 1))),
inp_p75 AggregateFunction(quantile(0.75), Nullable(Decimal(10, 1))),
inp_p95 AggregateFunction(quantile(0.95), Nullable(Decimal(10, 1))),
cls_p50 AggregateFunction(quantile(0.5), Nullable(Decimal(10, 4))),
cls_p75 AggregateFunction(quantile(0.75), Nullable(Decimal(10, 4))),
cls_p95 AggregateFunction(quantile(0.95), Nullable(Decimal(10, 4))),
fcp_p50 AggregateFunction(quantile(0.5), Nullable(Decimal(10, 1))),
fcp_p75 AggregateFunction(quantile(0.75), Nullable(Decimal(10, 1))),
fcp_p95 AggregateFunction(quantile(0.95), Nullable(Decimal(10, 1))),
ttfb_p50 AggregateFunction(quantile(0.5), Nullable(Decimal(10, 1))),
ttfb_p75 AggregateFunction(quantile(0.75), Nullable(Decimal(10, 1))),
ttfb_p95 AggregateFunction(quantile(0.95), Nullable(Decimal(10, 1))),
sample_count SimpleAggregateFunction(sum, UInt64),
created_at DateTime('UTC')
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (website_id, toStartOfHour(created_at), url_path)
SETTINGS index_granularity = 8192;
CREATE MATERIALIZED VIEW umami.website_performance_hourly_mv
TO umami.website_performance_hourly
AS
SELECT
website_id,
url_path,
quantileState(0.5)(lcp) as lcp_p50,
quantileState(0.75)(lcp) as lcp_p75,
quantileState(0.95)(lcp) as lcp_p95,
quantileState(0.5)(inp) as inp_p50,
quantileState(0.75)(inp) as inp_p75,
quantileState(0.95)(inp) as inp_p95,
quantileState(0.5)(cls) as cls_p50,
quantileState(0.75)(cls) as cls_p75,
quantileState(0.95)(cls) as cls_p95,
quantileState(0.5)(fcp) as fcp_p50,
quantileState(0.75)(fcp) as fcp_p75,
quantileState(0.95)(fcp) as fcp_p95,
quantileState(0.5)(ttfb) as ttfb_p50,
quantileState(0.75)(ttfb) as ttfb_p75,
quantileState(0.95)(ttfb) as ttfb_p95,
count() as sample_count,
toStartOfHour(created_at) as created_at
FROM umami.website_performance
GROUP BY website_id, url_path, toStartOfHour(created_at);

View file

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "performance" (
"performance_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"visit_id" UUID NOT NULL,
"url_path" VARCHAR(500) NOT NULL,
"lcp" DECIMAL(10,1),
"inp" DECIMAL(10,1),
"cls" DECIMAL(10,4),
"fcp" DECIMAL(10,1),
"ttfb" DECIMAL(10,1),
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "performance_pkey" PRIMARY KEY ("performance_id")
);
-- CreateIndex
CREATE INDEX "performance_website_id_idx" ON "performance"("website_id");
-- CreateIndex
CREATE INDEX "performance_session_id_idx" ON "performance"("session_id");
-- CreateIndex
CREATE INDEX "performance_website_id_created_at_idx" ON "performance"("website_id", "created_at");
-- CreateIndex
CREATE INDEX "performance_website_id_url_path_created_at_idx" ON "performance"("website_id", "url_path", "created_at");

View file

@ -47,6 +47,7 @@ model Session {
websiteEvents WebsiteEvent[]
sessionData SessionData[]
performance Performance[]
revenue Revenue[]
@@index([createdAt])
@ -79,6 +80,7 @@ model Website {
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
performance Performance[]
reports Report[]
revenue Revenue[]
segments Segment[]
@ -338,6 +340,29 @@ model Board {
@@map("board")
}
model Performance {
id String @id() @map("performance_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
urlPath String @map("url_path") @db.VarChar(500)
lcp Decimal? @db.Decimal(10, 1)
inp Decimal? @db.Decimal(10, 1)
cls Decimal? @db.Decimal(10, 4)
fcp Decimal? @db.Decimal(10, 1)
ttfb Decimal? @db.Decimal(10, 1)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@index([websiteId, createdAt])
@@index([websiteId, urlPath, createdAt])
@@map("performance")
}
model Share {
id String @id() @map("share_id") @db.Uuid
entityId String @map("entity_id") @db.Uuid

View file

@ -44,6 +44,7 @@
"cities": "Cities",
"city": "City",
"clear-all": "Clear all",
"cls": "CLS",
"cohort": "Cohort",
"cohorts": "Cohorts",
"compare": "Compare",
@ -113,6 +114,7 @@
"exists": "Exists",
"exit": "Exit page",
"false": "False",
"fcp": "FCP",
"field": "Field",
"fields": "Fields",
"filter": "Filter",
@ -126,6 +128,7 @@
"funnels": "Funnels",
"goal": "Goal",
"goals": "Goals",
"good": "Good",
"goals-description": "Track your goals for pageviews and events.",
"greater-than": "Greater than",
"greater-than-equals": "Greater than or equals",
@ -137,6 +140,7 @@
"insight": "Insight",
"insights": "Insights",
"insights-description": "Dive deeper into your data by using segments and filters.",
"inp": "INP",
"invalid-url": "Invalid URL",
"is": "Is",
"is-false": "Is false",
@ -157,6 +161,7 @@
"last-hours": "Last {x} hours",
"last-months": "Last {x} months",
"last-seen": "Last seen",
"lcp": "LCP",
"leave": "Leave",
"leave-team": "Leave team",
"less-than": "Less than",
@ -182,6 +187,7 @@
"my-account": "My account",
"my-websites": "My websites",
"name": "Name",
"needs-improvement": "Needs improvement",
"new-password": "New password",
"none": "None",
"number-of-records": "{x} {x, plural, one {record} other {records}}",
@ -208,8 +214,10 @@
"password": "Password",
"path": "Path",
"paths": "Paths",
"performance": "Performance",
"pixel": "Pixel",
"pixels": "Pixels",
"poor": "Poor",
"powered-by": "Powered by {name}",
"preferences": "Preferences",
"previous": "Previous",
@ -246,6 +254,7 @@
"save": "Save",
"save-cohort": "Save cohort",
"save-segment": "Save segment",
"sample-size": "Sample size",
"screen": "Screen",
"screens": "Screens",
"search": "Search",
@ -303,6 +312,7 @@
"transfer": "Transfer",
"transfer-website": "Transfer website",
"true": "True",
"ttfb": "TTFB",
"type": "Type",
"unique": "Unique",
"unique-events": "Unique events",

View file

@ -0,0 +1,3 @@
.sampleCount {
color: var(--base-color-text-secondary);
}

View file

@ -0,0 +1,184 @@
'use client';
import { Column, Grid, Row, Text } from '@umami/react-zen';
import { colord } from 'colord';
import { useCallback, useMemo, useState } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { PerformanceCard } from '@/components/metrics/PerformanceCard';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS, WEB_VITALS_THRESHOLDS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
import styles from './Performance.module.css';
export interface PerformanceProps {
websiteId: string;
startDate: Date;
endDate: Date;
unit: string;
}
const METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb'] as const;
const METRIC_LABELS: Record<string, string> = {
lcp: 'Largest Contentful Paint',
inp: 'Interaction to Next Paint',
cls: 'Cumulative Layout Shift',
fcp: 'First Contentful Paint',
ttfb: 'Time to First Byte',
};
function formatMetricValue(metric: string, value: number): string {
if (metric === 'cls') {
return value.toFixed(3);
}
return `${Math.round(value)} ms`;
}
export function Performance({ websiteId, startDate, endDate, unit }: PerformanceProps) {
const [selectedMetric, setSelectedMetric] = useState<string>('lcp');
const { t, labels } = useMessages();
const { locale, dateLocale } = useLocale();
const { data, error, isLoading } = useResultQuery<any>('performance', {
websiteId,
startDate,
endDate,
metric: selectedMetric,
});
const chartData: any = useMemo(() => {
if (!data?.chart) return { datasets: [] };
const p50Color = colord(CHART_COLORS[0]);
const p75Color = colord(CHART_COLORS[1]);
const p95Color = colord(CHART_COLORS[2]);
return {
datasets: [
{
label: 'p50',
data: generateTimeSeries(
data.chart.map((d: any) => ({ x: d.t, y: Number(d.p50) })),
startDate,
endDate,
unit,
dateLocale,
),
type: 'line',
borderColor: p50Color.alpha(0.8).toRgbString(),
backgroundColor: p50Color.alpha(0.1).toRgbString(),
borderWidth: 2,
fill: false,
tension: 0.3,
pointRadius: 2,
},
{
label: 'p75',
data: generateTimeSeries(
data.chart.map((d: any) => ({ x: d.t, y: Number(d.p75) })),
startDate,
endDate,
unit,
dateLocale,
),
type: 'line',
borderColor: p75Color.alpha(0.8).toRgbString(),
backgroundColor: p75Color.alpha(0.1).toRgbString(),
borderWidth: 2,
fill: false,
tension: 0.3,
pointRadius: 2,
},
{
label: 'p95',
data: generateTimeSeries(
data.chart.map((d: any) => ({ x: d.t, y: Number(d.p95) })),
startDate,
endDate,
unit,
dateLocale,
),
type: 'line',
borderColor: p95Color.alpha(0.8).toRgbString(),
backgroundColor: p95Color.alpha(0.1).toRgbString(),
borderWidth: 2,
fill: false,
tension: 0.3,
pointRadius: 2,
},
],
};
}, [data, startDate, endDate, unit]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
const threshold = WEB_VITALS_THRESHOLDS[selectedMetric as keyof typeof WEB_VITALS_THRESHOLDS];
return (
<Column gap>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Column gap>
<Grid columns={{ base: '1fr 1fr', lg: 'repeat(5, 1fr)' }} gap>
{METRICS.map(metric => (
<PerformanceCard
key={metric}
metric={metric}
value={Number(data.summary?.[metric]?.p75 || 0)}
label={t(labels[metric]) || metric.toUpperCase()}
formatValue={(n: number) => formatMetricValue(metric, n)}
onClick={() => setSelectedMetric(metric)}
selected={selectedMetric === metric}
/>
))}
</Grid>
<Panel>
<Column gap="4" padding="4">
<Row justifyContent="space-between" alignItems="center">
<Text weight="bold">{METRIC_LABELS[selectedMetric]}</Text>
<Row gap="4">
<Text size="sm" className={styles.sampleCount}>
{t(labels.sampleSize)}: {formatLongNumber(data.summary?.count || 0)}
</Text>
</Row>
</Row>
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
renderXLabel={renderXLabel}
renderYLabel={(label: string) => {
const val = Number(label);
if (selectedMetric === 'cls') return val.toFixed(2);
return `${Math.round(val)} ms`;
}}
height="400px"
/>
</Column>
</Panel>
<Panel>
<ListTable
title={t(labels.pages)}
metric={t(labels[selectedMetric]) || selectedMetric.toUpperCase()}
data={data.pages?.map(
({ urlPath, p75, count }: { urlPath: string; p75: number; count: number }) => ({
label: urlPath,
count: Number(p75),
percent: 0,
}),
)}
renderLabel={({ label }: { label: string }) => <Text>{label}</Text>}
/>
</Panel>
</Column>
)}
</LoadingPanel>
</Column>
);
}

View file

@ -0,0 +1,18 @@
'use client';
import { Column } from '@umami/react-zen';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange } from '@/components/hooks';
import { Performance } from './Performance';
export function PerformancePage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate, unit },
} = useDateRange();
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Performance websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} />
</Column>
);
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { PerformancePage } from './PerformancePage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <PerformancePage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Performance',
};

View file

@ -0,0 +1,26 @@
import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getPerformance, type PerformanceParameters } from '@/queries/sql/reports/getPerformance';
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema);
if (error) {
return error();
}
const { websiteId } = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = await getQueryFilters(body.filters, websiteId);
const data = await getPerformance(websiteId, parameters as PerformanceParameters, filters);
return json(data);
}

View file

@ -12,7 +12,7 @@ import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
import { createSession, saveEvent, savePerformance, saveSessionData } from '@/queries/sql';
interface Cache {
websiteId: string;
@ -22,7 +22,7 @@ interface Cache {
}
const schema = z.object({
type: z.enum(['event', 'identify']),
type: z.enum(['event', 'identify', 'performance']),
payload: z
.object({
website: z.uuid().optional(),
@ -44,6 +44,11 @@ const schema = z.object({
browser: z.string().optional(),
os: z.string().optional(),
device: z.string().optional(),
lcp: z.number().nonnegative().max(60000).optional(),
inp: z.number().nonnegative().max(60000).optional(),
cls: z.number().nonnegative().max(100).optional(),
fcp: z.number().nonnegative().max(60000).optional(),
ttfb: z.number().nonnegative().max(60000).optional(),
})
.refine(
data => {
@ -83,6 +88,11 @@ export async function POST(request: Request) {
tag,
timestamp,
id,
lcp,
inp,
cls,
fcp,
ttfb,
} = payload;
const sourceId = websiteId || pixelId || linkId;
@ -269,6 +279,23 @@ export async function POST(request: Request) {
createdAt,
});
}
} else if (type === COLLECTION_TYPE.performance) {
const base = hostname ? `https://${hostname}` : 'https://localhost';
const currentUrl = new URL(url, base);
const urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname;
await savePerformance({
websiteId: sourceId,
sessionId,
visitId,
urlPath,
lcp,
inp,
cls,
fcp,
ttfb,
createdAt,
});
}
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());

View file

@ -0,0 +1,33 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getPerformanceStats } from '@/queries/sql';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const data = await getPerformanceStats(websiteId, filters);
return json(data);
}

View file

@ -8,7 +8,7 @@ import {
User,
UserPlus,
} from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { Funnel, Gauge, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { useMessages } from './useMessages';
import { useNavigation } from './useNavigation';
@ -53,6 +53,12 @@ export function useWebsiteNavItems(websiteId: string) {
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'performance',
label: t(labels.performance),
icon: <Gauge />,
path: renderPath('/performance'),
},
{
id: 'compare',
label: t(labels.compare),

View file

@ -340,6 +340,16 @@ export const labels: Record<string, string> = {
support: 'label.support',
documentation: 'label.documentation',
switchAccount: 'label.switch-account',
performance: 'label.performance',
lcp: 'label.lcp',
inp: 'label.inp',
cls: 'label.cls',
fcp: 'label.fcp',
ttfb: 'label.ttfb',
good: 'label.good',
needsImprovement: 'label.needs-improvement',
poor: 'label.poor',
sampleSize: 'label.sample-size',
};
export const messages: Record<string, string> = {

View file

@ -0,0 +1,19 @@
.card {
transition: box-shadow 0.2s;
}
.card.selected {
box-shadow: 0 0 0 2px var(--base-color-primary);
}
.good .rating {
color: #0cce6b;
}
.needs-improvement .rating {
color: #ffa400;
}
.poor .rating {
color: #ff4e42;
}

View file

@ -0,0 +1,62 @@
import { useSpring } from '@react-spring/web';
import { Column, Text } from '@umami/react-zen';
import { AnimatedDiv } from '@/components/common/AnimatedDiv';
import { useMessages } from '@/components/hooks';
import { WEB_VITALS_THRESHOLDS } from '@/lib/constants';
import { formatNumber } from '@/lib/format';
import styles from './PerformanceCard.module.css';
export interface PerformanceCardProps {
metric: 'lcp' | 'inp' | 'cls' | 'fcp' | 'ttfb';
value: number;
label: string;
formatValue?: (n: any) => string;
onClick?: () => void;
selected?: boolean;
}
function getRating(metric: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const threshold = WEB_VITALS_THRESHOLDS[metric as keyof typeof WEB_VITALS_THRESHOLDS];
if (!threshold || value <= 0) return 'good';
if (value <= threshold.good) return 'good';
if (value <= threshold.poor) return 'needs-improvement';
return 'poor';
}
export const PerformanceCard = ({
metric,
value = 0,
label,
formatValue = formatNumber,
onClick,
selected = false,
}: PerformanceCardProps) => {
const { t, labels } = useMessages();
const rating = getRating(metric, value);
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
return (
<Column
className={`${styles.card} ${styles[rating]} ${selected ? styles.selected : ''}`}
justifyContent="center"
paddingX="6"
paddingY="4"
borderRadius
backgroundColor="surface-base"
border
gap="4"
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : undefined }}
>
<Text weight="bold" wrap="nowrap">
{label}
</Text>
<Text size="4xl" weight="bold" wrap="nowrap">
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
</Text>
<Text size="sm" className={styles.rating}>
{t(labels[rating === 'needs-improvement' ? 'needsImprovement' : rating])}
</Text>
</Column>
);
};

View file

@ -0,0 +1,15 @@
import type { SVGProps } from 'react';
const SvgGauge = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
fill="currentColor"
viewBox="0 0 512 512"
{...props}
>
<path d="M256 48C141.1 48 48 141.1 48 256s93.1 208 208 208 208-93.1 208-208S370.9 48 256 48m0 384c-97.2 0-176-78.8-176-176S158.8 80 256 80s176 78.8 176 176-78.8 176-176 176" />
<path d="M256 128c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16s16-7.2 16-16v-16c0-8.8-7.2-16-16-16m-108.7 44.7-11.3-11.3c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l11.3 11.3c6.2 6.2 16.4 6.2 22.6 0s6.2-16.4 0-22.6M144 240h-16c-8.8 0-16 7.2-16 16s7.2 16 16 16h16c8.8 0 16-7.2 16-16s-7.2-16-16-16m240 0h-16c-8.8 0-16 7.2-16 16s7.2 16 16 16h16c8.8 0 16-7.2 16-16s-7.2-16-16-16m-44.7-67.3c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l11.3 11.3c6.2 6.2 16.4 6.2 22.6 0s6.2-16.4 0-22.6zM256 208c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48m0 64c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16m0 64c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16s16-7.2 16-16v-16c0-8.8-7.2-16-16-16" />
</svg>
);
export default SvgGauge;

View file

@ -11,6 +11,7 @@ export { default as Expand } from './Expand';
export { default as Export } from './Export';
export { default as Flag } from './Flag';
export { default as Funnel } from './Funnel';
export { default as Gauge } from './Gauge';
export { default as Gear } from './Gear';
export { default as Lightbulb } from './Lightbulb';
export { default as Lightning } from './Lightning';

View file

@ -98,6 +98,15 @@ export const FILTER_COLUMNS = {
export const COLLECTION_TYPE = {
event: 'event',
identify: 'identify',
performance: 'performance',
} as const;
export const WEB_VITALS_THRESHOLDS = {
lcp: { good: 2500, poor: 4000, unit: 'ms' },
inp: { good: 200, poor: 500, unit: 'ms' },
cls: { good: 0.1, poor: 0.25, unit: '' },
fcp: { good: 1800, poor: 3000, unit: 'ms' },
ttfb: { good: 800, poor: 1800, unit: 'ms' },
} as const;
export const EVENT_TYPE = {

View file

@ -112,6 +112,7 @@ export const reportTypeParam = z.enum([
'funnel',
'goal',
'journey',
'performance',
'retention',
'revenue',
'utm',
@ -200,6 +201,17 @@ export const utmReportSchema = z.object({
}),
});
export const performanceReportSchema = z.object({
type: z.literal('performance'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
unit: unitParam.optional(),
timezone: timezoneParam.optional(),
metric: z.enum(['lcp', 'inp', 'cls', 'fcp', 'ttfb']).optional(),
}),
});
export const revenueReportSchema = z.object({
type: z.literal('revenue'),
parameters: z.object({
@ -244,6 +256,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
goalReportSchema,
funnelReportSchema,
journeyReportSchema,
performanceReportSchema,
retentionReportSchema,
utmReportSchema,
revenueReportSchema,

View file

@ -22,6 +22,8 @@ export * from './getWeeklyTraffic';
export * from './pageviews/getPageviewExpandedMetrics';
export * from './pageviews/getPageviewMetrics';
export * from './pageviews/getPageviewStats';
export * from './performance/getPerformanceStats';
export * from './performance/savePerformance';
export * from './reports/getBreakdown';
export * from './reports/getFunnel';
export * from './reports/getJourney';

View file

@ -0,0 +1,72 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
export interface PerformanceStatsResult {
lcp: number;
inp: number;
cls: number;
fcp: number;
ttfb: number;
count: number;
}
export async function getPerformanceStats(...args: [websiteId: string, filters: QueryFilters]) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
filters: QueryFilters,
): Promise<PerformanceStatsResult> {
const { rawQuery } = prisma;
const { startDate, endDate } = filters;
const result = await rawQuery(
`
select
percentile_cont(0.75) within group (order by lcp) as lcp,
percentile_cont(0.75) within group (order by inp) as inp,
percentile_cont(0.75) within group (order by cls) as cls,
percentile_cont(0.75) within group (order by fcp) as fcp,
percentile_cont(0.75) within group (order by ttfb) as ttfb,
count(*) as count
from performance
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
`,
{ websiteId, startDate, endDate },
);
return result?.[0] || { lcp: 0, inp: 0, cls: 0, fcp: 0, ttfb: 0, count: 0 };
}
async function clickhouseQuery(
websiteId: string,
filters: QueryFilters,
): Promise<PerformanceStatsResult> {
const { rawQuery } = clickhouse;
const { startDate, endDate } = filters;
const result = await rawQuery<PerformanceStatsResult>(
`
select
quantile(0.75)(lcp) as lcp,
quantile(0.75)(inp) as inp,
quantile(0.75)(cls) as cls,
quantile(0.75)(fcp) as fcp,
quantile(0.75)(ttfb) as ttfb,
count() as count
from website_performance
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
`,
{ websiteId, startDate, endDate },
);
return result?.[0] || { lcp: 0, inp: 0, cls: 0, fcp: 0, ttfb: 0, count: 0 };
}

View file

@ -0,0 +1,70 @@
import clickhouse from '@/lib/clickhouse';
import { uuid } from '@/lib/crypto';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import kafka from '@/lib/kafka';
import prisma from '@/lib/prisma';
export interface SavePerformanceArgs {
websiteId: string;
sessionId: string;
visitId: string;
urlPath: string;
lcp?: number;
inp?: number;
cls?: number;
fcp?: number;
ttfb?: number;
createdAt: Date;
}
export async function savePerformance(args: SavePerformanceArgs) {
return runQuery({
[PRISMA]: () => relationalQuery(args),
[CLICKHOUSE]: () => clickhouseQuery(args),
});
}
async function relationalQuery(data: SavePerformanceArgs) {
const { websiteId, sessionId, visitId, urlPath, lcp, inp, cls, fcp, ttfb, createdAt } = data;
await prisma.client.performance.create({
data: {
id: uuid(),
websiteId,
sessionId,
visitId,
urlPath: urlPath?.substring(0, 500),
lcp,
inp,
cls,
fcp,
ttfb,
createdAt,
},
});
}
async function clickhouseQuery(data: SavePerformanceArgs) {
const { websiteId, sessionId, visitId, urlPath, lcp, inp, cls, fcp, ttfb, createdAt } = data;
const { insert, getUTCString } = clickhouse;
const { sendMessage } = kafka;
const message = {
website_id: websiteId,
session_id: sessionId,
visit_id: visitId,
url_path: urlPath?.substring(0, 500),
lcp: lcp ?? null,
inp: inp ?? null,
cls: cls ?? null,
fcp: fcp ?? null,
ttfb: ttfb ?? null,
created_at: getUTCString(createdAt),
};
if (kafka.enabled) {
await sendMessage('performance', message);
} else {
await insert('website_performance', [message]);
}
}

View file

@ -0,0 +1,230 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
export interface PerformanceParameters {
startDate: Date;
endDate: Date;
unit: string;
timezone: string;
metric: string;
}
export interface PerformanceResult {
chart: { t: string; p50: number; p75: number; p95: number }[];
pages: { urlPath: string; p75: number; count: number }[];
summary: {
lcp: { p50: number; p75: number; p95: number };
inp: { p50: number; p75: number; p95: number };
cls: { p50: number; p75: number; p95: number };
fcp: { p50: number; p75: number; p95: number };
ttfb: { p50: number; p75: number; p95: number };
count: number;
};
}
export async function getPerformance(
...args: [websiteId: string, parameters: PerformanceParameters, filters: QueryFilters]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
parameters: PerformanceParameters,
filters: QueryFilters,
): Promise<PerformanceResult> {
const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters;
const { getDateSQL, rawQuery } = prisma;
const chart = await rawQuery(
`
select
${getDateSQL('created_at', unit, timezone)} t,
percentile_cont(0.5) within group (order by ${metric}) as p50,
percentile_cont(0.75) within group (order by ${metric}) as p75,
percentile_cont(0.95) within group (order by ${metric}) as p95
from performance
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
group by t
order by t
`,
{ websiteId, startDate, endDate },
);
const pages = await rawQuery(
`
select
url_path as "urlPath",
percentile_cont(0.75) within group (order by ${metric}) as p75,
count(*) as count
from performance
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
group by url_path
order by p75 desc
limit 100
`,
{ websiteId, startDate, endDate },
);
const summaryResult = await rawQuery(
`
select
percentile_cont(0.5) within group (order by lcp) as lcp_p50,
percentile_cont(0.75) within group (order by lcp) as lcp_p75,
percentile_cont(0.95) within group (order by lcp) as lcp_p95,
percentile_cont(0.5) within group (order by inp) as inp_p50,
percentile_cont(0.75) within group (order by inp) as inp_p75,
percentile_cont(0.95) within group (order by inp) as inp_p95,
percentile_cont(0.5) within group (order by cls) as cls_p50,
percentile_cont(0.75) within group (order by cls) as cls_p75,
percentile_cont(0.95) within group (order by cls) as cls_p95,
percentile_cont(0.5) within group (order by fcp) as fcp_p50,
percentile_cont(0.75) within group (order by fcp) as fcp_p75,
percentile_cont(0.95) within group (order by fcp) as fcp_p95,
percentile_cont(0.5) within group (order by ttfb) as ttfb_p50,
percentile_cont(0.75) within group (order by ttfb) as ttfb_p75,
percentile_cont(0.95) within group (order by ttfb) as ttfb_p95,
count(*) as count
from performance
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
`,
{ websiteId, startDate, endDate },
).then(result => result?.[0]);
const summary = {
lcp: {
p50: Number(summaryResult?.lcp_p50 || 0),
p75: Number(summaryResult?.lcp_p75 || 0),
p95: Number(summaryResult?.lcp_p95 || 0),
},
inp: {
p50: Number(summaryResult?.inp_p50 || 0),
p75: Number(summaryResult?.inp_p75 || 0),
p95: Number(summaryResult?.inp_p95 || 0),
},
cls: {
p50: Number(summaryResult?.cls_p50 || 0),
p75: Number(summaryResult?.cls_p75 || 0),
p95: Number(summaryResult?.cls_p95 || 0),
},
fcp: {
p50: Number(summaryResult?.fcp_p50 || 0),
p75: Number(summaryResult?.fcp_p75 || 0),
p95: Number(summaryResult?.fcp_p95 || 0),
},
ttfb: {
p50: Number(summaryResult?.ttfb_p50 || 0),
p75: Number(summaryResult?.ttfb_p75 || 0),
p95: Number(summaryResult?.ttfb_p95 || 0),
},
count: Number(summaryResult?.count || 0),
};
return { chart, pages, summary };
}
async function clickhouseQuery(
websiteId: string,
parameters: PerformanceParameters,
filters: QueryFilters,
): Promise<PerformanceResult> {
const { startDate, endDate, unit = 'day', timezone = 'utc', metric = 'lcp' } = parameters;
const { getDateSQL, rawQuery } = clickhouse;
const chart = await rawQuery<{ t: string; p50: number; p75: number; p95: number }[]>(
`
select
${getDateSQL('created_at', unit, timezone)} t,
quantile(0.5)(${metric}) as p50,
quantile(0.75)(${metric}) as p75,
quantile(0.95)(${metric}) as p95
from website_performance
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by t
order by t
`,
{ websiteId, startDate, endDate },
);
const pages = await rawQuery<{ urlPath: string; p75: number; count: number }[]>(
`
select
url_path as "urlPath",
quantile(0.75)(${metric}) as p75,
count() as count
from website_performance
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by url_path
order by p75 desc
limit 100
`,
{ websiteId, startDate, endDate },
);
const summaryResult = await rawQuery<any>(
`
select
quantile(0.5)(lcp) as lcp_p50,
quantile(0.75)(lcp) as lcp_p75,
quantile(0.95)(lcp) as lcp_p95,
quantile(0.5)(inp) as inp_p50,
quantile(0.75)(inp) as inp_p75,
quantile(0.95)(inp) as inp_p95,
quantile(0.5)(cls) as cls_p50,
quantile(0.75)(cls) as cls_p75,
quantile(0.95)(cls) as cls_p95,
quantile(0.5)(fcp) as fcp_p50,
quantile(0.75)(fcp) as fcp_p75,
quantile(0.95)(fcp) as fcp_p95,
quantile(0.5)(ttfb) as ttfb_p50,
quantile(0.75)(ttfb) as ttfb_p75,
quantile(0.95)(ttfb) as ttfb_p95,
count() as count
from website_performance
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
`,
{ websiteId, startDate, endDate },
).then(result => result?.[0]);
const summary = {
lcp: {
p50: Number(summaryResult?.lcp_p50 || 0),
p75: Number(summaryResult?.lcp_p75 || 0),
p95: Number(summaryResult?.lcp_p95 || 0),
},
inp: {
p50: Number(summaryResult?.inp_p50 || 0),
p75: Number(summaryResult?.inp_p75 || 0),
p95: Number(summaryResult?.inp_p95 || 0),
},
cls: {
p50: Number(summaryResult?.cls_p50 || 0),
p75: Number(summaryResult?.cls_p75 || 0),
p95: Number(summaryResult?.cls_p95 || 0),
},
fcp: {
p50: Number(summaryResult?.fcp_p50 || 0),
p75: Number(summaryResult?.fcp_p75 || 0),
p95: Number(summaryResult?.fcp_p95 || 0),
},
ttfb: {
p50: Number(summaryResult?.ttfb_p50 || 0),
p75: Number(summaryResult?.ttfb_p75 || 0),
p95: Number(summaryResult?.ttfb_p95 || 0),
},
count: Number(summaryResult?.count || 0),
};
return { chart, pages, summary };
}

View file

@ -35,6 +35,7 @@
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 domains = domain.split(',').map(n => n.trim());
const host =
@ -194,6 +195,7 @@
track();
handlePathChanges();
handleClicks();
if (perf) initPerformance();
}
};
@ -219,6 +221,77 @@
);
};
/* Performance */
const initPerformance = () => {
const metrics = {};
let sent = false;
const observe = (type, callback) => {
try {
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(callback);
});
observer.observe({ type, buffered: true });
} catch {
/* not supported */
}
};
// TTFB
observe('navigation', entry => {
metrics.ttfb = Math.max(entry.responseStart - entry.requestStart, 0);
});
// FCP
observe('paint', entry => {
if (entry.name === 'first-contentful-paint') {
metrics.fcp = entry.startTime;
}
});
// LCP
observe('largest-contentful-paint', entry => {
metrics.lcp = entry.startTime;
});
// CLS
let clsValue = 0;
observe('layout-shift', entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
metrics.cls = clsValue;
}
});
// INP
let inpValue = 0;
try {
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (entry.duration > inpValue) {
inpValue = entry.duration;
metrics.inp = inpValue;
}
});
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
} catch {
/* not supported */
}
const sendPerformance = () => {
if (sent || !Object.keys(metrics).length) return;
sent = true;
send({ ...getPayload(), ...metrics }, 'performance');
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sendPerformance();
});
window.addEventListener('pagehide', sendPerformance);
};
/* Start */
if (!window.umami) {