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[]
|
websiteEvents WebsiteEvent[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
performance Performance[]
|
||||||
revenue Revenue[]
|
revenue Revenue[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
|
@ -79,6 +80,7 @@ model Website {
|
||||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
|
performance Performance[]
|
||||||
reports Report[]
|
reports Report[]
|
||||||
revenue Revenue[]
|
revenue Revenue[]
|
||||||
segments Segment[]
|
segments Segment[]
|
||||||
|
|
@ -338,6 +340,29 @@ model Board {
|
||||||
@@map("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 {
|
model Share {
|
||||||
id String @id() @map("share_id") @db.Uuid
|
id String @id() @map("share_id") @db.Uuid
|
||||||
entityId String @map("entity_id") @db.Uuid
|
entityId String @map("entity_id") @db.Uuid
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"cities": "Cities",
|
"cities": "Cities",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"clear-all": "Clear all",
|
"clear-all": "Clear all",
|
||||||
|
"cls": "CLS",
|
||||||
"cohort": "Cohort",
|
"cohort": "Cohort",
|
||||||
"cohorts": "Cohorts",
|
"cohorts": "Cohorts",
|
||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
|
|
@ -113,6 +114,7 @@
|
||||||
"exists": "Exists",
|
"exists": "Exists",
|
||||||
"exit": "Exit page",
|
"exit": "Exit page",
|
||||||
"false": "False",
|
"false": "False",
|
||||||
|
"fcp": "FCP",
|
||||||
"field": "Field",
|
"field": "Field",
|
||||||
"fields": "Fields",
|
"fields": "Fields",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
|
|
@ -126,6 +128,7 @@
|
||||||
"funnels": "Funnels",
|
"funnels": "Funnels",
|
||||||
"goal": "Goal",
|
"goal": "Goal",
|
||||||
"goals": "Goals",
|
"goals": "Goals",
|
||||||
|
"good": "Good",
|
||||||
"goals-description": "Track your goals for pageviews and events.",
|
"goals-description": "Track your goals for pageviews and events.",
|
||||||
"greater-than": "Greater than",
|
"greater-than": "Greater than",
|
||||||
"greater-than-equals": "Greater than or equals",
|
"greater-than-equals": "Greater than or equals",
|
||||||
|
|
@ -137,6 +140,7 @@
|
||||||
"insight": "Insight",
|
"insight": "Insight",
|
||||||
"insights": "Insights",
|
"insights": "Insights",
|
||||||
"insights-description": "Dive deeper into your data by using segments and filters.",
|
"insights-description": "Dive deeper into your data by using segments and filters.",
|
||||||
|
"inp": "INP",
|
||||||
"invalid-url": "Invalid URL",
|
"invalid-url": "Invalid URL",
|
||||||
"is": "Is",
|
"is": "Is",
|
||||||
"is-false": "Is false",
|
"is-false": "Is false",
|
||||||
|
|
@ -157,6 +161,7 @@
|
||||||
"last-hours": "Last {x} hours",
|
"last-hours": "Last {x} hours",
|
||||||
"last-months": "Last {x} months",
|
"last-months": "Last {x} months",
|
||||||
"last-seen": "Last seen",
|
"last-seen": "Last seen",
|
||||||
|
"lcp": "LCP",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"leave-team": "Leave team",
|
"leave-team": "Leave team",
|
||||||
"less-than": "Less than",
|
"less-than": "Less than",
|
||||||
|
|
@ -182,6 +187,7 @@
|
||||||
"my-account": "My account",
|
"my-account": "My account",
|
||||||
"my-websites": "My websites",
|
"my-websites": "My websites",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"needs-improvement": "Needs improvement",
|
||||||
"new-password": "New password",
|
"new-password": "New password",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"number-of-records": "{x} {x, plural, one {record} other {records}}",
|
"number-of-records": "{x} {x, plural, one {record} other {records}}",
|
||||||
|
|
@ -208,8 +214,10 @@
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"paths": "Paths",
|
"paths": "Paths",
|
||||||
|
"performance": "Performance",
|
||||||
"pixel": "Pixel",
|
"pixel": "Pixel",
|
||||||
"pixels": "Pixels",
|
"pixels": "Pixels",
|
||||||
|
"poor": "Poor",
|
||||||
"powered-by": "Powered by {name}",
|
"powered-by": "Powered by {name}",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
|
|
@ -246,6 +254,7 @@
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"save-cohort": "Save cohort",
|
"save-cohort": "Save cohort",
|
||||||
"save-segment": "Save segment",
|
"save-segment": "Save segment",
|
||||||
|
"sample-size": "Sample size",
|
||||||
"screen": "Screen",
|
"screen": "Screen",
|
||||||
"screens": "Screens",
|
"screens": "Screens",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
|
@ -303,6 +312,7 @@
|
||||||
"transfer": "Transfer",
|
"transfer": "Transfer",
|
||||||
"transfer-website": "Transfer website",
|
"transfer-website": "Transfer website",
|
||||||
"true": "True",
|
"true": "True",
|
||||||
|
"ttfb": "TTFB",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"unique": "Unique",
|
"unique": "Unique",
|
||||||
"unique-events": "Unique events",
|
"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 { badRequest, forbidden, json, serverError } from '@/lib/response';
|
||||||
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
||||||
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
||||||
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
import { createSession, saveEvent, savePerformance, saveSessionData } from '@/queries/sql';
|
||||||
|
|
||||||
interface Cache {
|
interface Cache {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
|
|
@ -22,7 +22,7 @@ interface Cache {
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['event', 'identify']),
|
type: z.enum(['event', 'identify', 'performance']),
|
||||||
payload: z
|
payload: z
|
||||||
.object({
|
.object({
|
||||||
website: z.uuid().optional(),
|
website: z.uuid().optional(),
|
||||||
|
|
@ -44,6 +44,11 @@ const schema = z.object({
|
||||||
browser: z.string().optional(),
|
browser: z.string().optional(),
|
||||||
os: z.string().optional(),
|
os: z.string().optional(),
|
||||||
device: 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(
|
.refine(
|
||||||
data => {
|
data => {
|
||||||
|
|
@ -83,6 +88,11 @@ export async function POST(request: Request) {
|
||||||
tag,
|
tag,
|
||||||
timestamp,
|
timestamp,
|
||||||
id,
|
id,
|
||||||
|
lcp,
|
||||||
|
inp,
|
||||||
|
cls,
|
||||||
|
fcp,
|
||||||
|
ttfb,
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
const sourceId = websiteId || pixelId || linkId;
|
const sourceId = websiteId || pixelId || linkId;
|
||||||
|
|
@ -269,6 +279,23 @@ export async function POST(request: Request) {
|
||||||
createdAt,
|
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());
|
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,
|
User,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from '@/components/icons';
|
} 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 { useMessages } from './useMessages';
|
||||||
import { useNavigation } from './useNavigation';
|
import { useNavigation } from './useNavigation';
|
||||||
|
|
||||||
|
|
@ -53,6 +53,12 @@ export function useWebsiteNavItems(websiteId: string) {
|
||||||
icon: <Clock />,
|
icon: <Clock />,
|
||||||
path: renderPath('/realtime'),
|
path: renderPath('/realtime'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'performance',
|
||||||
|
label: t(labels.performance),
|
||||||
|
icon: <Gauge />,
|
||||||
|
path: renderPath('/performance'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'compare',
|
id: 'compare',
|
||||||
label: t(labels.compare),
|
label: t(labels.compare),
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,16 @@ export const labels: Record<string, string> = {
|
||||||
support: 'label.support',
|
support: 'label.support',
|
||||||
documentation: 'label.documentation',
|
documentation: 'label.documentation',
|
||||||
switchAccount: 'label.switch-account',
|
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> = {
|
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 Export } from './Export';
|
||||||
export { default as Flag } from './Flag';
|
export { default as Flag } from './Flag';
|
||||||
export { default as Funnel } from './Funnel';
|
export { default as Funnel } from './Funnel';
|
||||||
|
export { default as Gauge } from './Gauge';
|
||||||
export { default as Gear } from './Gear';
|
export { default as Gear } from './Gear';
|
||||||
export { default as Lightbulb } from './Lightbulb';
|
export { default as Lightbulb } from './Lightbulb';
|
||||||
export { default as Lightning } from './Lightning';
|
export { default as Lightning } from './Lightning';
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,15 @@ export const FILTER_COLUMNS = {
|
||||||
export const COLLECTION_TYPE = {
|
export const COLLECTION_TYPE = {
|
||||||
event: 'event',
|
event: 'event',
|
||||||
identify: 'identify',
|
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;
|
} as const;
|
||||||
|
|
||||||
export const EVENT_TYPE = {
|
export const EVENT_TYPE = {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export const reportTypeParam = z.enum([
|
||||||
'funnel',
|
'funnel',
|
||||||
'goal',
|
'goal',
|
||||||
'journey',
|
'journey',
|
||||||
|
'performance',
|
||||||
'retention',
|
'retention',
|
||||||
'revenue',
|
'revenue',
|
||||||
'utm',
|
'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({
|
export const revenueReportSchema = z.object({
|
||||||
type: z.literal('revenue'),
|
type: z.literal('revenue'),
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
|
|
@ -244,6 +256,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
goalReportSchema,
|
goalReportSchema,
|
||||||
funnelReportSchema,
|
funnelReportSchema,
|
||||||
journeyReportSchema,
|
journeyReportSchema,
|
||||||
|
performanceReportSchema,
|
||||||
retentionReportSchema,
|
retentionReportSchema,
|
||||||
utmReportSchema,
|
utmReportSchema,
|
||||||
revenueReportSchema,
|
revenueReportSchema,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export * from './getWeeklyTraffic';
|
||||||
export * from './pageviews/getPageviewExpandedMetrics';
|
export * from './pageviews/getPageviewExpandedMetrics';
|
||||||
export * from './pageviews/getPageviewMetrics';
|
export * from './pageviews/getPageviewMetrics';
|
||||||
export * from './pageviews/getPageviewStats';
|
export * from './pageviews/getPageviewStats';
|
||||||
|
export * from './performance/getPerformanceStats';
|
||||||
|
export * from './performance/savePerformance';
|
||||||
export * from './reports/getBreakdown';
|
export * from './reports/getBreakdown';
|
||||||
export * from './reports/getFunnel';
|
export * from './reports/getFunnel';
|
||||||
export * from './reports/getJourney';
|
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 excludeHash = attr(`${_data}exclude-hash`) === _true;
|
||||||
const domain = attr(`${_data}domains`) || '';
|
const domain = attr(`${_data}domains`) || '';
|
||||||
const credentials = attr(`${_data}fetch-credentials`) || 'omit';
|
const credentials = attr(`${_data}fetch-credentials`) || 'omit';
|
||||||
|
const perf = attr(`${_data}perf`) === _true;
|
||||||
|
|
||||||
const domains = domain.split(',').map(n => n.trim());
|
const domains = domain.split(',').map(n => n.trim());
|
||||||
const host =
|
const host =
|
||||||
|
|
@ -194,6 +195,7 @@
|
||||||
track();
|
track();
|
||||||
handlePathChanges();
|
handlePathChanges();
|
||||||
handleClicks();
|
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 */
|
/* Start */
|
||||||
|
|
||||||
if (!window.umami) {
|
if (!window.umami) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue