Skip to content

Commit 21e175b

Browse files
authored
feat: add performance_analyze_insight tool. (#42)
1 parent b58f787 commit 21e175b

File tree

12 files changed

+286
-35
lines changed

12 files changed

+286
-35
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
116116
- [`emulate_cpu`](docs/tool-reference.md#emulate_cpu)
117117
- [`emulate_network`](docs/tool-reference.md#emulate_network)
118118
- [`resize_page`](docs/tool-reference.md#resize_page)
119-
- **Performance** (2 tools)
119+
- **Performance** (3 tools)
120+
- [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight)
120121
- [`performance_start_trace`](docs/tool-reference.md#performance_start_trace)
121122
- [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace)
122123
- **Network** (2 tools)

docs/tool-reference.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
- [`emulate_cpu`](#emulate_cpu)
2323
- [`emulate_network`](#emulate_network)
2424
- [`resize_page`](#resize_page)
25-
- **[Performance](#performance)** (2 tools)
25+
- **[Performance](#performance)** (3 tools)
26+
- [`performance_analyze_insight`](#performance_analyze_insight)
2627
- [`performance_start_trace`](#performance_start_trace)
2728
- [`performance_stop_trace`](#performance_stop_trace)
2829
- **[Network](#network)** (2 tools)
@@ -216,6 +217,16 @@
216217

217218
## Performance
218219

220+
### `performance_analyze_insight`
221+
222+
**Description:** Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording
223+
224+
**Parameters:**
225+
226+
- **insightName** (string) **(required)**: The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"
227+
228+
---
229+
219230
### `performance_start_trace`
220231

221232
**Description:** Starts a performance trace recording

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99
"main": "index.js",
1010
"scripts": {
1111
"build": "tsc && node --experimental-strip-types scripts/post-build.ts",
12+
"typecheck": "tsc --noEmit",
1213
"format": "eslint --cache --fix . ;prettier --write --cache .",
1314
"check-format": "eslint --cache .; prettier --check --cache .;",
1415
"generate-docs": "npm run build && node --experimental-strip-types scripts/generate-docs.ts",
1516
"start": "npm run build && node build/src/index.js",
1617
"start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js",
17-
"test": "npm run build && node --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'",
18-
"test:only": "npm run build && node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
19-
"test:only:no-build": "node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
20-
"test:update-snapshots": "npm run build && node --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'",
18+
"test": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'",
19+
"test:only": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
20+
"test:only:no-build": "node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'",
21+
"test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'",
2122
"prepare": "node --experimental-strip-types scripts/prepare.ts"
2223
},
2324
"files": [

src/McpContext.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import fs from 'node:fs/promises';
1919
import os from 'node:os';
2020
import path from 'node:path';
2121
import {listPages} from './tools/pages.js';
22+
import {TraceResult} from './trace-processing/parse.js';
2223

2324
export interface TextSnapshotNode extends SerializedAXNode {
2425
id: string;
@@ -49,6 +50,7 @@ export class McpContext implements Context {
4950
#dialog?: Dialog;
5051

5152
#nextSnapshotId = 1;
53+
#traceResults: TraceResult[] = [];
5254

5355
private constructor(browser: Browser, logger: Debugger) {
5456
this.browser = browser;
@@ -292,4 +294,12 @@ export class McpContext implements Context {
292294
throw new Error('Could not save a screenshot to a file');
293295
}
294296
}
297+
298+
storeTraceRecording(result: TraceResult): void {
299+
this.#traceResults.push(result);
300+
}
301+
302+
recordedTraces(): TraceResult[] {
303+
return this.#traceResults;
304+
}
295305
}

src/tools/ToolDefinition.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import z from 'zod';
88
import {Dialog, ElementHandle, Page} from 'puppeteer-core';
99
import {ToolCategories} from './categories.js';
10+
import {TraceResult} from '../trace-processing/parse.js';
1011

1112
export interface ToolDefinition<
1213
Schema extends Zod.ZodRawShape = Zod.ZodRawShape,
@@ -54,6 +55,8 @@ export interface Response {
5455
export type Context = Readonly<{
5556
isRunningPerformanceTrace(): boolean;
5657
setIsRunningPerformanceTrace(x: boolean): void;
58+
recordedTraces(): TraceResult[];
59+
storeTraceRecording(result: TraceResult): void;
5760
getSelectedPage(): Page;
5861
getDialog(): Dialog | undefined;
5962
clearDialog(): void;

src/tools/performance.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
import z from 'zod';
88
import {Context, defineTool, Response} from './ToolDefinition.js';
9-
import {insightOutput, parseRawTraceBuffer} from '../trace-processing/parse.js';
9+
import {
10+
getInsightOutput,
11+
getTraceSummary,
12+
InsightName,
13+
parseRawTraceBuffer,
14+
} from '../trace-processing/parse.js';
1015
import {logger} from '../logger.js';
1116
import {Page} from 'puppeteer-core';
1217
import {ToolCategories} from './categories.js';
@@ -108,6 +113,43 @@ export const stopTrace = defineTool({
108113
},
109114
});
110115

116+
export const analyzeInsight = defineTool({
117+
name: 'performance_analyze_insight',
118+
description:
119+
'Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording',
120+
annotations: {
121+
category: ToolCategories.PERFORMANCE,
122+
readOnlyHint: true,
123+
},
124+
schema: {
125+
insightName: z
126+
.string()
127+
.describe(
128+
'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
129+
),
130+
},
131+
handler: async (request, response, context) => {
132+
const lastRecording = context.recordedTraces().at(-1);
133+
if (!lastRecording) {
134+
response.appendResponseLine(
135+
'No recorded traces found. Record a performance trace so you have Insights to analyze.',
136+
);
137+
return;
138+
}
139+
140+
const insightOutput = getInsightOutput(
141+
lastRecording,
142+
request.params.insightName as InsightName,
143+
);
144+
if ('error' in insightOutput) {
145+
response.appendResponseLine(insightOutput.error);
146+
return;
147+
}
148+
149+
response.appendResponseLine(insightOutput.output);
150+
},
151+
});
152+
111153
async function stopTracingAndAppendOutput(
112154
page: Page,
113155
response: Response,
@@ -118,7 +160,8 @@ async function stopTracingAndAppendOutput(
118160
const result = await parseRawTraceBuffer(traceEventsBuffer);
119161
response.appendResponseLine('The performance trace has been stopped.');
120162
if (result) {
121-
const insightText = insightOutput(result);
163+
context.storeTraceRecording(result);
164+
const insightText = getTraceSummary(result);
122165
if (insightText) {
123166
response.appendResponseLine('Insights with performance opportunities:');
124167
response.appendResponseLine(insightText);

src/trace-processing/parse.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import {PerformanceTraceFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';
8+
import {PerformanceInsightFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';
89
import * as TraceEngine from '../../node_modules/chrome-devtools-frontend/front_end/models/trace/trace.js';
910
import {logger} from '../logger.js';
1011
import {AgentFocus} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';
@@ -60,12 +61,51 @@ export async function parseRawTraceBuffer(
6061
}
6162
}
6263

63-
// TODO(jactkfranklin): move the formatters from DevTools to use here.
64-
// This is a very temporary helper to output some text from the tool call to aid development.
65-
export function insightOutput(result: TraceResult): string {
64+
export function getTraceSummary(result: TraceResult): string {
6665
const focus = AgentFocus.full(result.parsedTrace);
6766
const serializer = new TraceEngine.EventsSerializer.EventsSerializer();
6867
const formatter = new PerformanceTraceFormatter(focus, serializer);
6968
const output = formatter.formatTraceSummary();
7069
return output;
7170
}
71+
72+
export type InsightName = keyof TraceEngine.Insights.Types.InsightModels;
73+
export type InsightOutput = {output: string} | {error: string};
74+
75+
export function getInsightOutput(
76+
result: TraceResult,
77+
insightName: InsightName,
78+
): InsightOutput {
79+
// Currently, we do not support inspecting traces with multiple navigations. We either:
80+
// 1. Find Insights from the first navigation (common case: user records a trace with a page reload to test load performance)
81+
// 2. Fall back to finding Insights not associated with a navigation (common case: user tests an interaction without a page load).
82+
const mainNavigationId =
83+
result.parsedTrace.data.Meta.mainFrameNavigations.at(0)?.args.data
84+
?.navigationId;
85+
86+
const insightsForNav = result.insights.get(
87+
mainNavigationId ?? TraceEngine.Types.Events.NO_NAVIGATION,
88+
);
89+
90+
if (!insightsForNav) {
91+
return {
92+
error: 'No Performance Insights for this trace.',
93+
};
94+
}
95+
96+
const matchingInsight =
97+
insightName in insightsForNav.model
98+
? insightsForNav.model[insightName]
99+
: null;
100+
if (!matchingInsight) {
101+
return {
102+
error: `No Insight with the name ${insightName} found. Double check the name you provided is accurate and try again.`,
103+
};
104+
}
105+
106+
const formatter = new PerformanceInsightFormatter(
107+
result.parsedTrace,
108+
matchingInsight,
109+
);
110+
return {output: formatter.formatInsight()};
111+
}

tests/McpContext.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
*/
66
import {describe, it} from 'node:test';
77
import assert from 'assert';
8-
8+
import {TraceResult} from '../src/trace-processing/parse.js';
99
import {withBrowser} from './utils.js';
1010

11-
describe('McpResponse', () => {
11+
describe('McpContext', () => {
1212
it('list pages', async () => {
13-
await withBrowser(async (response, context) => {
13+
await withBrowser(async (_response, context) => {
1414
const page = context.getSelectedPage();
1515
await page.setContent(`<!DOCTYPE html>
1616
<button>Click me</button><input type="text" value="Input">`);
@@ -28,4 +28,14 @@ describe('McpResponse', () => {
2828
}
2929
});
3030
});
31+
32+
it('can store and retrieve performance traces', async () => {
33+
await withBrowser(async (_response, context) => {
34+
const fakeTrace1 = {} as unknown as TraceResult;
35+
const fakeTrace2 = {} as unknown as TraceResult;
36+
context.storeTraceRecording(fakeTrace1);
37+
context.storeTraceRecording(fakeTrace2);
38+
assert.deepEqual(context.recordedTraces(), [fakeTrace1, fakeTrace2]);
39+
});
40+
});
3141
});

tests/setup.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import {it} from 'node:test';
7+
8+
// This is run by Node when we execute the tests via the --require flag.
9+
it.snapshot.setResolveSnapshotPath(testPath => {
10+
// By default the snapshots go into the build directory, but we want them
11+
// in the tests/ directory.
12+
const correctPath = testPath?.replace('/build/tests', '/tests');
13+
return correctPath + '.snapshot';
14+
});
15+
16+
// The default serializer is JSON.stringify which outputs a very hard to read
17+
// snapshot. So we override it to one that shows new lines literally rather
18+
// than via `\n`.
19+
it.snapshot.setDefaultSnapshotSerializers([String]);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
exports[`performance > performance_analyze_insight > returns the information on the insight 1`] = `
2+
## Insight Title: LCP breakdown
3+
4+
## Insight Summary:
5+
This insight is used to analyze the time spent that contributed to the final LCP time and identify which of the 4 phases (or 2 if there was no LCP resource) are contributing most to the delay in rendering the LCP element.
6+
7+
## Detailed analysis:
8+
The Largest Contentful Paint (LCP) time for this navigation was 129.2 ms.
9+
The LCP element is an image fetched from \`https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg\`.
10+
## LCP resource network request: https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg
11+
Timings:
12+
- Queued at: 41.1 ms
13+
- Request sent at: 46.6 ms
14+
- Download complete at: 55.8 ms
15+
- Main thread processing completed at: 58.2 ms
16+
Durations:
17+
- Download time: 0.3 ms
18+
- Main thread processing time: 2.3 ms
19+
- Total duration: 17.1 ms
20+
Redirects: no redirects
21+
Status code: 200
22+
MIME Type: image/svg+xml
23+
Protocol: unknown
24+
Priority: VeryHigh
25+
Render blocking: No
26+
From a service worker: No
27+
Initiators (root request to the request that directly loaded this one): none
28+
29+
30+
We can break this time down into the 4 phases that combine to make the LCP time:
31+
32+
- Time to first byte: 7.9 ms (6.1% of total LCP time)
33+
- Resource load delay: 33.2 ms (25.7% of total LCP time)
34+
- Resource load duration: 14.7 ms (11.4% of total LCP time)
35+
- Element render delay: 73.4 ms (56.8% of total LCP time)
36+
37+
## Estimated savings: none
38+
39+
## External resources:
40+
- https://web.dev/articles/lcp
41+
- https://web.dev/articles/optimize-lcp
42+
`;

0 commit comments

Comments
 (0)