/** * Web Vitals monitoring for frontend performance. * * Captures Core Web Vitals (LCP, FID, CLS) and sends to analytics. * These metrics are critical for user experience and SEO. * * @see Story 1.8 - T8.4: Web Vitals (FCP, LCP, TTI) */ import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals'; type VitalsReporter = (metric: VitalMetric) => void; export interface VitalMetric { name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'; value: number; rating: 'good' | 'needs-improvement' | 'poor'; delta: number; id: string; } /** * Web Vitals thresholds (Core Web Vitals 2024 standards). * * - LCP (Largest Contentful Paint): < 2.5s good, < 4s needs improvement * - FCP (First Contentful Paint): < 1.8s good, < 3s needs improvement * - INP (Interaction to Next Paint): < 200ms good, < 500ms needs improvement * - CLS (Cumulative Layout Shift): < 0.1 good, < 0.25 needs improvement * - TTFB (Time to First Byte): < 800ms good, < 1.8s needs improvement */ const THRESHOLDS = { LCP: { good: 2500, needsImprovement: 4000 }, FCP: { good: 1800, needsImprovement: 3000 }, INP: { good: 200, needsImprovement: 500 }, CLS: { good: 0.1, needsImprovement: 0.25 }, TTFB: { good: 800, needsImprovement: 1800 } } as const; function getRating( name: VitalMetric['name'], value: number ): 'good' | 'needs-improvement' | 'poor' { const threshold = THRESHOLDS[name]; if (value <= threshold.good) return 'good'; if (value <= threshold.needsImprovement) return 'needs-improvement'; return 'poor'; } function createVitalMetric(metric: Metric): VitalMetric { return { name: metric.name as VitalMetric['name'], value: metric.value, rating: getRating(metric.name as VitalMetric['name'], metric.value), delta: metric.delta, id: metric.id }; } /** * Initialize Web Vitals collection. * * @param reporter - Callback function to report metrics (e.g., to analytics) */ export function initWebVitals(reporter: VitalsReporter): void { // Core Web Vitals onLCP((metric) => reporter(createVitalMetric(metric))); onCLS((metric) => reporter(createVitalMetric(metric))); onINP((metric) => reporter(createVitalMetric(metric))); // Other Web Vitals onFCP((metric) => reporter(createVitalMetric(metric))); onTTFB((metric) => reporter(createVitalMetric(metric))); } /** * Default reporter that logs to console (dev) or sends to backend (prod). */ export function createDefaultReporter(options: { endpoint?: string; debug?: boolean; }): VitalsReporter { return (metric: VitalMetric) => { // Log in development if (options.debug) { // eslint-disable-next-line no-console console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`); } // Send to analytics endpoint in production if (options.endpoint) { // Use sendBeacon for reliability during page unload const body = JSON.stringify({ metric: metric.name, value: metric.value, rating: metric.rating, delta: metric.delta, id: metric.id, timestamp: Date.now(), url: window.location.href }); if (navigator.sendBeacon) { navigator.sendBeacon(options.endpoint, body); } else { fetch(options.endpoint, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => { // Silently fail - vitals are best-effort }); } } }; }