fledged out analytics, self gather, remove vercel speed insights
This commit is contained in:
163
src/lib/performance-tracking.ts
Normal file
163
src/lib/performance-tracking.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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 = {};
|
||||
}
|
||||
Reference in New Issue
Block a user