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

+73
View File
@@ -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) {