Skip to content

Commit 8f2dc7b

Browse files
Add explore memory snapshot tool
1 parent fefc8ad commit 8f2dc7b

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';
@@ -905,4 +906,49 @@ export class McpContext implements Context {
905906
getExtension(id: string): InstalledExtension | undefined {
906907
return this.#extensionRegistry.getById(id);
907908
}
909+
910+
async getHeapSnapshotProxy(
911+
heapsnapshotPath: string,
912+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
913+
const workerProxy =
914+
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
915+
() => {
916+
/* noop */
917+
},
918+
import.meta.resolve('../third_party/devtools-heap-snapshot-worker.js'),
919+
);
920+
921+
const absolutePath = path.resolve(heapsnapshotPath);
922+
923+
try {
924+
const {promise: snapshotPromise, resolve: resolveSnapshot} =
925+
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
926+
927+
const loaderProxy = workerProxy.createLoader(
928+
1,
929+
(
930+
snapshotProxy: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy,
931+
) => {
932+
resolveSnapshot(snapshotProxy);
933+
},
934+
);
935+
936+
const fileStream = fsSync.createReadStream(absolutePath, {
937+
encoding: 'utf-8',
938+
highWaterMark: 1024 * 1024,
939+
});
940+
941+
for await (const chunk of fileStream) {
942+
await loaderProxy.write(chunk);
943+
}
944+
945+
await loaderProxy.close();
946+
947+
const snapshot = await snapshotPromise;
948+
949+
return snapshot;
950+
} finally {
951+
workerProxy.dispose();
952+
}
953+
}
908954
}

src/McpResponse.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
88
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
9+
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
910
import {IssueFormatter} from './formatters/IssueFormatter.js';
1011
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
1112
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
@@ -166,6 +167,14 @@ export class McpResponse implements Response {
166167
#attachedLighthouseResult?: LighthouseData;
167168
#textResponseLines: string[] = [];
168169
#images: ImageContentData[] = [];
170+
#heapSnapshotOptions?: {
171+
include: boolean;
172+
aggregates: Record<
173+
string,
174+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
175+
>;
176+
pagination?: PaginationOptions;
177+
};
169178
#networkRequestsOptions?: {
170179
include: boolean;
171180
pagination?: PaginationOptions;
@@ -358,6 +367,20 @@ export class McpResponse implements Response {
358367
this.#textResponseLines.push(value);
359368
}
360369

370+
setHeapSnapshot(
371+
aggregates: Record<
372+
string,
373+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
374+
>,
375+
options?: PaginationOptions,
376+
) {
377+
this.#heapSnapshotOptions = {
378+
include: true,
379+
aggregates,
380+
pagination: options,
381+
};
382+
}
383+
361384
attachImage(value: ImageContentData): void {
362385
this.#images.push(value);
363386
}
@@ -638,6 +661,7 @@ export class McpResponse implements Response {
638661
};
639662
pages?: object[];
640663
pagination?: object;
664+
heapSnapshot?: object[];
641665
extensionServiceWorkers?: object[];
642666
extensionPages?: object[];
643667
} = {};
@@ -834,6 +858,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
834858
}
835859
}
836860

861+
if (this.#heapSnapshotOptions?.include) {
862+
const aggregates = this.#heapSnapshotOptions.aggregates;
863+
const entries = Object.entries(aggregates);
864+
const sortedEntries = entries.sort((a, b) => b[1].self - a[1].self);
865+
866+
const paginationData = this.#dataWithPagination(
867+
sortedEntries,
868+
this.#heapSnapshotOptions.pagination,
869+
);
870+
871+
structuredContent.pagination = paginationData.pagination;
872+
response.push(...paginationData.info);
873+
874+
const paginatedRecord = Object.fromEntries(paginationData.items);
875+
const formatter = new HeapSnapshotFormatter(paginatedRecord);
876+
877+
response.push(formatter.toString());
878+
structuredContent.heapSnapshot = formatter.toJSON();
879+
}
880+
837881
if (data.detailedNetworkRequest) {
838882
response.push(data.detailedNetworkRequest.toStringDetailed());
839883
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;
@@ -199,6 +207,9 @@ export type Context = Readonly<{
199207
getExtensionServiceWorkerId(
200208
extensionServiceWorker: ExtensionServiceWorker,
201209
): string | undefined;
210+
getHeapSnapshotProxy(
211+
heapsnapshotPath: string,
212+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>;
202213
}>;
203214

204215
export type ContextPage = Readonly<{

src/tools/memory.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
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

99
import {ToolCategory} from './categories.js';
10-
import {definePageTool} from './ToolDefinition.js';
10+
import {definePageTool, defineTool} from './ToolDefinition.js';
1111

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

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)