/** * Performance metrics collector — captures Core Web Vitals and navigation timing * for pages visited during crawl runs. * Collects LCP, CLS, INP, TTFB, long tasks, and slow resources via PerformanceObserver * and the Navigation/Resource Timing APIs. */ import type { Page } from 'playwright'; export type CwvScore = 'good' | 'needs-improvement' | 'poor'; export interface LcpMetric { value: number; score: CwvScore; elementTag: string | null; url: string | null; } export interface ClsMetric { value: number; score: CwvScore; shiftCount: number; } export interface InpMetric { p75: number; score: CwvScore; sampleCount: number; } export interface NavTimingMetric { ttfb: number; ttfbScore: CwvScore; domContentLoaded: number; domComplete: number; loadComplete: number; transferSizeBytes: number; protocol: string; } export interface SlowResource { path: string; durationMs: number; transferSizeBytes: number; initiatorType: string; } export interface PerformanceMetricsResult { lcp: LcpMetric | null; cls: ClsMetric | null; inp: InpMetric | null; navTiming: NavTimingMetric | null; slowResources: SlowResource[]; longTaskCount: number; captureSuccess: boolean; } // --------------------------------------------------------------------------- // Observer injection — call before page navigation via addInitScript // --------------------------------------------------------------------------- export async function injectPerformanceObservers(page: Page): Promise { await page.addInitScript(() => { (window as any).__zetaPerf = { lcp: null, cls: 0, clsCount: 0, inp: [], longTaskCount: 0 }; try { new PerformanceObserver(list => { const entries = list.getEntries(); const last = entries[entries.length - 1] as any; if (last) { (window as any).__zetaPerf.lcp = { value: last.startTime, elementTag: last.element?.tagName || null, url: last.url || null, }; } }).observe({ entryTypes: ['largest-contentful-paint'] }); } catch {} try { new PerformanceObserver(list => { for (const entry of list.getEntries() as any[]) { if (!entry.hadRecentInput) { (window as any).__zetaPerf.cls += entry.value; (window as any).__zetaPerf.clsCount++; } } }).observe({ entryTypes: ['layout-shift'] }); } catch {} try { new PerformanceObserver(list => { for (const entry of list.getEntries() as any[]) { (window as any).__zetaPerf.inp.push(entry.duration); } }).observe({ entryTypes: ['event'], durationThreshold: 40 } as any); } catch {} try { new PerformanceObserver(list => { (window as any).__zetaPerf.longTaskCount += list.getEntries().length; }).observe({ entryTypes: ['longtask'] }); } catch {} }); } // --------------------------------------------------------------------------- // Scoring helpers // --------------------------------------------------------------------------- function scoreLcp(value: number): CwvScore { if (value <= 2500) return 'good'; if (value <= 4000) return 'needs-improvement'; return 'poor'; } function scoreCls(value: number): CwvScore { if (value <= 0.1) return 'good'; if (value <= 0.25) return 'needs-improvement'; return 'poor'; } function scoreInp(p75: number): CwvScore { if (p75 <= 200) return 'good'; if (p75 <= 500) return 'needs-improvement'; return 'poor'; } function scoreTtfb(value: number): CwvScore { if (value <= 800) return 'good'; if (value <= 1800) return 'needs-improvement'; return 'poor'; } // --------------------------------------------------------------------------- // Internal collector // --------------------------------------------------------------------------- async function collectMetrics(page: Page): Promise { const rawMetrics = await page.evaluate(() => { const p = (window as any).__zetaPerf || {}; const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined; const resources = performance.getEntriesByType('resource') .filter(r => r.duration > 500) .sort((a, b) => b.duration - a.duration) .slice(0, 10) .map(r => ({ path: (() => { try { return new URL(r.name).pathname; } catch { return r.name.slice(0, 80); } })(), durationMs: Math.round(r.duration), transferSizeBytes: (r as any).transferSize || 0, initiatorType: (r as any).initiatorType || '', })); const inpValues = p.inp || []; const p75 = inpValues.length > 0 ? inpValues.sort((a: number, b: number) => a - b)[Math.floor(inpValues.length * 0.75)] : 0; return { lcp: p.lcp || null, cls: p.cls ?? null, clsCount: p.clsCount ?? 0, inpP75: p75, inpCount: inpValues.length, longTaskCount: p.longTaskCount || 0, nav: nav ? { ttfb: Math.round(nav.responseStart - nav.requestStart), domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime), domComplete: Math.round(nav.domComplete - nav.startTime), loadComplete: Math.round(nav.loadEventEnd - nav.startTime), transferSizeBytes: (nav as any).transferSize || 0, protocol: (nav as any).nextHopProtocol || '', } : null, resources, }; }); const lcp: LcpMetric | null = rawMetrics.lcp ? { value: rawMetrics.lcp.value, score: scoreLcp(rawMetrics.lcp.value), elementTag: rawMetrics.lcp.elementTag, url: rawMetrics.lcp.url, } : null; const cls: ClsMetric | null = rawMetrics.cls !== null ? { value: rawMetrics.cls, score: scoreCls(rawMetrics.cls), shiftCount: rawMetrics.clsCount, } : null; const inp: InpMetric | null = rawMetrics.inpCount > 0 ? { p75: rawMetrics.inpP75, score: scoreInp(rawMetrics.inpP75), sampleCount: rawMetrics.inpCount, } : null; const navTiming: NavTimingMetric | null = rawMetrics.nav ? { ttfb: rawMetrics.nav.ttfb, ttfbScore: scoreTtfb(rawMetrics.nav.ttfb), domContentLoaded: rawMetrics.nav.domContentLoaded, domComplete: rawMetrics.nav.domComplete, loadComplete: rawMetrics.nav.loadComplete, transferSizeBytes: rawMetrics.nav.transferSizeBytes, protocol: rawMetrics.nav.protocol, } : null; return { lcp, cls, inp, navTiming, slowResources: rawMetrics.resources, longTaskCount: rawMetrics.longTaskCount, captureSuccess: true, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- const emptyResult: Omit = { lcp: null, cls: null, inp: null, navTiming: null, slowResources: [], longTaskCount: 0, }; export async function collectPagePerformance(page: Page): Promise { try { return await collectMetrics(page); } catch { return { ...emptyResult, captureSuccess: false }; } }