Skip to content

Commit b98de32

Browse files
Add explore memory snapshot tool
1 parent 0aff266 commit b98de32

9 files changed

Lines changed: 249 additions & 5 deletions

File tree

rollup.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,4 +296,11 @@ export default [
296296
},
297297
(_source, _importer, _isResolved) => false,
298298
),
299+
bundleDependency(
300+
'devtools-heap-snapshot-worker.js',
301+
{
302+
inlineDynamicImports: true,
303+
},
304+
(_source, _importer, _isResolved) => false,
305+
),
299306
];

src/McpContext.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import fsSync from 'node:fs';
78
import fs from 'node:fs/promises';
89
import path from 'node:path';
910

@@ -16,7 +17,6 @@ import {
1617
type ListenerMap,
1718
type UncaughtError,
1819
} from './PageCollector.js';
19-
import type {DevTools} from './third_party/index.js';
2020
import type {
2121
Browser,
2222
BrowserContext,
@@ -29,6 +29,7 @@ import type {
2929
Viewport,
3030
Target,
3131
} from './third_party/index.js';
32+
import {DevTools} from './third_party/index.js';
3233
import {Locator} from './third_party/index.js';
3334
import {PredefinedNetworkConditions} from './third_party/index.js';
3435
import {listPages} from './tools/pages.js';
@@ -913,4 +914,49 @@ export class McpContext implements Context {
913914
getExtension(id: string): InstalledExtension | undefined {
914915
return this.#extensionRegistry.getById(id);
915916
}
917+
918+
async getHeapSnapshotProxy(
919+
heapsnapshotPath: string,
920+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
921+
const workerProxy =
922+
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
923+
() => {
924+
/* noop */
925+
},
926+
import.meta.resolve('../third_party/devtools-heap-snapshot-worker.js'),
927+
);
928+
929+
const absolutePath = path.resolve(heapsnapshotPath);
930+
931+
try {
932+
const {promise: snapshotPromise, resolve: resolveSnapshot} =
933+
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
934+
935+
const loaderProxy = workerProxy.createLoader(
936+
1,
937+
(
938+
snapshotProxy: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy,
939+
) => {
940+
resolveSnapshot(snapshotProxy);
941+
},
942+
);
943+
944+
const fileStream = fsSync.createReadStream(absolutePath, {
945+
encoding: 'utf-8',
946+
highWaterMark: 1024 * 1024,
947+
});
948+
949+
for await (const chunk of fileStream) {
950+
await loaderProxy.write(chunk);
951+
}
952+
953+
await loaderProxy.close();
954+
955+
const snapshot = await snapshotPromise;
956+
957+
return snapshot;
958+
} finally {
959+
workerProxy.dispose();
960+
}
961+
}
916962
}

src/McpResponse.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {WebMCPTool} from 'puppeteer-core';
88

99
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
1010
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
11+
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
1112
import {IssueFormatter} from './formatters/IssueFormatter.js';
1213
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
1314
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
@@ -168,6 +169,14 @@ export class McpResponse implements Response {
168169
#attachedLighthouseResult?: LighthouseData;
169170
#textResponseLines: string[] = [];
170171
#images: ImageContentData[] = [];
172+
#heapSnapshotOptions?: {
173+
include: boolean;
174+
aggregates: Record<
175+
string,
176+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
177+
>;
178+
pagination?: PaginationOptions;
179+
};
171180
#networkRequestsOptions?: {
172181
include: boolean;
173182
pagination?: PaginationOptions;
@@ -365,6 +374,20 @@ export class McpResponse implements Response {
365374
this.#textResponseLines.push(value);
366375
}
367376

377+
setHeapSnapshot(
378+
aggregates: Record<
379+
string,
380+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
381+
>,
382+
options?: PaginationOptions,
383+
) {
384+
this.#heapSnapshotOptions = {
385+
include: true,
386+
aggregates,
387+
pagination: options,
388+
};
389+
}
390+
368391
attachImage(value: ImageContentData): void {
369392
this.#images.push(value);
370393
}
@@ -661,6 +684,7 @@ export class McpResponse implements Response {
661684
};
662685
pages?: object[];
663686
pagination?: object;
687+
heapSnapshot?: object[];
664688
extensionServiceWorkers?: object[];
665689
extensionPages?: object[];
666690
} = {};
@@ -857,6 +881,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
857881
}
858882
}
859883

