diff --git a/README.md b/README.md index cdc842d46..f1946040d 100644 --- a/README.md +++ b/README.md @@ -497,11 +497,10 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`resize_page`](docs/tool-reference.md#resize_page) -- **Performance** (4 tools) +- **Performance** (3 tools) - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) - - [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot) - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) @@ -512,6 +511,8 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) +- **Memory** (1 tools) + - [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 366c21ea4..f5adfb5f7 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -22,11 +22,10 @@ - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (4 tools) +- **[Performance](#performance)** (3 tools) - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) - - [`take_memory_snapshot`](#take_memory_snapshot) - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) @@ -37,6 +36,8 @@ - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) +- **[Memory](#memory)** (1 tools) + - [`take_memory_snapshot`](#take_memory_snapshot) ## Input automation @@ -276,16 +277,6 @@ --- -### `take_memory_snapshot` - -**Description:** Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks. - -**Parameters:** - -- **filePath** (string) **(required)**: A path to a .heapsnapshot file to save the heapsnapshot to. - ---- - ## Network ### `get_network_request` @@ -397,3 +388,15 @@ in the DevTools Elements panel (if any). - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- + +## Memory + +### `take_memory_snapshot` + +**Description:** Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks. + +**Parameters:** + +- **filePath** (string) **(required)**: A path to a .heapsnapshot file to save the heapsnapshot to. + +--- diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 5d105db99..ea9c022a5 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -8,6 +8,14 @@ import fsSync from 'node:fs'; import path from 'node:path'; import {DevTools} from './third_party/index.js'; +import { + createIdGenerator, + stableIdSymbol, + type WithSymbolId, +} from './utils/id.js'; + +export type AggregatedInfoWithUid = + WithSymbolId; export class HeapSnapshotManager { #snapshots = new Map< @@ -15,6 +23,10 @@ export class HeapSnapshotManager { { snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; + // TODO: use a multimap + uidToClassKey: Map; + classKeyToUid: Map; + idGenerator: () => number; } >(); @@ -28,20 +40,35 @@ export class HeapSnapshotManager { } const {snapshot, worker} = await this.#loadSnapshot(absolutePath); - this.#snapshots.set(absolutePath, {snapshot, worker}); + this.#snapshots.set(absolutePath, { + snapshot, + worker, + uidToClassKey: new Map(), + classKeyToUid: new Map(), + idGenerator: createIdGenerator(), + }); return snapshot; } async getAggregates( filePath: string, - ): Promise< - Record - > { + ): Promise> { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - return await snapshot.aggregatesWithFilter(filter); + const aggregates: Record = + await snapshot.aggregatesWithFilter(filter); + + for (const key of Object.keys(aggregates)) { + const uid = await this.getOrCreateUidForClassKey(filePath, key); + const aggregate = aggregates[key]; + if (aggregate) { + aggregate[stableIdSymbol] = uid; + } + } + + return aggregates; } async getStats( @@ -58,6 +85,29 @@ export class HeapSnapshotManager { return snapshot.staticData; } + async getOrCreateUidForClassKey( + filePath: string, + classKey: string, + ): Promise { + const cached = this.#getCachedSnapshot(filePath); + let uid = cached.classKeyToUid.get(classKey); + if (!uid) { + uid = cached.idGenerator(); + cached.classKeyToUid.set(classKey, uid); + cached.uidToClassKey.set(uid, classKey); + } + return uid; + } + + #getCachedSnapshot(filePath: string) { + const absolutePath = path.resolve(filePath); + const cached = this.#snapshots.get(absolutePath); + if (!cached) { + throw new Error(`Snapshot not loaded for ${filePath}`); + } + return cached; + } + async #loadSnapshot(absolutePath: string): Promise<{ snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; diff --git a/src/McpContext.ts b/src/McpContext.ts index 198a91a59..15a83d73f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import type {TargetUniverse} from './DevtoolsUtils.js'; import {UniverseManager} from './DevtoolsUtils.js'; import {HeapSnapshotManager} from './HeapSnapshotManager.js'; +import type {AggregatedInfoWithUid} from './HeapSnapshotManager.js'; import {McpPage} from './McpPage.js'; import { NetworkCollector, @@ -918,9 +919,7 @@ export class McpContext implements Context { async getHeapSnapshotAggregates( filePath: string, - ): Promise< - Record - > { + ): Promise> { return await this.#heapSnapshotManager.getAggregates(filePath); } diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 92f461201..e901fb789 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -22,6 +22,11 @@ import { type Page, type PageEvents as PuppeteerPageEvents, } from './third_party/index.js'; +import { + createIdGenerator, + stableIdSymbol, + type WithSymbolId, +} from './utils/id.js'; export class UncaughtError { readonly details: Protocol.Runtime.ExceptionDetails; @@ -42,21 +47,6 @@ export type ListenerMap = { [K in keyof EventMap]?: (event: EventMap[K]) => void; }; -function createIdGenerator() { - let i = 1; - return () => { - if (i === Number.MAX_SAFE_INTEGER) { - i = 0; - } - return i++; - }; -} - -export const stableIdSymbol = Symbol('stableIdSymbol'); -type WithSymbolId = T & { - [stableIdSymbol]?: number; -}; - export class PageCollector { #browser: Browser; #listenersInitializer: ( diff --git a/src/bin/cliDefinitions.ts b/src/bin/cliDefinitions.ts index aef789af3..52a8c8ba4 100644 --- a/src/bin/cliDefinitions.ts +++ b/src/bin/cliDefinitions.ts @@ -583,7 +583,7 @@ export const commands: Commands = { take_memory_snapshot: { description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.', - category: 'Performance', + category: 'Memory', args: { filePath: { name: 'filePath', diff --git a/src/formatters/HeapSnapshotFormatter.ts b/src/formatters/HeapSnapshotFormatter.ts index 37ba96f26..f5bcda0c9 100644 --- a/src/formatters/HeapSnapshotFormatter.ts +++ b/src/formatters/HeapSnapshotFormatter.ts @@ -4,41 +4,39 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js'; import type {DevTools} from '../third_party/index.js'; +import {stableIdSymbol} from '../utils/id.js'; export interface FormattedSnapshotEntry { className: string; + classUid?: number; count: number; selfSize: number; retainedSize: number; } export class HeapSnapshotFormatter { - #aggregates: Record< - string, - DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo - >; + #aggregates: Record; - constructor( - aggregates: Record< - string, - DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo - >, - ) { + constructor(aggregates: Record) { this.#aggregates = aggregates; } - #getSortedAggregates(): DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo[] { + #getSortedAggregates(): AggregatedInfoWithUid[] { return Object.values(this.#aggregates).sort((a, b) => b.self - a.self); } toString(): string { const sorted = this.#getSortedAggregates(); const lines: string[] = []; - lines.push('className,count,selfSize,maxRetainedSize'); + lines.push('uid,className,count,selfSize,maxRetainedSize'); for (const info of sorted) { - lines.push(`"${info.name}",${info.count},${info.self},${info.maxRet}`); + const uid = info[stableIdSymbol] ?? ''; + lines.push( + `${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`, + ); } return lines.join('\n'); @@ -47,6 +45,7 @@ export class HeapSnapshotFormatter { toJSON(): FormattedSnapshotEntry[] { const sorted = this.#getSortedAggregates(); return sorted.map(info => ({ + uid: info[stableIdSymbol], className: info.name, count: info.count, selfSize: info.self, diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index a09d781dd..3f5bdab0d 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -565,5 +565,22 @@ "argType": "number" } ] + }, + { + "name": "get_memory_snapshot_details", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + } + ] } ] diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 2aa640f7f..d080be758 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -5,6 +5,7 @@ */ import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js'; +import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js'; import type {McpPage} from '../McpPage.js'; import {zod} from '../third_party/index.js'; import type { @@ -227,9 +228,7 @@ export type Context = Readonly<{ ): string | undefined; getHeapSnapshotAggregates( filePath: string, - ): Promise< - Record - >; + ): Promise>; getHeapSnapshotStats( filePath: string, ): Promise; diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 38eacf119..67281b029 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -14,7 +14,7 @@ export const takeMemorySnapshot = definePageTool({ name: 'take_memory_snapshot', description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`, annotations: { - category: ToolCategory.PERFORMANCE, + category: ToolCategory.MEMORY, readOnlyHint: false, }, schema: { @@ -38,7 +38,7 @@ export const takeMemorySnapshot = definePageTool({ export const exploreMemorySnapshot = defineTool({ name: 'load_memory_snapshot', description: - 'Loads a memory heapsnapshot and returns snapshot summary stats. ', + 'Loads a memory heapsnapshot and returns snapshot summary stats.', annotations: { category: ToolCategory.MEMORY, readOnlyHint: true, @@ -56,3 +56,35 @@ export const exploreMemorySnapshot = defineTool({ response.setHeapSnapshotStats(stats, staticData); }, }); + +export const getMemorySnapshotDetails = defineTool({ + name: 'get_memory_snapshot_details', + description: + 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates.', + annotations: { + category: ToolCategory.MEMORY, + readOnlyHint: true, + conditions: ['experimentalMemory'], + }, + schema: { + filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), + pageIdx: zod + .number() + .optional() + .describe('The page index for pagination of aggregates.'), + pageSize: zod + .number() + .optional() + .describe('The page size for pagination of aggregates.'), + }, + handler: async (request, response, context) => { + const aggregates = await context.getHeapSnapshotAggregates( + request.params.filePath, + ); + + response.setHeapSnapshotAggregates(aggregates, { + pageIdx: request.params.pageIdx, + pageSize: request.params.pageSize, + }); + }, +}); diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 000000000..9e14b231a --- /dev/null +++ b/src/utils/id.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export function createIdGenerator() { + let i = 1; + return () => { + if (i === Number.MAX_SAFE_INTEGER) { + i = 0; + } + return i++; + }; +} + +export const stableIdSymbol = Symbol('stableIdSymbol'); + +export type WithSymbolId = T & { + [stableIdSymbol]?: number; +}; diff --git a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot index 45b3af05c..5f729f11d 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot +++ b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot @@ -1,5 +1,5 @@ exports[`HeapSnapshotFormatter > toString > formats data as CSV and sorts by self size 1`] = ` -className,count,selfSize,maxRetainedSize -"ObjectA",10,100,1000 -"ObjectB",5,50,500 +uid,className,count,selfSize,maxRetainedSize +1,"ObjectA",10,100,1000 +2,"ObjectB",5,50,500 `; diff --git a/tests/formatters/HeapSnapshotFormatter.test.ts b/tests/formatters/HeapSnapshotFormatter.test.ts index 6cde28a7a..21bef9f15 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.ts +++ b/tests/formatters/HeapSnapshotFormatter.test.ts @@ -9,6 +9,7 @@ import {describe, it} from 'node:test'; import {HeapSnapshotFormatter} from '../../src/formatters/HeapSnapshotFormatter.js'; import type {DevTools} from '../../src/third_party/index.js'; +import {stableIdSymbol} from '../../src/utils/id.js'; describe('HeapSnapshotFormatter', () => { const mockAggregates: Record< @@ -22,6 +23,7 @@ describe('HeapSnapshotFormatter', () => { maxRet: 1000, distance: 1, idxs: [], + [stableIdSymbol]: 1, } as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo, ObjectB: { name: 'ObjectB', @@ -30,6 +32,7 @@ describe('HeapSnapshotFormatter', () => { maxRet: 500, distance: 2, idxs: [], + [stableIdSymbol]: 2, } as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo, }; @@ -47,12 +50,14 @@ describe('HeapSnapshotFormatter', () => { const result = formatter.toJSON(); assert.deepStrictEqual(result, [ { + uid: 1, className: 'ObjectA', count: 10, selfSize: 100, retainedSize: 1000, }, { + uid: 2, className: 'ObjectB', count: 5, selfSize: 50, diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot new file mode 100644 index 000000000..4d091bff4 --- /dev/null +++ b/tests/tools/memory.test.js.snapshot @@ -0,0 +1,186 @@ +exports[`memory > get_memory_snapshot_details > with default options 1`] = ` +## Heap Snapshot Data +Showing 1-157 of 157 (Page 1 of 1). +uid,className,count,selfSize,maxRetainedSize +2,"(system)",3205,199264,655004 +16,"(object shape)",2878,181480,184320 +11,"Function",3581,111464,237120 +42,"Window (global*) / https://example.com",2,81068,101832 +94,"Window (prototype) / https://example.com",4,53336,53712 +1,"(compiled code)",713,42812,46988 +3,"(array)",34,41416,117472 +6,"(string)",846,27880,27880 +109,"Document",1,8448,8700 +105,"HTMLElement",1,4728,4768 +104,"Element",1,4224,4540 +15,"Object (global*)",2,3856,13488 +10,"HTMLDocument",1,3288,6192 +89,"system / Context",116,2544,3488 +103,"Node",1,1764,1804 +49,"TypedArray",48,1424,8784 +50,"Error",48,1344,7872 +106,"HTMLBodyElement (prototype) / https://example.com",1,1188,1228 +19,"Array",8,1056,7728 +126,"Performance",1,1040,1784 +32,"String",4,960,6056 +54,"Object",22,928,1168 +130,"StyleEngine",1,912,1104 +9,"Window / https://example.com",1,904,1944 +55,"{constructor, toString, toDateString, toTimeString, toISOString, toUTCString, toGMTString, getDate, setDate, getDay}",4,848,8848 +47,"Math",4,816,7792 +39,"{constructor, buffer, get buffer, byteLength, get byteLength, byteOffset, get byteOffset, length, get length}",4,624,7008 +118,"CSSStyleRule",8,576,1024 +119,"CSSStyleSheet",3,528,976 +44,"console",4,488,6152 +68,"DataView",4,480,5504 +76,"{constructor, getColumnNumber, getEnclosingColumnNumber, getEnclosingLineNumber, getEvalOrigin, getFileName, getFunction}",4,480,4448 +110,"Text",6,480,480 +37,"WebAssembly",4,416,1904 +96,"Window (internal cache) / https://example.com",4,400,20256 +135,"EventListener",10,400,400 +40,"{constructor}",14,392,1560 +30,"Set",4,384,1616 +27,"Map",4,336,1504 +43,"Atomics",4,336,3056 +45,"Intl",4,336,4192 +46,"Reflect",4,336,2480 +67,"{at, copyWithin, entries, fill, find, findIndex, findLast, findLastIndex, flat, flatMap, includes, keys, toReversed}",4,336,1344 +134,"FontFaceSet",1,328,328 +133,"StylePropertyMap",8,320,320 +139,"MutationObserver",2,320,320 +25,"{, constructor, get constructor, set constructor, reduce, toArray, forEach, some, every, find}",4,288,3600 +28,"{constructor, __defineGetter__, __defineSetter__, hasOwnProperty, __lookupGetter__, __lookupSetter__, isPrototypeOf}",4,288,1128 +62,"Intl.Locale",4,288,4672 +33,"WeakMap",4,240,960 +36,"{constructor, exec, dotAll, get dotAll, flags, get flags, global, get global, hasIndices, get hasIndices, ignoreCase}",4,240,576 +48,"Number",4,240,1488 +91,"DisposableStack",4,240,3040 +92,"AsyncDisposableStack",4,240,3040 +4,"{, }",8,224,352 +131,"ScriptedAnimationController",1,208,208 +123,"IntersectionObserver",1,200,240 +7,"(number)",16,192,192 +17,"{isTraceCategoryEnabled, trace, getContinuationPreservedEmbedderData, setContinuationPreservedEmbedderData, console}",4,192,1120 +21,"Generator",4,192,1008 +23,"AsyncGenerator",4,192,1008 +34,"JSON",4,192,1008 +35,"Promise",4,192,1000 +53,"WeakSet",4,192,416 +56,"Intl.NumberFormat",4,192,1536 +59,"Intl.PluralRules",4,192,1168 +60,"Intl.RelativeTimeFormat",4,192,1168 +61,"Intl.ListFormat",4,192,1168 +65,"Intl.DurationFormat",4,192,1168 +66,"Intl.DateTimeFormat",4,192,1536 +69,"BigInt",4,192,1168 +70,"Symbol",4,192,1376 +72,"ArrayBuffer",4,192,2160 +79,"WebAssembly.Table",4,192,768 +80,"WebAssembly.Memory",4,192,768 +120,"NavigationHistoryEntry",1,192,192 +129,"StyleSheetCollection",1,192,192 +146,"DocumentTimeline",1,184,184 +116,"PerformancePaintTiming",2,176,176 +145,"

",2,176,176 +122,"ServiceWorkerContainer",1,168,168 +125,"Navigator",1,168,168 +155,"PerformanceNavigationTiming",1,168,168 +95,"MutationObserver (prototype) / https://example.com",1,156,196 +127,"Navigation",1,152,384 +71,"Boolean",4,144,752 +117,"PerformanceResourceTiming",1,136,136 +147,"",1,136,192 +152,"LargestContentfulPaint",1,136,136 +38,"Tag",4,128,128 +115,"