Skip to content

Commit 5ce83fd

Browse files
committed
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
1 parent e6b7a09 commit 5ce83fd

2 files changed

Lines changed: 172 additions & 1 deletion

File tree

src/trace-processing/parse.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,87 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription}
7676
7777
${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`;
7878

79+
type Rating = 'good' | 'needs-improvement' | 'poor';
80+
81+
/**
82+
* Rate a Web Vitals metric value against its thresholds.
83+
* Thresholds are from https://web.dev/articles/vitals
84+
*/
85+
function rateMetric(metric: string, value: number): Rating | null {
86+
const thresholds: Record<string, {good: number; poor: number}> = {
87+
LCP: {good: 2500, poor: 4000},
88+
FCP: {good: 1800, poor: 3000},
89+
INP: {good: 200, poor: 500},
90+
TTFB: {good: 800, poor: 1800},
91+
};
92+
93+
const t = thresholds[metric];
94+
if (!t) {
95+
return null;
96+
}
97+
if (value <= t.good) {
98+
return 'good';
99+
}
100+
if (value >= t.poor) {
101+
return 'poor';
102+
}
103+
return 'needs-improvement';
104+
}
105+
106+
function rateCLS(value: number): Rating {
107+
if (value <= 0.1) {
108+
return 'good';
109+
}
110+
if (value >= 0.25) {
111+
return 'poor';
112+
}
113+
return 'needs-improvement';
114+
}
115+
116+
/**
117+
* Post-process the formatter output to append a rating to each CrUX field
118+
* metric line. Lines produced by the DevTools formatter look like:
119+
* - LCP: 2595 ms (scope: url)
120+
* - INP: 140 ms (scope: url)
121+
* - CLS: 0.06 (scope: url)
122+
* - TTFB: 1273 ms (scope: url)
123+
* - FCP: 2425 ms (scope: url)
124+
*/
125+
export function addRatingsToCruxMetrics(summary: string): string {
126+
const timingMetricRe =
127+
/^(\s+- (?:LCP|INP|FCP|TTFB): )(\d+) ms( \(scope: \w+\))$/;
128+
const clsMetricRe = /^(\s+- CLS: )(\d+\.\d+)( \(scope: \w+\))$/;
129+
130+
return summary
131+
.split('\n')
132+
.map(line => {
133+
const timingMatch = line.match(timingMetricRe);
134+
if (timingMatch) {
135+
const metric = timingMatch[1]
136+
.trim()
137+
.replace(/^- /, '')
138+
.replace(/:.*/, '');
139+
const value = Number(timingMatch[2]);
140+
const rating = rateMetric(metric, value);
141+
if (rating) {
142+
return `${timingMatch[1]}${timingMatch[2]} ms${timingMatch[3]} [${rating}]`;
143+
}
144+
}
145+
const clsMatch = line.match(clsMetricRe);
146+
if (clsMatch) {
147+
const value = Number(clsMatch[2]);
148+
const rating = rateCLS(value);
149+
return `${clsMatch[1]}${clsMatch[2]}${clsMatch[3]} [${rating}]`;
150+
}
151+
return line;
152+
})
153+
.join('\n');
154+
}
155+
79156
export function getTraceSummary(result: TraceResult): string {
80157
const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace);
81158
const formatter = new DevTools.PerformanceTraceFormatter(focus);
82-
const summaryText = formatter.formatTraceSummary();
159+
const summaryText = addRatingsToCruxMetrics(formatter.formatTraceSummary());
83160
return `## Summary of Performance trace findings:
84161
${summaryText}
85162

tests/trace-processing/parse.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import assert from 'node:assert';
88
import {describe, it} from 'node:test';
99

1010
import {
11+
addRatingsToCruxMetrics,
1112
getTraceSummary,
1213
parseRawTraceBuffer,
1314
} from '../../src/trace-processing/parse.js';
@@ -40,6 +41,99 @@ describe('Trace parsing', async () => {
4041
t.assert.snapshot?.(output);
4142
});
4243

44+
describe('addRatingsToCruxMetrics', () => {
45+
it('adds good rating for fast LCP', () => {
46+
const input = ' - LCP: 1500 ms (scope: url)';
47+
assert.strictEqual(
48+
addRatingsToCruxMetrics(input),
49+
' - LCP: 1500 ms (scope: url) [good]',
50+
);
51+
});
52+
53+
it('adds needs-improvement rating for moderate LCP', () => {
54+
const input = ' - LCP: 3000 ms (scope: url)';
55+
assert.strictEqual(
56+
addRatingsToCruxMetrics(input),
57+
' - LCP: 3000 ms (scope: url) [needs-improvement]',
58+
);
59+
});
60+
61+
it('adds poor rating for slow LCP', () => {
62+
const input = ' - LCP: 5000 ms (scope: url)';
63+
assert.strictEqual(
64+
addRatingsToCruxMetrics(input),
65+
' - LCP: 5000 ms (scope: url) [poor]',
66+
);
67+
});
68+
69+
it('adds good rating for fast INP', () => {
70+
const input = ' - INP: 100 ms (scope: url)';
71+
assert.strictEqual(
72+
addRatingsToCruxMetrics(input),
73+
' - INP: 100 ms (scope: url) [good]',
74+
);
75+
});
76+
77+
it('adds good rating for low CLS', () => {
78+
const input = ' - CLS: 0.05 (scope: url)';
79+
assert.strictEqual(
80+
addRatingsToCruxMetrics(input),
81+
' - CLS: 0.05 (scope: url) [good]',
82+
);
83+
});
84+
85+
it('adds poor rating for high CLS', () => {
86+
const input = ' - CLS: 0.30 (scope: url)';
87+
assert.strictEqual(
88+
addRatingsToCruxMetrics(input),
89+
' - CLS: 0.30 (scope: url) [poor]',
90+
);
91+
});
92+
93+
it('adds rating for FCP', () => {
94+
const input = ' - FCP: 2500 ms (scope: origin)';
95+
assert.strictEqual(
96+
addRatingsToCruxMetrics(input),
97+
' - FCP: 2500 ms (scope: origin) [needs-improvement]',
98+
);
99+
});
100+
101+
it('adds rating for TTFB', () => {
102+
const input = ' - TTFB: 500 ms (scope: url)';
103+
assert.strictEqual(
104+
addRatingsToCruxMetrics(input),
105+
' - TTFB: 500 ms (scope: url) [good]',
106+
);
107+
});
108+
109+
it('does not modify non-CrUX lines', () => {
110+
const input = ' - LCP: 1500 ms, event: (eventKey: 1, ts: 123)';
111+
assert.strictEqual(addRatingsToCruxMetrics(input), input);
112+
});
113+
114+
it('handles multi-line summary with mixed content', () => {
115+
const input = [
116+
'Metrics (field / real users):',
117+
' - LCP: 2595 ms (scope: url)',
118+
' - LCP breakdown:',
119+
' - TTFB: 1273 ms (scope: url)',
120+
' - INP: 140 ms (scope: url)',
121+
' - CLS: 0.06 (scope: url)',
122+
' - The above data is from CrUX',
123+
].join('\n');
124+
const expected = [
125+
'Metrics (field / real users):',
126+
' - LCP: 2595 ms (scope: url) [needs-improvement]',
127+
' - LCP breakdown:',
128+
' - TTFB: 1273 ms (scope: url) [needs-improvement]',
129+
' - INP: 140 ms (scope: url) [good]',
130+
' - CLS: 0.06 (scope: url) [good]',
131+
' - The above data is from CrUX',
132+
].join('\n');
133+
assert.strictEqual(addRatingsToCruxMetrics(input), expected);
134+
});
135+
});
136+
43137
it('will return a message if there is an error', async () => {
44138
const result = await parseRawTraceBuffer(undefined);
45139
assert.deepEqual(result, {

0 commit comments

Comments
 (0)