Skip to content

Commit b747f9d

Browse files
paulirishOrKoN
andauthored
feat: Integrate CrUX data into performance trace summaries (#733)
We upgrade the performance trace tools to include real-user experience data from the Chrome User Experience Report (CrUX). https://developer.chrome.com/docs/crux https://developer.chrome.com/docs/crux/methodology ### Deets * When a trace is stopped, the server now extracts the primary navigation URLs from the trace (determined by insightSets). * It calls the public CrUX API to fetch field metrics (LCP, INP, CLS) for each unique URL/Origin. * The formatting of crux data is handled by upstream TraceFormatter, but it looks like this: ```md Metrics (field / real users): - LCP: 2595 ms (scope: url) - LCP breakdown: - TTFB: 1273 ms (scope: url) - Load delay: 86 ms (scope: url) - Load duration: 451 ms (scope: url) - Render delay: 786 ms (scope: url) - INP: 140 ms (scope: url) - CLS: 0.06 (scope: url) - The above data is from CrUX–Chrome User Experience Report. It's how the page performs for real users. - The values shown above are the p75 measure of all real Chrome users - The scope indicates if the data came from the entire origin, or a specific url - Lab metrics describe how this specific page load performed, while field metrics are an aggregation of results from real-world users. Best practice is to prioritize metrics that are bad in field data. Lab metrics may be better or worse than fields metrics depending on the developer's machine, network, or the actions performed while tracing. ``` **Privacy Considerations:** * Updates the server README to inform users that performance analysis tools may send trace URLs to the Google CrUX API. * Adds a notification message to the server startup logs regarding the CrUX API interaction. Doc: go/crux-in-bifrost Fixes b/446630695 --------- Co-authored-by: Alex Rudenko <alexrudenko@chromium.org> Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
1 parent 247a419 commit b747f9d

File tree

10 files changed

+235
-5
lines changed

10 files changed

+235
-5
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ allowing them to inspect, debug, and modify any data in the browser or DevTools.
2727
Avoid sharing sensitive or personal information that you don't want to share with
2828
MCP clients.
2929

30+
Performance tools may send trace URLs to the Google CrUX API to fetch real-user
31+
experience data. This helps provide a holistic performance picture by
32+
presenting field data alongside lab data. This data is collected by the [Chrome
33+
User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). To disable
34+
this, run with the `--no-performance-crux` flag.
35+
3036
## **Usage statistics**
3137

3238
Google collects usage statistics (such as tool invocation success rates, latency, and environment information) to improve the reliability and performance of Chrome DevTools MCP.
@@ -466,6 +472,11 @@ The Chrome DevTools MCP server supports the following configuration option:
466472
- **Type:** boolean
467473
- **Default:** `true`
468474

475+
- **`--performanceCrux`/ `--performance-crux`**
476+
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
477+
- **Type:** boolean
478+
- **Default:** `true`
479+
469480
- **`--usageStatistics`/ `--usage-statistics`**
470481
Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
471482
- **Type:** boolean

src/McpContext.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ interface McpContextOptions {
6969
experimentalDevToolsDebugging: boolean;
7070
// Whether all page-like targets are exposed as pages.
7171
experimentalIncludeAllPages?: boolean;
72+
// Whether CrUX data should be fetched.
73+
performanceCrux: boolean;
7274
}
7375

7476
const DEFAULT_TIMEOUT = 5_000;
@@ -370,6 +372,10 @@ export class McpContext implements Context {
370372
return this.#isRunningTrace;
371373
}
372374

375+
isCruxEnabled(): boolean {
376+
return this.#options.performanceCrux;
377+
}
378+
373379
getDialog(): Dialog | undefined {
374380
return this.#dialog;
375381
}

src/cli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ export const cliOptions = {
204204
hidden: true,
205205
describe: 'Set to false to exclude tools related to extensions.',
206206
},
207+
performanceCrux: {
208+
type: 'boolean',
209+
default: true,
210+
describe:
211+
'Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.',
212+
},
207213
usageStatistics: {
208214
type: 'boolean',
209215
default: true,
@@ -297,6 +303,10 @@ export function parseArguments(version: string, argv = process.argv) {
297303
'$0 --no-usage-statistics',
298304
'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.',
299305
],
306+
[
307+
'$0 --no-performance-crux',
308+
'Disable CrUX (field data) integration in performance tools.',
309+
],
300310
]);
301311