884+
if (this.#heapSnapshotOptions?.include) {
885+
const aggregates = this.#heapSnapshotOptions.aggregates;
886+
const entries = Object.entries(aggregates);
887+
const sortedEntries = entries.sort((a, b) => b[1].self - a[1].self);
888+
889+
const paginationData = this.#dataWithPagination(
890+
sortedEntries,
891+
this.#heapSnapshotOptions.pagination,
892+
);
893+
894+
structuredContent.pagination = paginationData.pagination;
895+
response.push(...paginationData.info);
896+
897+
const paginatedRecord = Object.fromEntries(paginationData.items);
898+
const formatter = new HeapSnapshotFormatter(paginatedRecord);
899+
900+
response.push(formatter.toString());
901+
structuredContent.heapSnapshot = formatter.toJSON();
902+
}
903+
860904
if (data.detailedNetworkRequest) {
861905
response.push(data.detailedNetworkRequest.toStringDetailed());
862906
structuredContent.networkRequest =
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {DevTools} from '../third_party/index.js';
8+
9+
export interface FormattedSnapshotEntry {
10+
className: string;
11+
count: number;
12+
selfSize: number;
13+
retainedSize: number;
14+
}
15+
16+
export class HeapSnapshotFormatter {
17+
#aggregates: Record<
18+
string,
19+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
20+
>;
21+
22+
constructor(
23+
aggregates: Record<
24+
string,
25+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
26+
>,
27+
) {
28+
this.#aggregates = aggregates;
29+
}
30+
31+
#getSortedAggregates(): DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo[] {
32+
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
33+
}
34+
35+
toString(): string {
36+
const sorted = this.#getSortedAggregates();
37+
const lines: string[] = [];
38+
lines.push('className,count,selfSize,maxRetainedSize');
39+
40+
for (const info of sorted) {
41+
lines.push(`"${info.name}",${info.count},${info.self},${info.maxRet}`);
42+
}
43+
44+
return lines.join('\n');
45+
}
46+
47+
toJSON(): FormattedSnapshotEntry[] {
48+
const sorted = this.#getSortedAggregates();
49+
return sorted.map(info => ({
50+
className: info.name,
51+
count: info.count,
52+
selfSize: info.self,
53+
retainedSize: info.maxRet,
54+
}));
55+
}
56+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// eslint-disable-next-line no-restricted-imports
8+
import '../../node_modules/chrome-devtools-frontend/front_end/entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js';

src/tools/ToolDefinition.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
Page,
1414
ScreenRecorder,
1515
Viewport,
16+
DevTools,
1617
} from '../third_party/index.js';
1718
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
1819
import type {
@@ -99,6 +100,13 @@ export interface DevToolsData {
99100

100101
export interface Response {
101102
appendResponseLine(value: string): void;
103+
setHeapSnapshot(
104+
aggregates: Record<
105+
string,
106+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
107+
>,
108+
options?: PaginationOptions,
109+
): void;
102110
setIncludePages(value: boolean): void;
103111
setIncludeNetworkRequests(
104112
value: boolean,
@@ -118,7 +126,7 @@ export interface Response {
118126
includeSnapshot(params?: SnapshotParams): void;
119127
attachImage(value: ImageContentData): void;
120128
attachNetworkRequest(
121-
reqid: number,
129+
reqId: number,
122130
options?: {requestFilePath?: string; responseFilePath?: string},
123131
): void;
124132
attachConsoleMessage(msgid: number): void;
@@ -213,6 +221,9 @@ export type Context = Readonly<{
213221
getExtensionServiceWorkerId(
214222
extensionServiceWorker: ExtensionServiceWorker,
215223
): string | undefined;
224+
getHeapSnapshotProxy(
225+
heapsnapshotPath: string,
226+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>;
216227
}>;
217228

218229
export type ContextPage = Readonly<{

src/tools/memory.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {zod} from '../third_party/index.js';
7+
import {zod, DevTools} from '../third_party/index.js';
88
import {ensureExtension} from '../utils/files.js';
99

1010
import {ToolCategory} from './categories.js';
11-
import {definePageTool} from './ToolDefinition.js';
11+
import {definePageTool, defineTool} from './ToolDefinition.js';
1212

1313
export const takeMemorySnapshot = definePageTool({
1414
name: 'take_memory_snapshot',
@@ -34,3 +34,37 @@ export const takeMemorySnapshot = definePageTool({
3434
);
3535
},
3636
});
37+
38+
export const exploreMemorySnapshot = defineTool({
39+
name: 'explored_memory_snapshot',
40+
description: 'Explose ',
41+
annotations: {
42+
category: ToolCategory.PERFORMANCE,
43+
readOnlyHint: true,
44+
},
45+
schema: {
46+
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
47+
pageSize: zod.number().optional().describe('Page size for pagination.'),
48+
pageIdx: zod.number().optional().describe('Page index for pagination.'),
49+
},
50+
handler: async (request, response, context) => {
51+
const snapshot = await context.getHeapSnapshotProxy(
52+
request.params.filePath,
53+
);
54+
const stats = await snapshot.getStatistics();
55+
56+
response.appendResponseLine(
57+
`Statistics: ${JSON.stringify(stats, null, 2)}`,
58+
);
59+
response.appendResponseLine(
60+
`Static Data: ${JSON.stringify(snapshot.staticData, null, 2)}`,
61+
);
62+
63+
const filter =
64+
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
65+
const aggregates = await snapshot.aggregatesWithFilter(filter);
66+
67+
const {pageSize, pageIdx} = request.params;
68+
response.setHeapSnapshot(aggregates, {pageSize, pageIdx});
69+
},
70+
});

tests/tools/memory.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {tmpdir} from 'node:os';
1111
import {join} from 'node:path';
1212
import {describe, it} from 'node:test';
1313

14-
import {takeMemorySnapshot} from '../../src/tools/memory.js';
14+
import {
15+
takeMemorySnapshot,
16+
exploreMemorySnapshot,
17+
} from '../../src/tools/memory.js';
1518
import {withMcpContext} from '../utils.js';
1619

1720
describe('memory', () => {
@@ -36,4 +39,38 @@ describe('memory', () => {
3639
});
3740
});
3841
});
42+
43+
describe('explore_memory_snapshot', () => {
44+
it('with default options', async () => {
45+
await withMcpContext(async (response, context) => {
46+
const filePath = join(tmpdir(), 'test-explore.heapsnapshot');
47+
try {
48+
await takeMemorySnapshot.handler(
49+
{params: {filePath}, page: context.getSelectedMcpPage()},
50+
response,
51+
context,
52+
);
53+
54+
await exploreMemorySnapshot.handler(
55+
{params: {filePath}},
56+
response,
57+
context,
58+
);
59+
60+
assert.equal(
61+
response.responseLines.at(0),
62+
`Heap snapshot saved to ${filePath}`,
63+
);
64+
assert.ok(existsSync(filePath));
65+
66+
// Check if response contains Statistics or Static Data
67+
const output = response.responseLines.join('\n');
68+
assert.ok(output.includes('Statistics:'));
69+
assert.ok(output.includes('Static Data:'));
70+
} finally {
71+
await rm(filePath, {force: true});
72+
}
73+
});
74+
});
75+
});
3976
});

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"node_modules/chrome-devtools-frontend/front_end/core/root",
3434
"node_modules/chrome-devtools-frontend/front_end/core/sdk",
3535
"node_modules/chrome-devtools-frontend/front_end/entrypoints/formatter_worker",
36+
"node_modules/chrome-devtools-frontend/front_end/entrypoints/heap_snapshot_worker",
3637
"node_modules/chrome-devtools-frontend/front_end/foundation/foundation.ts",
3738
"node_modules/chrome-devtools-frontend/front_end/foundation/Universe.ts",
3839
"node_modules/chrome-devtools-frontend/front_end/generated",

0 commit comments

Comments
 (0)