diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts index 7b152d853..9f6afbce2 100644 --- a/src/trace-processing/parse.ts +++ b/src/trace-processing/parse.ts @@ -76,10 +76,144 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription} ${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`; +type Rating = 'good' | 'needs-improvement' | 'poor'; + +/** + * Rate a timing-based Web Vitals metric value (in ms) against its thresholds. + * Thresholds are from https://web.dev/articles/vitals + */ +export function rateTimingMetric( + metric: string, + valueMs: number, +): Rating | null { + const thresholds: Record = { + LCP: {good: 2500, poor: 4000}, + FCP: {good: 1800, poor: 3000}, + INP: {good: 200, poor: 500}, + TTFB: {good: 800, poor: 1800}, + }; + + const t = thresholds[metric]; + if (!t) { + return null; + } + if (valueMs <= t.good) { + return 'good'; + } + if (valueMs >= t.poor) { + return 'poor'; + } + return 'needs-improvement'; +} + +export function rateCLS(value: number): Rating { + if (value <= 0.1) { + return 'good'; + } + if (value >= 0.25) { + return 'poor'; + } + return 'needs-improvement'; +} + +/** + * Build a CrUX field metrics section with ratings included directly, + * using the structured data from the trace insights rather than + * regex post-processing. + */ +function buildRatedCruxSection(result: TraceResult): string[] | null { + const parsedTrace = result.parsedTrace; + const insights = result.insights; + if (!insights) { + return null; + } + + // Find the first insight set with CrUX data. + for (const insightSet of insights.values()) { + try { + const cruxScope = + DevTools.CrUXManager.instance().getSelectedScope(); + const fieldMetrics = + DevTools.TraceEngine.Insights.Common.getFieldMetricsForInsightSet( + insightSet, + parsedTrace.metadata, + cruxScope, + ); + + if (!fieldMetrics) { + continue; + } + + const {lcp: fieldLcp, inp: fieldInp, cls: fieldCls} = fieldMetrics; + if (!fieldLcp && !fieldInp && !fieldCls) { + continue; + } + + const parts: string[] = []; + parts.push('Metrics (field / real users):'); + + if (fieldLcp) { + const ms = Math.round(fieldLcp.value / 1000); + const rating = rateTimingMetric('LCP', ms); + const ratingStr = rating ? ` [${rating}]` : ''; + parts.push( + ` - LCP: ${ms} ms (scope: ${fieldLcp.pageScope})${ratingStr}`, + ); + } + if (fieldInp) { + const ms = Math.round(fieldInp.value / 1000); + const rating = rateTimingMetric('INP', ms); + const ratingStr = rating ? ` [${rating}]` : ''; + parts.push( + ` - INP: ${ms} ms (scope: ${fieldInp.pageScope})${ratingStr}`, + ); + } + if (fieldCls) { + const clsValue = fieldCls.value; + const rating = rateCLS(clsValue); + parts.push( + ` - CLS: ${clsValue.toFixed(2)} (scope: ${fieldCls.pageScope}) [${rating}]`, + ); + } + + return parts; + } catch { + continue; + } + } + + return null; +} + export function getTraceSummary(result: TraceResult): string { const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace); const formatter = new DevTools.PerformanceTraceFormatter(focus); - const summaryText = formatter.formatTraceSummary(); + let summaryText = formatter.formatTraceSummary(); + + // Replace the CrUX section in the formatter output with our rated version. + const ratedCrux = buildRatedCruxSection(result); + if (ratedCrux) { + const lines = summaryText.split('\n'); + const cruxHeaderIdx = lines.findIndex(l => + l.startsWith('Metrics (field / real users):'), + ); + if (cruxHeaderIdx !== -1) { + // Find the end of the CrUX section (next non-indented line or section header). + let endIdx = cruxHeaderIdx + 1; + while ( + endIdx < lines.length && + (lines[endIdx].startsWith(' - ') || lines[endIdx].startsWith(' - ')) + ) { + endIdx++; + } + lines.splice( + cruxHeaderIdx, + endIdx - cruxHeaderIdx, + ...ratedCrux, + ); + summaryText = lines.join('\n'); + } + } return `## Summary of Performance trace findings: ${summaryText} diff --git a/tests/trace-processing/parse.test.ts b/tests/trace-processing/parse.test.ts index d54ff24ba..9b04d6eae 100644 --- a/tests/trace-processing/parse.test.ts +++ b/tests/trace-processing/parse.test.ts @@ -10,6 +10,8 @@ import {describe, it} from 'node:test'; import { getTraceSummary, parseRawTraceBuffer, + rateCLS, + rateTimingMetric, } from '../../src/trace-processing/parse.js'; import '../../src/DevtoolsUtils.js'; @@ -40,6 +42,50 @@ describe('Trace parsing', async () => { t.assert.snapshot?.(output); }); + describe('rateTimingMetric', () => { + it('rates fast LCP as good', () => { + assert.strictEqual(rateTimingMetric('LCP', 1500), 'good'); + }); + + it('rates moderate LCP as needs-improvement', () => { + assert.strictEqual(rateTimingMetric('LCP', 3000), 'needs-improvement'); + }); + + it('rates slow LCP as poor', () => { + assert.strictEqual(rateTimingMetric('LCP', 5000), 'poor'); + }); + + it('rates fast INP as good', () => { + assert.strictEqual(rateTimingMetric('INP', 100), 'good'); + }); + + it('rates FCP at boundary as needs-improvement', () => { + assert.strictEqual(rateTimingMetric('FCP', 2500), 'needs-improvement'); + }); + + it('rates fast TTFB as good', () => { + assert.strictEqual(rateTimingMetric('TTFB', 500), 'good'); + }); + + it('returns null for unknown metrics', () => { + assert.strictEqual(rateTimingMetric('UNKNOWN', 100), null); + }); + }); + + describe('rateCLS', () => { + it('rates low CLS as good', () => { + assert.strictEqual(rateCLS(0.05), 'good'); + }); + + it('rates moderate CLS as needs-improvement', () => { + assert.strictEqual(rateCLS(0.15), 'needs-improvement'); + }); + + it('rates high CLS as poor', () => { + assert.strictEqual(rateCLS(0.30), 'poor'); + }); + }); + it('will return a message if there is an error', async () => { const result = await parseRawTraceBuffer(undefined); assert.deepEqual(result, {