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

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

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

No files matched your search

@@ -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 };
}
@@ -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]);
}
}