From 4c186548bbe3229bd4e18f5277d786b102618c6d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 18 Jan 2026 17:48:47 +0100 Subject: [PATCH] feat: add web vitals tracking script and dashboard UI --- public/script.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/public/script.js b/public/script.js index 4d768e9..ba28a42 100644 --- a/public/script.js +++ b/public/script.js @@ -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 });