Files
freno-dev/src/lib/performance-tracking.ts

164 lines
4.5 KiB
TypeScript

/**
* Real User Monitoring (RUM) - Client-side performance tracking
* Captures Core Web Vitals and sends to analytics endpoint
*/
interface PerformanceMetrics {
fcp?: number;
lcp?: number;
cls?: number;
fid?: number;
inp?: number;
ttfb?: number;
domLoad?: number;
loadComplete?: number;
}
let metrics: PerformanceMetrics = {};
let clsValue = 0;
let clsEntries: number[] = [];
let inpValue = 0;
export function initPerformanceTracking() {
if (typeof window === "undefined" || !("PerformanceObserver" in window)) {
return;
}
// Observe LCP
try {
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as any;
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
} catch (e) {
console.debug("LCP not supported");
}
// Observe CLS
try {
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const layoutShift = entry as any;
if (!layoutShift.hadRecentInput) {
clsValue += layoutShift.value;
clsEntries.push(layoutShift.value);
}
}
metrics.cls = clsValue;
});
clsObserver.observe({ type: "layout-shift", buffered: true });
} catch (e) {
console.debug("CLS not supported");
}
// Observe FID
try {
const fidObserver = new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0] as any;
if (firstInput) {
metrics.fid = firstInput.processingStart - firstInput.startTime;
}
});
fidObserver.observe({ type: "first-input", buffered: true });
} catch (e) {
console.debug("FID not supported");
}
// Observe INP (event timing)
try {
const interactions: number[] = [];
const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const eventEntry = entry as any;
if (eventEntry.interactionId) {
interactions.push(eventEntry.duration);
const sorted = [...interactions].sort((a, b) => b - a);
const p98Index = Math.floor(sorted.length * 0.02);
inpValue = sorted[p98Index] || sorted[0] || 0;
metrics.inp = inpValue;
}
}
});
inpObserver.observe({ type: "event", buffered: true });
} catch (e) {
console.debug("INP not supported");
}
// Get navigation timing metrics
window.addEventListener("load", () => {
setTimeout(() => {
const navTiming = performance.getEntriesByType(
"navigation"
)[0] as PerformanceNavigationTiming;
if (navTiming) {
metrics.ttfb = navTiming.responseStart - navTiming.requestStart;
metrics.domLoad =
navTiming.domContentLoadedEventEnd - navTiming.fetchStart;
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
}
// Get FCP
const paintEntries = performance.getEntriesByType("paint");
const fcpEntry = paintEntries.find(
(entry) => entry.name === "first-contentful-paint"
);
if (fcpEntry) {
metrics.fcp = fcpEntry.startTime;
}
// Send metrics after a short delay to ensure all metrics are captured
setTimeout(() => {
sendMetrics();
}, 2000);
}, 0);
});
// Send metrics before page unload (in case user navigates away)
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
sendMetrics();
}
});
}
function sendMetrics() {
// Only send if we have at least one metric
if (Object.keys(metrics).length === 0) {
return;
}
const path = window.location.pathname + window.location.search;
// tRPC batch format for public procedure
const tRPCPayload = {
0: {
path: path,
metrics: { ...metrics }
}
};
const apiUrl = "/api/trpc/analytics.logPerformance?batch=1";
const payload = JSON.stringify(tRPCPayload);
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(apiUrl, blob);
} else {
// Fallback to fetch with keepalive
fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true
}).catch((err) =>
console.debug("Failed to send performance metrics:", err)
);
}
// Clear metrics after sending
metrics = {};
}