mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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>
This commit is contained in:
10 files changed
+235
-5
No files matched your search
@@ -27,6 +27,12 @@ allowing them to inspect, debug, and modify any data in the browser or DevTools.
|
||||
Avoid sharing sensitive or personal information that you don't want to share with
|
||||
MCP clients.
|
||||
|
||||
Performance tools may send trace URLs to the Google CrUX API to fetch real-user
|
||||
experience data. This helps provide a holistic performance picture by
|
||||
presenting field data alongside lab data. This data is collected by the [Chrome
|
||||
User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). To disable
|
||||
this, run with the `--no-performance-crux` flag.
|
||||
|
||||
## **Usage statistics**
|
||||
|
||||
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:
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--performanceCrux`/ `--performance-crux`**
|
||||
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--usageStatistics`/ `--usage-statistics`**
|
||||
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.
|
||||
- **Type:** boolean
|
||||
|
||||
@@ -69,6 +69,8 @@ interface McpContextOptions {
|
||||
experimentalDevToolsDebugging: boolean;
|
||||
// Whether all page-like targets are exposed as pages.
|
||||
experimentalIncludeAllPages?: boolean;
|
||||
// Whether CrUX data should be fetched.
|
||||
performanceCrux: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 5_000;
|
||||
@@ -370,6 +372,10 @@ export class McpContext implements Context {
|
||||
return this.#isRunningTrace;
|
||||
}
|
||||
|
||||
isCruxEnabled(): boolean {
|
||||
return this.#options.performanceCrux;
|
||||
}
|
||||
|
||||
getDialog(): Dialog | undefined {
|
||||
return this.#dialog;
|
||||
}
|
||||
|
||||
+10
@@ -204,6 +204,12 @@ export const cliOptions = {
|
||||
hidden: true,
|
||||
describe: 'Set to false to exclude tools related to extensions.',
|
||||
},
|
||||
performanceCrux: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe:
|
||||
'Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.',
|
||||
},
|
||||
usageStatistics: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
@@ -297,6 +303,10 @@ export function parseArguments(version: string, argv = process.argv) {
|
||||
'$0 --no-usage-statistics',
|
||||
'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.',
|
||||
],
|
||||
[
|
||||
'$0 --no-performance-crux',
|
||||
'Disable CrUX (field data) integration in performance tools.',
|
||||
],
|
||||
]);
|
||||
|
||||
return yargsInstance
|
||||
|
||||
@@ -115,6 +115,7 @@ async function getContext(): Promise<McpContext> {
|
||||
context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
|
||||
performanceCrux: args.performanceCrux,
|
||||
});
|
||||
}
|
||||
return context;
|
||||
@@ -127,6 +128,12 @@ debug, and modify any data in the browser or DevTools.
|
||||
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`,
|
||||
);
|
||||
|
||||
if (args.performanceCrux) {
|
||||
console.error(
|
||||
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (args.usageStatistics) {
|
||||
console.error(
|
||||
`
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface Response {
|
||||
export type Context = Readonly<{
|
||||
isRunningPerformanceTrace(): boolean;
|
||||
setIsRunningPerformanceTrace(x: boolean): void;
|
||||
isCruxEnabled(): boolean;
|
||||
recordedTraces(): TraceResult[];
|
||||
storeTraceRecording(result: TraceResult): void;
|
||||
getSelectedPage(): Page;
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import {zod} from '../third_party/index.js';
|
||||
import {logger} from '../logger.js';
|
||||
import {zod, DevTools} from '../third_party/index.js';
|
||||
import type {Page} from '../third_party/index.js';
|
||||
import type {InsightName} from '../trace-processing/parse.js';
|
||||
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
|
||||
import {
|
||||
parseRawTraceBuffer,
|
||||
traceResultIsSuccess,
|
||||
@@ -202,6 +203,9 @@ async function stopTracingAndAppendOutput(
|
||||
const result = await parseRawTraceBuffer(traceEventsBuffer);
|
||||
response.appendResponseLine('The performance trace has been stopped.');
|
||||
if (traceResultIsSuccess(result)) {
|
||||
if (context.isCruxEnabled()) {
|
||||
await populateCruxData(result);
|
||||
}
|
||||
context.storeTraceRecording(result);
|
||||
response.attachTraceSummary(result);
|
||||
} else {
|
||||
@@ -213,3 +217,43 @@ async function stopTracingAndAppendOutput(
|
||||
context.setIsRunningPerformanceTrace(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** We tell CrUXManager to fetch data so it's available when DevTools.PerformanceTraceFormatter is invoked */
|
||||
async function populateCruxData(result: TraceResult): Promise<void> {
|
||||
logger('populateCruxData called');
|
||||
const cruxManager = DevTools.CrUXManager.instance();
|
||||
// go/jtfbx. Yes, we're aware this API key is public. ;)
|
||||
cruxManager.setEndpointForTesting(
|
||||
'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk',
|
||||
);
|
||||
const cruxSetting =
|
||||
DevTools.Common.Settings.Settings.instance().createSetting('field-data', {
|
||||
enabled: true,
|
||||
});
|
||||
cruxSetting.set({enabled: true});
|
||||
|
||||
// Gather URLs to fetch CrUX data for
|
||||
const urls = [...(result.parsedTrace.insights?.values() ?? [])].map(c =>
|
||||
c.url.toString(),
|
||||
);
|
||||
urls.push(result.parsedTrace.data.Meta.mainFrameURL);
|
||||
const urlSet = new Set(urls);
|
||||
|
||||
if (urlSet.size === 0) {
|
||||
logger('No URLs found for CrUX data');
|
||||
return;
|
||||
}
|
||||
|
||||
logger(
|
||||
`Fetching CrUX data for ${urlSet.size} URLs: ${Array.from(urlSet).join(', ')}`,
|
||||
);
|
||||
const cruxData = await Promise.all(
|
||||
Array.from(urlSet).map(async url => {
|
||||
const data = await cruxManager.getFieldDataForPage(url);
|
||||
logger(`CrUX data for ${url}: ${data ? 'found' : 'not found'}`);
|
||||
return data;
|
||||
}),
|
||||
);
|
||||
|
||||
result.parsedTrace.metadata.cruxFieldData = cruxData;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ describe('cli args parsing', () => {
|
||||
categoryNetwork: true,
|
||||
'auto-connect': undefined,
|
||||
autoConnect: undefined,
|
||||
'performance-crux': true,
|
||||
performanceCrux: true,
|
||||
'usage-statistics': true,
|
||||
usageStatistics: true,
|
||||
};
|
||||
@@ -272,4 +274,24 @@ describe('cli args parsing', () => {
|
||||
]);
|
||||
assert.strictEqual(disabledArgs.usageStatistics, false);
|
||||
});
|
||||
|
||||
it('parses performance crux flag', async () => {
|
||||
const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']);
|
||||
assert.strictEqual(defaultArgs.performanceCrux, true);
|
||||
|
||||
// force enable
|
||||
const enabledArgs = parseArguments('1.0.0', [
|
||||
'node',
|
||||
'main.js',
|
||||
'--performance-crux',
|
||||
]);
|
||||
assert.strictEqual(enabledArgs.performanceCrux, true);
|
||||
|
||||
const disabledArgs = parseArguments('1.0.0', [
|
||||
'node',
|
||||
'main.js',
|
||||
'--no-performance-crux',
|
||||
]);
|
||||
assert.strictEqual(disabledArgs.performanceCrux, false);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,19 @@ Metrics (lab / observed):
|
||||
- Load duration: 15 ms, bounds: {min: 122411037986, max: 122411052690}
|
||||
- Render delay: 73 ms, bounds: {min: 122411052690, max: 122411126100}
|
||||
- CLS: 0.00
|
||||
Metrics (field / real users): n/a – no data for this page in CrUX
|
||||
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.
|
||||
Available insights:
|
||||
- insight name: LCPBreakdown
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {describe, it, afterEach} from 'node:test';
|
||||
import {describe, it, afterEach, beforeEach} from 'node:test';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import sinon from 'sinon';
|
||||
@@ -28,6 +28,20 @@ describe('performance', () => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(globalThis, 'fetch').callsFake(async url => {
|
||||
const cruxEndpoint =
|
||||
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
||||
if (url.toString().startsWith(cruxEndpoint)) {
|
||||
return new Response(JSON.stringify(cruxResponseFixture()), {
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch to ${url}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance_start_trace', () => {
|
||||
it('starts a trace recording', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
@@ -311,5 +325,103 @@ describe('performance', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch CrUX data if performanceCrux is false', async () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedPage();
|
||||
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
||||
|
||||
await stopTrace.handler({params: {}}, response, context);
|
||||
|
||||
const cruxEndpoint =
|
||||
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
||||
const cruxCall = (globalThis.fetch as sinon.SinonStub)
|
||||
.getCalls()
|
||||
.find(call => call.args[0].toString().startsWith(cruxEndpoint));
|
||||
assert.strictEqual(
|
||||
cruxCall,
|
||||
undefined,
|
||||
'CrUX fetch should not have been called',
|
||||
);
|
||||
},
|
||||
{performanceCrux: false},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function cruxResponseFixture() {
|
||||
// Ideally we could use `mockResponse` from 'chrome-devtools-frontend/front_end/models/crux-manager/CrUXManager.test.ts'
|
||||
// But test files are not published in the cdtf npm package.
|
||||
return {
|
||||
record: {
|
||||
key: {
|
||||
url: 'https://web.dev/',
|
||||
},
|
||||
metrics: {
|
||||
form_factors: {
|
||||
fractions: {desktop: 0.5056, phone: 0.4796, tablet: 0.0148},
|
||||
},
|
||||
largest_contentful_paint: {
|
||||
histogram: [
|
||||
{start: 0, end: 2500, density: 0.7309},
|
||||
{start: 2500, end: 4000, density: 0.163},
|
||||
{start: 4000, density: 0.1061},
|
||||
],
|
||||
percentiles: {p75: 2595},
|
||||
},
|
||||
largest_contentful_paint_image_element_render_delay: {
|
||||
percentiles: {p75: 786},
|
||||
},
|
||||
largest_contentful_paint_image_resource_load_delay: {
|
||||
percentiles: {p75: 86},
|
||||
},
|
||||
largest_contentful_paint_image_time_to_first_byte: {
|
||||
percentiles: {p75: 1273},
|
||||
},
|
||||
cumulative_layout_shift: {
|
||||
histogram: [
|
||||
{start: '0.00', end: '0.10', density: 0.8665},
|
||||
{start: '0.10', end: '0.25', density: 0.0716},
|
||||
{start: '0.25', density: 0.0619},
|
||||
],
|
||||
percentiles: {p75: '0.06'},
|
||||
},
|
||||
interaction_to_next_paint: {
|
||||
histogram: [
|
||||
{start: 0, end: 200, density: 0.8414},
|
||||
{start: 200, end: 500, density: 0.1081},
|
||||
{start: 500, density: 0.0505},
|
||||
],
|
||||
percentiles: {p75: 140},
|
||||
},
|
||||
largest_contentful_paint_image_resource_load_duration: {
|
||||
percentiles: {p75: 451},
|
||||
},
|
||||
round_trip_time: {
|
||||
histogram: [
|
||||
{start: 0, end: 75, density: 0.3663},
|
||||
{start: 75, end: 275, density: 0.5089},
|
||||
{start: 275, density: 0.1248},
|
||||
],
|
||||
percentiles: {p75: 178},
|
||||
},
|
||||
first_contentful_paint: {
|
||||
histogram: [
|
||||
{start: 0, end: 1800, density: 0.5899},
|
||||
{start: 1800, end: 3000, density: 0.2439},
|
||||
{start: 3000, density: 0.1662},
|
||||
],
|
||||
percentiles: {p75: 2425},
|
||||
},
|
||||
},
|
||||
collectionPeriod: {
|
||||
firstDate: {year: 2025, month: 12, day: 8},
|
||||
lastDate: {year: 2026, month: 1, day: 4},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
+6
-1
@@ -80,7 +80,11 @@ export async function withBrowser(
|
||||
|
||||
export async function withMcpContext(
|
||||
cb: (response: McpResponse, context: McpContext) => Promise<void>,
|
||||
options: {debug?: boolean; autoOpenDevTools?: boolean} = {},
|
||||
options: {
|
||||
debug?: boolean;
|
||||
autoOpenDevTools?: boolean;
|
||||
performanceCrux?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
await withBrowser(async browser => {
|
||||
const response = new McpResponse();
|
||||
@@ -92,6 +96,7 @@ export async function withMcpContext(
|
||||
logger('test'),
|
||||
{
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: options.performanceCrux ?? true,
|
||||
},
|
||||
Locator,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user