From ce9e2416fbf8080cdfdff36ca92d48b559c9460d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 8 Feb 2026 19:25:20 -0800 Subject: [PATCH] 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 --- .../migrations/09_add_performance.sql | 72 ++++++ .../18_add_performance/migration.sql | 28 +++ prisma/schema.prisma | 35 ++- public/intl/messages/en-US.json | 10 + .../performance/Performance.module.css | 3 + .../(reports)/performance/Performance.tsx | 184 ++++++++++++++ .../(reports)/performance/PerformancePage.tsx | 18 ++ .../(reports)/performance/page.tsx | 12 + src/app/api/reports/performance/route.ts | 26 ++ src/app/api/send/route.ts | 31 ++- .../websites/[websiteId]/performance/route.ts | 33 +++ src/components/hooks/useWebsiteNavItems.tsx | 8 +- src/components/messages.ts | 10 + .../metrics/PerformanceCard.module.css | 19 ++ src/components/metrics/PerformanceCard.tsx | 62 +++++ src/components/svg/Gauge.tsx | 15 ++ src/components/svg/index.ts | 1 + src/lib/constants.ts | 9 + src/lib/schema.ts | 13 + src/queries/sql/index.ts | 2 + .../sql/performance/getPerformanceStats.ts | 72 ++++++ .../sql/performance/savePerformance.ts | 70 ++++++ src/queries/sql/reports/getPerformance.ts | 230 ++++++++++++++++++ src/tracker/index.js | 73 ++++++ 24 files changed, 1028 insertions(+), 8 deletions(-) create mode 100644 db/clickhouse/migrations/09_add_performance.sql create mode 100644 prisma/migrations/18_add_performance/migration.sql create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.module.css create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/performance/PerformancePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/performance/page.tsx create mode 100644 src/app/api/reports/performance/route.ts create mode 100644 src/app/api/websites/[websiteId]/performance/route.ts create mode 100644 src/components/metrics/PerformanceCard.module.css create mode 100644 src/components/metrics/PerformanceCard.tsx create mode 100644 src/components/svg/Gauge.tsx create mode 100644 src/queries/sql/performance/getPerformanceStats.ts create mode 100644 src/queries/sql/performance/savePerformance.ts create mode 100644 src/queries/sql/reports/getPerformance.ts diff --git a/db/clickhouse/migrations/09_add_performance.sql b/db/clickhouse/migrations/09_add_performance.sql new file mode 100644 index 000000000..762db074e --- /dev/null +++ b/db/clickhouse/migrations/09_add_performance.sql @@ -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); diff --git a/prisma/migrations/18_add_performance/migration.sql b/prisma/migrations/18_add_performance/migration.sql new file mode 100644 index 000000000..a71f8721d --- /dev/null +++ b/prisma/migrations/18_add_performance/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8750f8d12..582acd074 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model Session { websiteEvents WebsiteEvent[] sessionData SessionData[] + performance Performance[] revenue Revenue[] @@index([createdAt]) @@ -78,11 +79,12 @@ model Website { user User? @relation("user", fields: [userId], references: [id]) createUser User? @relation("createUser", fields: [createdBy], references: [id]) team Team? @relation(fields: [teamId], references: [id]) - eventData EventData[] - reports Report[] - revenue Revenue[] - segments Segment[] - sessionData SessionData[] + eventData EventData[] + performance Performance[] + reports Report[] + revenue Revenue[] + segments Segment[] + sessionData SessionData[] @@index([userId]) @@index([teamId]) @@ -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 diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 5fe797d85..7e9932656 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -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", diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.module.css b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.module.css new file mode 100644 index 000000000..ddb5dc10a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.module.css @@ -0,0 +1,3 @@ +.sampleCount { + color: var(--base-color-text-secondary); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx new file mode 100644 index 000000000..90821f377 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx @@ -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 = { + 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('lcp'); + const { t, labels } = useMessages(); + const { locale, dateLocale } = useLocale(); + + const { data, error, isLoading } = useResultQuery('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 ( + + + {data && ( + + + {METRICS.map(metric => ( + formatMetricValue(metric, n)} + onClick={() => setSelectedMetric(metric)} + selected={selectedMetric === metric} + /> + ))} + + + + + {METRIC_LABELS[selectedMetric]} + + + {t(labels.sampleSize)}: {formatLongNumber(data.summary?.count || 0)} + + + + { + const val = Number(label); + if (selectedMetric === 'cls') return val.toFixed(2); + return `${Math.round(val)} ms`; + }} + height="400px" + /> + + + + ({ + label: urlPath, + count: Number(p75), + percent: 0, + }), + )} + renderLabel={({ label }: { label: string }) => {label}} + /> + + + )} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/PerformancePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/performance/PerformancePage.tsx new file mode 100644 index 000000000..3cd8d0d0a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/PerformancePage.tsx @@ -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 ( + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/performance/page.tsx new file mode 100644 index 000000000..dad23a2c5 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/page.tsx @@ -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 ; +} + +export const metadata: Metadata = { + title: 'Performance', +}; diff --git a/src/app/api/reports/performance/route.ts b/src/app/api/reports/performance/route.ts new file mode 100644 index 000000000..e6a085574 --- /dev/null +++ b/src/app/api/reports/performance/route.ts @@ -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); +} diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index c3aa9a00e..1eadafee1 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -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()); diff --git a/src/app/api/websites/[websiteId]/performance/route.ts b/src/app/api/websites/[websiteId]/performance/route.ts new file mode 100644 index 000000000..ea18f75e8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/performance/route.ts @@ -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); +} diff --git a/src/components/hooks/useWebsiteNavItems.tsx b/src/components/hooks/useWebsiteNavItems.tsx index 918a423d6..76bd400f7 100644 --- a/src/components/hooks/useWebsiteNavItems.tsx +++ b/src/components/hooks/useWebsiteNavItems.tsx @@ -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: , path: renderPath('/realtime'), }, + { + id: 'performance', + label: t(labels.performance), + icon: , + path: renderPath('/performance'), + }, { id: 'compare', label: t(labels.compare), diff --git a/src/components/messages.ts b/src/components/messages.ts index e93cb8f81..60e10ea17 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -340,6 +340,16 @@ export const labels: Record = { 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 = { diff --git a/src/components/metrics/PerformanceCard.module.css b/src/components/metrics/PerformanceCard.module.css new file mode 100644 index 000000000..4c7390216 --- /dev/null +++ b/src/components/metrics/PerformanceCard.module.css @@ -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; +} diff --git a/src/components/metrics/PerformanceCard.tsx b/src/components/metrics/PerformanceCard.tsx new file mode 100644 index 000000000..1c2a1eb65 --- /dev/null +++ b/src/components/metrics/PerformanceCard.tsx @@ -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 ( + + + {label} + + + {props?.x?.to(x => formatValue(x))} + + + {t(labels[rating === 'needs-improvement' ? 'needsImprovement' : rating])} + + + ); +}; diff --git a/src/components/svg/Gauge.tsx b/src/components/svg/Gauge.tsx new file mode 100644 index 000000000..38933993c --- /dev/null +++ b/src/components/svg/Gauge.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgGauge = (props: SVGProps) => ( + + + + +); +export default SvgGauge; diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts index 76756af44..15721e52e 100644 --- a/src/components/svg/index.ts +++ b/src/components/svg/index.ts @@ -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'; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fb492e03b..0d6fd72bd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 66d487e43..63dd1aab4 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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, diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index 1573bdefd..202262aae 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -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'; diff --git a/src/queries/sql/performance/getPerformanceStats.ts b/src/queries/sql/performance/getPerformanceStats.ts new file mode 100644 index 000000000..f727e2111 --- /dev/null +++ b/src/queries/sql/performance/getPerformanceStats.ts @@ -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 { + 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 { + const { rawQuery } = clickhouse; + const { startDate, endDate } = filters; + + const result = await rawQuery( + ` + 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 }; +} diff --git a/src/queries/sql/performance/savePerformance.ts b/src/queries/sql/performance/savePerformance.ts new file mode 100644 index 000000000..b50856185 --- /dev/null +++ b/src/queries/sql/performance/savePerformance.ts @@ -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]); + } +} diff --git a/src/queries/sql/reports/getPerformance.ts b/src/queries/sql/reports/getPerformance.ts new file mode 100644 index 000000000..b73bf100a --- /dev/null +++ b/src/queries/sql/reports/getPerformance.ts @@ -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 { + 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 { + 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( + ` + 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 }; +} diff --git a/src/tracker/index.js b/src/tracker/index.js index 85d274301..8393ff71f 100644 --- a/src/tracker/index.js +++ b/src/tracker/index.js @@ -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) {