import type { Metric } from 'web-vitals'; const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; /** * Baseline targets for Core Web Vitals: * LCP < 2.5s (good), < 4.0s (needs improvement) * FCP < 1.8s (good), < 3.0s (needs improvement) * CLS < 0.1 (good), < 0.25 (needs improvement) * TTFB < 800ms (good) * INP < 200ms (good), < 500ms (needs improvement) */ export const WEB_VITALS_THRESHOLDS = { LCP: { good: 2500, poor: 4000 }, FCP: { good: 1800, poor: 3000 }, CLS: { good: 0.1, poor: 0.25 }, TTFB: { good: 800, poor: 1800 }, INP: { good: 200, poor: 500 }, } as const; type VitalName = keyof typeof WEB_VITALS_THRESHOLDS; function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' { const thresholds = WEB_VITALS_THRESHOLDS[name as VitalName]; if (!thresholds) return 'good'; if (value <= thresholds.good) return 'good'; if (value <= thresholds.poor) return 'needs-improvement'; return 'poor'; } interface WebVitalPayload { name: string; value: number; rating: string; delta: number; id: string; navigationType: string; url: string; timestamp: number; } /** Queue metrics and flush in batches to avoid per-metric requests. */ const queue: WebVitalPayload[] = []; let flushTimer: ReturnType | null = null; const FLUSH_INTERVAL_MS = 5000; const MAX_BATCH_SIZE = 10; function getCsrfToken(): string | undefined { if (typeof document === 'undefined') return undefined; const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); return match?.[1] ? decodeURIComponent(match[1]) : undefined; } function flushQueue(): void { if (queue.length === 0) return; const batch = queue.splice(0, MAX_BATCH_SIZE); const body = JSON.stringify({ metrics: batch }); // Use sendBeacon for reliability during page unload; fall back to fetch if (typeof navigator !== 'undefined' && navigator.sendBeacon) { const blob = new Blob([body], { type: 'application/json' }); const sent = navigator.sendBeacon(`${API_BASE_URL}/web-vitals`, blob); if (!sent) { sendViaFetch(body); } } else { sendViaFetch(body); } } function sendViaFetch(body: string): void { const headers: Record = { 'Content-Type': 'application/json' }; const csrfToken = getCsrfToken(); if (csrfToken) headers['X-CSRF-Token'] = csrfToken; fetch(`${API_BASE_URL}/web-vitals`, { method: 'POST', headers, credentials: 'include', body, keepalive: true, }).catch(() => { // Silently drop — metrics are best-effort }); } function enqueue(payload: WebVitalPayload): void { queue.push(payload); if (queue.length >= MAX_BATCH_SIZE) { if (flushTimer) clearTimeout(flushTimer); flushTimer = null; flushQueue(); return; } if (!flushTimer) { flushTimer = setTimeout(() => { flushTimer = null; flushQueue(); }, FLUSH_INTERVAL_MS); } } /** Report a single web-vitals Metric to the backend. */ export function reportWebVital(metric: Metric): void { const payload: WebVitalPayload = { name: metric.name, value: metric.value, rating: metric.rating ?? getRating(metric.name, metric.value), delta: metric.delta, id: metric.id, navigationType: metric.navigationType ?? 'unknown', url: typeof location !== 'undefined' ? location.pathname : '', timestamp: Date.now(), }; enqueue(payload); } /** Flush any remaining metrics (call on page hide / unload). */ export function flushWebVitals(): void { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } flushQueue(); }