diff --git a/README.md b/README.md index 20668aee7..c403b2cc5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ 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). + ## Requirements - [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version. diff --git a/src/McpContext.ts b/src/McpContext.ts index aa848493d..76dec45aa 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -8,7 +8,11 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; +import { + extractUrlLikeFromDevToolsTitle, + urlsEqual, + UniverseManager, +} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; @@ -116,6 +120,7 @@ export class McpContext implements Context { #nextSnapshotId = 1; #traceResults: TraceResult[] = []; + #universeManager: UniverseManager; #locatorClass: typeof Locator; #options: McpContextOptions; @@ -130,6 +135,7 @@ export class McpContext implements Context { this.logger = logger; this.#locatorClass = locatorClass; this.#options = options; + this.#universeManager = new UniverseManager(this.browser); this.#networkCollector = new NetworkCollector(this.browser); @@ -156,11 +162,13 @@ export class McpContext implements Context { async #init() { const pages = await this.createPagesSnapshot(); + await this.#universeManager.init(pages); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); } dispose() { + this.#universeManager.dispose(); this.#networkCollector.dispose(); this.#consoleCollector.dispose(); } diff --git a/src/main.ts b/src/main.ts index ad6507928..bd49d1db7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,7 +106,8 @@ const logDisclaimers = () => { console.error( `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, 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.`, +Avoid sharing sensitive or personal information that you do not want to share with MCP clients. +Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data.`, ); if (args.usageStatistics) { diff --git a/src/third_party/devtools.ts b/src/third_party/devtools.ts index d4838b178..c06af7e4d 100644 --- a/src/third_party/devtools.ts +++ b/src/third_party/devtools.ts @@ -25,6 +25,7 @@ export { ProtocolClient, Common, I18n, + CrUXManager, IssueAggregatorEvents, IssuesManagerEvents, createIssuesFromProtocolIssue, diff --git a/src/tools/performance.ts b/src/tools/performance.ts index a6f8e718e..6ab42f6b6 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -7,9 +7,9 @@ import zlib from 'node:zlib'; import {logger} from '../logger.js'; -import {zod} from '../third_party/index.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 { getInsightOutput, getTraceSummary, @@ -212,6 +212,7 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { + await populateCruxData(result); context.storeTraceRecording(result); const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); @@ -232,3 +233,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 { + logger('populateCruxData called'); + const cruxManager = DevTools.CrUXManager.instance(); + // go/jtfbx + 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; +} diff --git a/tests/tools/performance.test.js.snapshot b/tests/tools/performance.test.js.snapshot index 9cda7250b..e5bca155c 100644 --- a/tests/tools/performance.test.js.snapshot +++ b/tests/tools/performance.test.js.snapshot @@ -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. diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index b1fc2ad5a..f1a5f0140 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -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) => { @@ -347,3 +361,76 @@ describe('performance', () => { }); }); }); + +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}, + }, + }, + }; +}