mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 03:55:37 +01:00
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:
parent
8cd3c03702
commit
ce9e2416fb
24 changed files with 1028 additions and 8 deletions
72
db/clickhouse/migrations/09_add_performance.sql
Normal file
72
db/clickhouse/migrations/09_add_performance.sql
Normal 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);
|
||||
28
prisma/migrations/18_add_performance/migration.sql
Normal file
28
prisma/migrations/18_add_performance/migration.sql
Normal 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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.sampleCount {
|
||||
color: var(--base-color-text-secondary);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
26
src/app/api/reports/performance/route.ts
Normal file
26
src/app/api/reports/performance/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
33
src/app/api/websites/[websiteId]/performance/route.ts
Normal file
33
src/app/api/websites/[websiteId]/performance/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
19
src/components/metrics/PerformanceCard.module.css
Normal file
19
src/components/metrics/PerformanceCard.module.css
Normal 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;
|
||||
}
|
||||
62
src/components/metrics/PerformanceCard.tsx
Normal file
62
src/components/metrics/PerformanceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/components/svg/Gauge.tsx
Normal file
15
src/components/svg/Gauge.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
72
src/queries/sql/performance/getPerformanceStats.ts
Normal file
72
src/queries/sql/performance/getPerformanceStats.ts
Normal 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 };
|
||||
}
|
||||
70
src/queries/sql/performance/savePerformance.ts
Normal file
70
src/queries/sql/performance/savePerformance.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
230
src/queries/sql/reports/getPerformance.ts
Normal file
230
src/queries/sql/reports/getPerformance.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue