From 5ce83fdad799cbd07d03cb126d9b970d106f225a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:34:53 -0700 Subject: [PATCH 1/2] feat: add rating to CrUX field data metrics Add Web Vitals ratings (good/needs-improvement/poor) to each CrUX field metric in the performance trace summary output. Ratings are based on the official Web Vitals thresholds: - LCP: good <= 2500ms, poor >= 4000ms - CLS: good <= 0.1, poor >= 0.25 - INP: good <= 200ms, poor >= 500ms - FCP: good <= 1800ms, poor >= 3000ms - TTFB: good <= 800ms, poor >= 1800ms Fixes #1055 --- src/trace-processing/parse.ts | 79 ++++++++++++++++++++++- tests/trace-processing/parse.test.ts | 94 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts index 7b152d853..af459a7c4 100644 --- a/src/trace-processing/parse.ts +++ b/src/trace-processing/parse.ts @@ -76,10 +76,87 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription} ${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`; +type Rating = 'good' | 'needs-improvement' | 'poor'; + +/** + * Rate a Web Vitals metric value against its thresholds. + * Thresholds are from https://web.dev/articles/vitals + */ +function rateMetric(metric: string, value: 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 (value <= t.good) { + return 'good'; + } + if (value >= t.poor) { + return 'poor'; + } + return 'needs-improvement'; +} + +function rateCLS(value: number): Rating { + if (value <= 0.1) { + return 'good'; + } + if (value >= 0.25) { + return 'poor'; + } + return 'needs-improvement'; +} + +/** + * Post-process the formatter output to append a rating to each CrUX field + * metric line. Lines produced by the DevTools formatter look like: + * - LCP: 2595 ms (scope: url) + * - INP: 140 ms (scope: url) + * - CLS: 0.06 (scope: url) + * - TTFB: 1273 ms (scope: url) + * - FCP: 2425 ms (scope: url) + */ +export function addRatingsToCruxMetrics(summary: string): string { + const timingMetricRe = + /^(\s+- (?:LCP|INP|FCP|TTFB): )(\d+) ms( \(scope: \w+\))$/; + const clsMetricRe = /^(\s+- CLS: )(\d+\.\d+)( \(scope: \w+\))$/; + + return summary + .split('\n') + .map(line => { + const timingMatch = line.match(timingMetricRe); + if (timingMatch) { + const metric = timingMatch[1] + .trim() + .replace(/^- /, '') + .replace(/:.*/, ''); + const value = Number(timingMatch[2]); + const rating = rateMetric(metric, value); + if (rating) { + return `${timingMatch[1]}${timingMatch[2]} ms${timingMatch[3]} [${rating}]`; + } + } + const clsMatch = line.match(clsMetricRe); + if (clsMatch) { + const value = Number(clsMatch[2]); + const rating = rateCLS(value); + return `${clsMatch[1]}${clsMatch[2]}${clsMatch[3]} [${rating}]`; + } + return line; + }) + .join('\n'); +} + export function getTraceSummary(result: TraceResult): string { const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace); const formatter = new DevTools.PerformanceTraceFormatter(focus); - const summaryText = formatter.formatTraceSummary(); + const summaryText = addRatingsToCruxMetrics(formatter.formatTraceSummary()); 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..32cf7cb18 100644 --- a/tests/trace-processing/parse.test.ts +++ b/tests/trace-processing/parse.test.ts @@ -8,6 +8,7 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; import { + addRatingsToCruxMetrics, getTraceSummary, parseRawTraceBuffer, } from '../../src/trace-processing/parse.js'; @@ -40,6 +41,99 @@ describe('Trace parsing', async () => { t.assert.snapshot?.(output); }); + describe('addRatingsToCruxMetrics', () => { + it('adds good rating for fast LCP', () => { + const input = ' - LCP: 1500 ms (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - LCP: 1500 ms (scope: url) [good]', + ); + }); + + it('adds needs-improvement rating for moderate LCP', () => { + const input = ' - LCP: 3000 ms (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - LCP: 3000 ms (scope: url) [needs-improvement]', + ); + }); + + it('adds poor rating for slow LCP', () => { + const input = ' - LCP: 5000 ms (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - LCP: 5000 ms (scope: url) [poor]', + ); + }); + + it('adds good rating for fast INP', () => { + const input = ' - INP: 100 ms (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - INP: 100 ms (scope: url) [good]', + ); + }); + + it('adds good rating for low CLS', () => { + const input = ' - CLS: 0.05 (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - CLS: 0.05 (scope: url) [good]', + ); + }); + + it('adds poor rating for high CLS', () => { + const input = ' - CLS: 0.30 (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - CLS: 0.30 (scope: url) [poor]', + ); + }); + + it('adds rating for FCP', () => { + const input = ' - FCP: 2500 ms (scope: origin)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - FCP: 2500 ms (scope: origin) [needs-improvement]', + ); + }); + + it('adds rating for TTFB', () => { + const input = ' - TTFB: 500 ms (scope: url)'; + assert.strictEqual( + addRatingsToCruxMetrics(input), + ' - TTFB: 500 ms (scope: url) [good]', + ); + }); + + it('does not modify non-CrUX lines', () => { + const input = ' - LCP: 1500 ms, event: (eventKey: 1, ts: 123)'; + assert.strictEqual(addRatingsToCruxMetrics(input), input); + }); + + it('handles multi-line summary with mixed content', () => { + const input = [ + 'Metrics (field / real users):', + ' - LCP: 2595 ms (scope: url)', + ' - LCP breakdown:', + ' - TTFB: 1273 ms (scope: url)', + ' - INP: 140 ms (scope: url)', + ' - CLS: 0.06 (scope: url)', + ' - The above data is from CrUX', + ].join('\n'); + const expected = [ + 'Metrics (field / real users):', + ' - LCP: 2595 ms (scope: url) [needs-improvement]', + ' - LCP breakdown:', + ' - TTFB: 1273 ms (scope: url) [needs-improvement]', + ' - INP: 140 ms (scope: url) [good]', + ' - CLS: 0.06 (scope: url) [good]', + ' - The above data is from CrUX', + ].join('\n'); + assert.strictEqual(addRatingsToCruxMetrics(input), expected); + }); + }); + it('will return a message if there is an error', async () => { const result = await parseRawTraceBuffer(undefined); assert.deepEqual(result, { From 106b5d9c22fa6d7b32a6e6ec852c3a00104e5f1d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:14:03 -0700 Subject: [PATCH 2/2] Move CrUX ratings into formatTraceFormatter per review Replace regex post-processing of formatter output with direct access to CrUX field metrics via DevTools.TraceEngine.Insights.Common. Ratings are now computed from structured data and injected into the summary, avoiding fragile string parsing. --- src/trace-processing/parse.ts | 137 +++++++++++++++++++-------- tests/trace-processing/parse.test.ts | 98 +++++-------------- 2 files changed, 122 insertions(+), 113 deletions(-) diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts index af459a7c4..9f6afbce2 100644 --- a/src/trace-processing/parse.ts +++ b/src/trace-processing/parse.ts @@ -79,10 +79,13 @@ ${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`; type Rating = 'good' | 'needs-improvement' | 'poor'; /** - * Rate a Web Vitals metric value against its thresholds. + * Rate a timing-based Web Vitals metric value (in ms) against its thresholds. * Thresholds are from https://web.dev/articles/vitals */ -function rateMetric(metric: string, value: number): Rating | null { +export function rateTimingMetric( + metric: string, + valueMs: number, +): Rating | null { const thresholds: Record = { LCP: {good: 2500, poor: 4000}, FCP: {good: 1800, poor: 3000}, @@ -94,16 +97,16 @@ function rateMetric(metric: string, value: number): Rating | null { if (!t) { return null; } - if (value <= t.good) { + if (valueMs <= t.good) { return 'good'; } - if (value >= t.poor) { + if (valueMs >= t.poor) { return 'poor'; } return 'needs-improvement'; } -function rateCLS(value: number): Rating { +export function rateCLS(value: number): Rating { if (value <= 0.1) { return 'good'; } @@ -114,49 +117,103 @@ function rateCLS(value: number): Rating { } /** - * Post-process the formatter output to append a rating to each CrUX field - * metric line. Lines produced by the DevTools formatter look like: - * - LCP: 2595 ms (scope: url) - * - INP: 140 ms (scope: url) - * - CLS: 0.06 (scope: url) - * - TTFB: 1273 ms (scope: url) - * - FCP: 2425 ms (scope: url) + * Build a CrUX field metrics section with ratings included directly, + * using the structured data from the trace insights rather than + * regex post-processing. */ -export function addRatingsToCruxMetrics(summary: string): string { - const timingMetricRe = - /^(\s+- (?:LCP|INP|FCP|TTFB): )(\d+) ms( \(scope: \w+\))$/; - const clsMetricRe = /^(\s+- CLS: )(\d+\.\d+)( \(scope: \w+\))$/; - - return summary - .split('\n') - .map(line => { - const timingMatch = line.match(timingMetricRe); - if (timingMatch) { - const metric = timingMatch[1] - .trim() - .replace(/^- /, '') - .replace(/:.*/, ''); - const value = Number(timingMatch[2]); - const rating = rateMetric(metric, value); - if (rating) { - return `${timingMatch[1]}${timingMatch[2]} ms${timingMatch[3]} [${rating}]`; - } +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}`, + ); } - const clsMatch = line.match(clsMetricRe); - if (clsMatch) { - const value = Number(clsMatch[2]); - const rating = rateCLS(value); - return `${clsMatch[1]}${clsMatch[2]}${clsMatch[3]} [${rating}]`; + 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}`, + ); } - return line; - }) - .join('\n'); + 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 = addRatingsToCruxMetrics(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 32cf7cb18..9b04d6eae 100644 --- a/tests/trace-processing/parse.test.ts +++ b/tests/trace-processing/parse.test.ts @@ -8,9 +8,10 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; import { - addRatingsToCruxMetrics, getTraceSummary, parseRawTraceBuffer, + rateCLS, + rateTimingMetric, } from '../../src/trace-processing/parse.js'; import '../../src/DevtoolsUtils.js'; @@ -41,96 +42,47 @@ describe('Trace parsing', async () => { t.assert.snapshot?.(output); }); - describe('addRatingsToCruxMetrics', () => { - it('adds good rating for fast LCP', () => { - const input = ' - LCP: 1500 ms (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - LCP: 1500 ms (scope: url) [good]', - ); + describe('rateTimingMetric', () => { + it('rates fast LCP as good', () => { + assert.strictEqual(rateTimingMetric('LCP', 1500), 'good'); }); - it('adds needs-improvement rating for moderate LCP', () => { - const input = ' - LCP: 3000 ms (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - LCP: 3000 ms (scope: url) [needs-improvement]', - ); + it('rates moderate LCP as needs-improvement', () => { + assert.strictEqual(rateTimingMetric('LCP', 3000), 'needs-improvement'); }); - it('adds poor rating for slow LCP', () => { - const input = ' - LCP: 5000 ms (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - LCP: 5000 ms (scope: url) [poor]', - ); + it('rates slow LCP as poor', () => { + assert.strictEqual(rateTimingMetric('LCP', 5000), 'poor'); }); - it('adds good rating for fast INP', () => { - const input = ' - INP: 100 ms (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - INP: 100 ms (scope: url) [good]', - ); + it('rates fast INP as good', () => { + assert.strictEqual(rateTimingMetric('INP', 100), 'good'); }); - it('adds good rating for low CLS', () => { - const input = ' - CLS: 0.05 (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - CLS: 0.05 (scope: url) [good]', - ); + it('rates FCP at boundary as needs-improvement', () => { + assert.strictEqual(rateTimingMetric('FCP', 2500), 'needs-improvement'); }); - it('adds poor rating for high CLS', () => { - const input = ' - CLS: 0.30 (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - CLS: 0.30 (scope: url) [poor]', - ); + it('rates fast TTFB as good', () => { + assert.strictEqual(rateTimingMetric('TTFB', 500), 'good'); }); - it('adds rating for FCP', () => { - const input = ' - FCP: 2500 ms (scope: origin)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - FCP: 2500 ms (scope: origin) [needs-improvement]', - ); + it('returns null for unknown metrics', () => { + assert.strictEqual(rateTimingMetric('UNKNOWN', 100), null); }); + }); - it('adds rating for TTFB', () => { - const input = ' - TTFB: 500 ms (scope: url)'; - assert.strictEqual( - addRatingsToCruxMetrics(input), - ' - TTFB: 500 ms (scope: url) [good]', - ); + describe('rateCLS', () => { + it('rates low CLS as good', () => { + assert.strictEqual(rateCLS(0.05), 'good'); }); - it('does not modify non-CrUX lines', () => { - const input = ' - LCP: 1500 ms, event: (eventKey: 1, ts: 123)'; - assert.strictEqual(addRatingsToCruxMetrics(input), input); + it('rates moderate CLS as needs-improvement', () => { + assert.strictEqual(rateCLS(0.15), 'needs-improvement'); }); - it('handles multi-line summary with mixed content', () => { - const input = [ - 'Metrics (field / real users):', - ' - LCP: 2595 ms (scope: url)', - ' - LCP breakdown:', - ' - TTFB: 1273 ms (scope: url)', - ' - INP: 140 ms (scope: url)', - ' - CLS: 0.06 (scope: url)', - ' - The above data is from CrUX', - ].join('\n'); - const expected = [ - 'Metrics (field / real users):', - ' - LCP: 2595 ms (scope: url) [needs-improvement]', - ' - LCP breakdown:', - ' - TTFB: 1273 ms (scope: url) [needs-improvement]', - ' - INP: 140 ms (scope: url) [good]', - ' - CLS: 0.06 (scope: url) [good]', - ' - The above data is from CrUX', - ].join('\n'); - assert.strictEqual(addRatingsToCruxMetrics(input), expected); + it('rates high CLS as poor', () => { + assert.strictEqual(rateCLS(0.30), 'poor'); }); });