feat: add web vitals tracking script and dashboard UI

This commit is contained in:
Usman Baig
2026-01-18 17:48:47 +01:00
parent c021e61608
commit 4c186548bb

View File

@@ -20,6 +20,80 @@
const domain = script.getAttribute('data-domain');
const apiUrl = script.getAttribute('data-api') || 'https://analytics-api.ciphera.net';
// * Performance Monitoring (Core Web Vitals) State
let currentEventId = null;
let metrics = { lcp: 0, cls: 0, inp: 0 };
// * Minimal Web Vitals Observer
function observeMetrics() {
try {
if (typeof PerformanceObserver === 'undefined') return;
// * LCP (Largest Contentful Paint)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
metrics.lcp = lastEntry.startTime;
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// * CLS (Cumulative Layout Shift)
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
metrics.cls += entry.value;
}
}
}).observe({ type: 'layout-shift', buffered: true });
// * INP (Interaction to Next Paint) - Simplified (track max duration)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
for (const entry of entries) {
// * Track longest interaction
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
} catch (e) {
// * Browser doesn't support PerformanceObserver or specific entry types
}
}
function sendMetrics() {
if (!currentEventId || (metrics.lcp === 0 && metrics.cls === 0 && metrics.inp === 0)) return;
// * Use sendBeacon if available for reliability on unload
const data = JSON.stringify({
event_id: currentEventId,
lcp: metrics.lcp,
cls: metrics.cls,
inp: metrics.inp
});
if (navigator.sendBeacon) {
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
} else {
fetch(apiUrl + '/api/v1/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: data,
keepalive: true
}).catch(() => {});
}
}
// * Start observing immediately
observeMetrics();
// * Send metrics when user leaves or hides the page
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendMetrics();
}
});
// * Memory cache for session ID (fallback if sessionStorage is unavailable)
let cachedSessionId = null;
@@ -63,6 +137,22 @@
// * Track pageview
function trackPageview() {
// * Reset metrics for new pageview (SPA navigation)
// * We don't reset immediately on the first run, but for subsequent calls we should
// * However, for the very first call, metrics are already 0.
// * The issue is if we reset metrics here, we might lose early captured metrics (e.g. LCP) if this runs late?
// * No, trackPageview runs early.
// * BUT for SPA navigation, we want to reset.
if (currentEventId) {
// If we already had an event ID, it means this is a subsequent navigation
// We should try to send metrics for the *previous* page before resetting?
// Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange.
sendMetrics();
}
metrics = { lcp: 0, cls: 0, inp: 0 };
currentEventId = null;
const path = window.location.pathname + window.location.search;
const referrer = document.referrer || '';
const screen = {
@@ -86,6 +176,11 @@
},
body: JSON.stringify(payload),
keepalive: true,
}).then(res => res.json())
.then(data => {
if (data && data.id) {
currentEventId = data.id;
}
}).catch(() => {
// * Silently fail - don't interrupt user experience
});