302312
return yargsInstance

src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ async function getContext(): Promise<McpContext> {
115115
context = await McpContext.from(browser, logger, {
116116
experimentalDevToolsDebugging: devtools,
117117
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
118+
performanceCrux: args.performanceCrux,
118119
});
119120
}
120121
return context;
@@ -127,6 +128,12 @@ debug, and modify any data in the browser or DevTools.
127128
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`,
128129
);
129130

131+
if (args.performanceCrux) {
132+
console.error(
133+
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
134+
);
135+
}
136+
130137
if (args.usageStatistics) {
131138
console.error(
132139
`

src/tools/ToolDefinition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export interface Response {
102102
export type Context = Readonly<{
103103
isRunningPerformanceTrace(): boolean;
104104
setIsRunningPerformanceTrace(x: boolean): void;
105+
isCruxEnabled(): boolean;
105106
recordedTraces(): TraceResult[];
106107
storeTraceRecording(result: TraceResult): void;
107108
getSelectedPage(): Page;

src/tools/performance.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
import zlib from 'node:zlib';
88

9-
import {zod} from '../third_party/index.js';
9+
import {logger} from '../logger.js';
10+
import {zod, DevTools} from '../third_party/index.js';
1011
import type {Page} from '../third_party/index.js';
11-
import type {InsightName} from '../trace-processing/parse.js';
12+
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
1213
import {
1314
parseRawTraceBuffer,
1415
traceResultIsSuccess,
@@ -202,6 +203,9 @@ async function stopTracingAndAppendOutput(
202203
const result = await parseRawTraceBuffer(traceEventsBuffer);
203204
response.appendResponseLine('The performance trace has been stopped.');
204205
if (traceResultIsSuccess(result)) {
206+
if (context.isCruxEnabled()) {
207+
await populateCruxData(result);
208+
}
205209
context.storeTraceRecording(result);
206210
response.attachTraceSummary(result);
207211
} else {
@@ -213,3 +217,43 @@ async function stopTracingAndAppendOutput(
213217
context.setIsRunningPerformanceTrace(false);
214218
}
215219
}
220+
221+
/** We tell CrUXManager to fetch data so it's available when DevTools.PerformanceTraceFormatter is invoked */
222+
async function populateCruxData(result: TraceResult): Promise<void> {
223+
logger('populateCruxData called');
224+
const cruxManager = DevTools.CrUXManager.instance();
225+
// go/jtfbx. Yes, we're aware this API key is public. ;)
226+
cruxManager.setEndpointForTesting(
227+
'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk',
228+
);
229+
const cruxSetting =
230+
DevTools.Common.Settings.Settings.instance().createSetting('field-data', {
231+
enabled: true,
232+
});
233+
cruxSetting.set({enabled: true});
234+
235+
// Gather URLs to fetch CrUX data for
236+
const urls = [...(result.parsedTrace.insights?.values() ?? [])].map(c =>
237+
c.url.toString(),
238+
);
239+
urls.push(result.parsedTrace.data.Meta.mainFrameURL);
240+
const urlSet = new Set(urls);
241+
242+
if (urlSet.size === 0) {
243+
logger('No URLs found for CrUX data');
244+
return;
245+
}
246+
247+
logger(
248+
`Fetching CrUX data for ${urlSet.size} URLs: ${Array.from(urlSet).join(', ')}`,
249+
);
250+
const cruxData = await Promise.all(
251+
Array.from(urlSet).map(async url => {
252+
const data = await cruxManager.getFieldDataForPage(url);
253+
logger(`CrUX data for ${url}: ${data ? 'found' : 'not found'}`);
254+
return data;
255+
}),
256+
);
257+
258+
result.parsedTrace.metadata.cruxFieldData = cruxData;
259+
}

tests/cli.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ describe('cli args parsing', () => {
2121
categoryNetwork: true,
2222
'auto-connect': undefined,
2323
autoConnect: undefined,
24+
'performance-crux': true,
25+
performanceCrux: true,
2426
'usage-statistics': true,
2527
usageStatistics: true,
2628
};
@@ -272,4 +274,24 @@ describe('cli args parsing', () => {
272274
]);
273275
assert.strictEqual(disabledArgs.usageStatistics, false);
274276
});
277+
278+
it('parses performance crux flag', async () => {
279+
const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']);
280+
assert.strictEqual(defaultArgs.performanceCrux, true);
281+
282+
// force enable
283+
const enabledArgs = parseArguments('1.0.0', [
284+
'node',
285+
'main.js',
286+
'--performance-crux',
287+
]);
288+
assert.strictEqual(enabledArgs.performanceCrux, true);
289+
290+
const disabledArgs = parseArguments('1.0.0', [
291+
'node',
292+
'main.js',
293+
'--no-performance-crux',
294+
]);
295+
assert.strictEqual(disabledArgs.performanceCrux, false);
296+
});
275297
});

tests/tools/performance.test.js.snapshot

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,19 @@ Metrics (lab / observed):
7373
- Load duration: 15 ms, bounds: {min: 122411037986, max: 122411052690}
7474
- Render delay: 73 ms, bounds: {min: 122411052690, max: 122411126100}
7575
- CLS: 0.00
76-
Metrics (field / real users): n/a – no data for this page in CrUX
76+
Metrics (field / real users):
77+
- LCP: 2595 ms (scope: url)
78+
- LCP breakdown:
79+
- TTFB: 1273 ms (scope: url)
80+
- Load delay: 86 ms (scope: url)
81+
- Load duration: 451 ms (scope: url)
82+
- Render delay: 786 ms (scope: url)
83+
- INP: 140 ms (scope: url)
84+
- CLS: 0.06 (scope: url)
85+
- The above data is from CrUX–Chrome User Experience Report. It's how the page performs for real users.
86+
- The values shown above are the p75 measure of all real Chrome users
87+
- The scope indicates if the data came from the entire origin, or a specific url
88+
- Lab metrics describe how this specific page load performed, while field metrics are an aggregation of results from real-world users. Best practice is to prioritize metrics that are bad in field data. Lab metrics may be better or worse than fields metrics depending on the developer's machine, network, or the actions performed while tracing.
7789
Available insights:
7890
- insight name: LCPBreakdown
7991
description: Each [subpart has specific improvement strategies](https://developer.chrome.com/docs/performance/insights/lcp-breakdown). Ideally, most of the LCP time should be spent on loading the resources, not within delays.

tests/tools/performance.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import assert from 'node:assert';
8-
import {describe, it, afterEach} from 'node:test';
8+
import {describe, it, afterEach, beforeEach} from 'node:test';
99
import zlib from 'node:zlib';
1010

1111
import sinon from 'sinon';
@@ -28,6 +28,20 @@ describe('performance', () => {
2828
sinon.restore();
2929
});
3030

31+
beforeEach(() => {
32+
sinon.stub(globalThis, 'fetch').callsFake(async url => {
33+
const cruxEndpoint =
34+
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
35+
if (url.toString().startsWith(cruxEndpoint)) {
36+
return new Response(JSON.stringify(cruxResponseFixture()), {
37+
status: 200,
38+
headers: {'Content-Type': 'application/json'},
39+
});
40+
}
41+
throw new Error(`Unexpected fetch to ${url}`);
42+
});
43+
});
44+
3145
describe('performance_start_trace', () => {
3246
it('starts a trace recording', async () => {
3347
await withMcpContext(async (response, context) => {
@@ -311,5 +325,103 @@ describe('performance', () => {
311325
);
312326
});
313327
});
328+
329+
it('does not fetch CrUX data if performanceCrux is false', async () => {
330+
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
331+
await withMcpContext(
332+
async (response, context) => {
333+
context.setIsRunningPerformanceTrace(true);
334+
const selectedPage = context.getSelectedPage();
335+
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
336+
337+
await stopTrace.handler({params: {}}, response, context);
338+
339+
const cruxEndpoint =
340+
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
341+
const cruxCall = (globalThis.fetch as sinon.SinonStub)
342+
.getCalls()
343+
.find(call => call.args[0].toString().startsWith(cruxEndpoint));
344+
assert.strictEqual(
345+
cruxCall,
346+
undefined,
347+
'CrUX fetch should not have been called',
348+
);
349+
},
350+
{performanceCrux: false},
351+
);
352+
});
314353
});
315354
});
355+
356+
function cruxResponseFixture() {
357+
// Ideally we could use `mockResponse` from 'chrome-devtools-frontend/front_end/models/crux-manager/CrUXManager.test.ts'
358+
// But test files are not published in the cdtf npm package.
359+
return {
360+
record: {
361+
key: {
362+
url: 'https://web.dev/',
363+
},
364+
metrics: {
365+
form_factors: {
366+
fractions: {desktop: 0.5056, phone: 0.4796, tablet: 0.0148},
367+
},
368+
largest_contentful_paint: {
369+
histogram: [
370+
{start: 0, end: 2500, density: 0.7309},
371+
{start: 2500, end: 4000, density: 0.163},
372+
{start: 4000, density: 0.1061},
373+
],
374+
percentiles: {p75: 2595},
375+
},
376+
largest_contentful_paint_image_element_render_delay: {
377+
percentiles: {p75: 786},
378+
},
379+
largest_contentful_paint_image_resource_load_delay: {
380+
percentiles: {p75: 86},
381+
},
382+
largest_contentful_paint_image_time_to_first_byte: {
383+
percentiles: {p75: 1273},
384+
},
385+
cumulative_layout_shift: {
386+
histogram: [
387+
{start: '0.00', end: '0.10', density: 0.8665},
388+
{start: '0.10', end: '0.25', density: 0.0716},
389+
{start: '0.25', density: 0.0619},
390+
],
391+
percentiles: {p75: '0.06'},
392+
},
393+
interaction_to_next_paint: {
394+
histogram: [
395+
{start: 0, end: 200, density: 0.8414},
396+
{start: 200, end: 500, density: 0.1081},
397+
{start: 500, density: 0.0505},
398+
],
399+
percentiles: {p75: 140},
400+
},
401+
largest_contentful_paint_image_resource_load_duration: {
402+
percentiles: {p75: 451},
403+
},
404+
round_trip_time: {
405+
histogram: [
406+
{start: 0, end: 75, density: 0.3663},
407+
{start: 75, end: 275, density: 0.5089},
408+
{start: 275, density: 0.1248},
409+
],
410+
percentiles: {p75: 178},
411+
},
412+
first_contentful_paint: {
413+
histogram: [
414+
{start: 0, end: 1800, density: 0.5899},
415+
{start: 1800, end: 3000, density: 0.2439},
416+
{start: 3000, density: 0.1662},
417+
],
418+
percentiles: {p75: 2425},
419+
},
420+
},
421+
collectionPeriod: {
422+
firstDate: {year: 2025, month: 12, day: 8},
423+
lastDate: {year: 2026, month: 1, day: 4},
424+
},
425+
},
426+
};
427+
}

tests/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ export async function withBrowser(
8080

8181
export async function withMcpContext(
8282
cb: (response: McpResponse, context: McpContext) => Promise<void>,
83-
options: {debug?: boolean; autoOpenDevTools?: boolean} = {},
83+
options: {
84+
debug?: boolean;
85+
autoOpenDevTools?: boolean;
86+
performanceCrux?: boolean;
87+
} = {},
8488
) {
8589
await withBrowser(async browser => {
8690
const response = new McpResponse();
@@ -92,6 +96,7 @@ export async function withMcpContext(
9296
logger('test'),
9397
{
9498
experimentalDevToolsDebugging: false,
99+
performanceCrux: options.performanceCrux ?? true,
95100
},
96101
Locator,
97102
);

0 commit comments

Comments
 (0)