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

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