feat: Support filter with heap snapshots aggregates (#2323)

This exposes DevTools named filters in get_heapsnapshot_details and
get_heapsnapshot_class_nodes tools. This allows the client to list e.g.
all objects retained through detached DOM objects.

Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
Dominik Inführ
2026-07-09 11:08:29 +02:00
committed by GitHub
parent c065fd90ce
commit 2812902ba9
9 changed files with 154 additions and 2 deletions
+2
View File
@@ -484,6 +484,7 @@ in the DevTools Elements panel (if any).
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
- **id** (number) **(required)**: The ID for the class, obtained from details.
- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the nodes.
- **pageIdx** (number) _(optional)_: The page index for pagination.
- **pageSize** (number) _(optional)_: The page size for pagination.
@@ -496,6 +497,7 @@ in the DevTools Elements panel (if any).
**Parameters:**
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the aggregates.
- **pageIdx** (number) _(optional)_: The page index for pagination of aggregates.
- **pageSize** (number) _(optional)_: The page size for pagination of aggregates.
+8
View File
@@ -73,10 +73,14 @@ export class HeapSnapshotManager {
async getAggregates(
filePath: string,
filterName?: string,
): Promise<Record<string, AggregatedInfoWithId>> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
if (filterName) {
filter.filterName = filterName;
}
const aggregates: Record<string, AggregatedInfoWithId> =
await snapshot.aggregatesWithFilter(filter);
@@ -122,10 +126,14 @@ export class HeapSnapshotManager {
async getNodesById(
filePath: string,
id: number,
filterName?: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
if (filterName) {
filter.filterName = filterName;
}
const className = await this.resolveClassKeyFromId(filePath, id);
if (!className) {
throw new Error(`Class with ID ${id} not found in heap snapshot`);
+8 -2
View File
@@ -952,8 +952,9 @@ export class McpContext implements Context {
async getHeapSnapshotAggregates(
filePath: string,
filterName?: string,
): Promise<Record<string, AggregatedInfoWithId>> {
return await this.#heapSnapshotManager.getAggregates(filePath);
return await this.#heapSnapshotManager.getAggregates(filePath, filterName);
}
async getHeapSnapshotDuplicateStrings(
@@ -977,8 +978,13 @@ export class McpContext implements Context {
async getHeapSnapshotNodesById(
filePath: string,
id: number,
filterName?: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
return await this.#heapSnapshotManager.getNodesById(filePath, id);
return await this.#heapSnapshotManager.getNodesById(
filePath,
id,
filterName,
);
}
async getHeapSnapshotRetainers(
+24
View File
@@ -349,6 +349,18 @@ export const commands: Commands = {
description: 'The ID for the class, obtained from details.',
required: true,
},
filterName: {
name: 'filterName',
type: 'string',
description: 'An optional filter to apply to the nodes.',
required: false,
enum: [
'objectsRetainedByDetachedDomNodes',
'objectsRetainedByConsole',
'objectsRetainedByEventHandlers',
'objectsRetainedByContexts',
],
},
pageIdx: {
name: 'pageIdx',
type: 'number',
@@ -374,6 +386,18 @@ export const commands: Commands = {
description: 'A path to a .heapsnapshot file to read.',
required: true,
},
filterName: {
name: 'filterName',
type: 'string',
description: 'An optional filter to apply to the aggregates.',
required: false,
enum: [
'objectsRetainedByDetachedDomNodes',
'objectsRetainedByConsole',
'objectsRetainedByEventHandlers',
'objectsRetainedByContexts',
],
},
pageIdx: {
name: 'pageIdx',
type: 'number',
+8
View File
@@ -684,6 +684,10 @@
{
"name": "id",
"argType": "number"
},
{
"name": "filter_name",
"argType": "string"
}
]
},
@@ -701,6 +705,10 @@
{
"name": "page_size",
"argType": "number"
},
{
"name": "filter_name",
"argType": "string"
}
]
},
+2
View File
@@ -257,6 +257,7 @@ export type Context = Readonly<{
): string | undefined;
getHeapSnapshotAggregates(
filePath: string,
filterName?: string,
): Promise<Record<string, AggregatedInfoWithId>>;
getHeapSnapshotDuplicateStrings(
filePath: string,
@@ -270,6 +271,7 @@ export type Context = Readonly<{
getHeapSnapshotNodesById(
filePath: string,
id: number,
filterName?: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
getHeapSnapshotRetainers(
filePath: string,
+17
View File
@@ -9,6 +9,13 @@ import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool, defineTool} from './ToolDefinition.js';
const HEAP_SNAPSHOT_FILTERS: readonly [string, ...string[]] = [
'objectsRetainedByDetachedDomNodes',
'objectsRetainedByConsole',
'objectsRetainedByEventHandlers',
'objectsRetainedByContexts',
];
export const takeHeapSnapshot = definePageTool({
name: 'take_heapsnapshot',
description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`,
@@ -73,6 +80,10 @@ export const getHeapSnapshotDetails = defineTool({
},
schema: {
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
filterName: zod
.enum(HEAP_SNAPSHOT_FILTERS)
.optional()
.describe('An optional filter to apply to the aggregates.'),
pageIdx: zod
.number()
.optional()
@@ -87,6 +98,7 @@ export const getHeapSnapshotDetails = defineTool({
handler: async (request, response, context) => {
const aggregates = await context.getHeapSnapshotAggregates(
request.params.filePath,
request.params.filterName,
);
response.setHeapSnapshotAggregates(aggregates, {
@@ -108,6 +120,10 @@ export const getHeapSnapshotClassNodes = defineTool({
schema: {
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
id: zod.number().describe('The ID for the class, obtained from details.'),
filterName: zod
.enum(HEAP_SNAPSHOT_FILTERS)
.optional()
.describe('An optional filter to apply to the nodes.'),
pageIdx: zod.number().optional().describe('The page index for pagination.'),
pageSize: zod.number().optional().describe('The page size for pagination.'),
},
@@ -117,6 +133,7 @@ export const getHeapSnapshotClassNodes = defineTool({
const nodes = await context.getHeapSnapshotNodesById(
request.params.filePath,
request.params.id,
request.params.filterName,
);
response.setHeapSnapshotNodes(nodes, {
+22
View File
@@ -49,6 +49,18 @@ nodeId,nodeName,type,distance,selfSize,retainedSize
Showing 1-8 of 8 (Page 1 of 1).
`;
exports[`memory > get_heapsnapshot_class_nodes > with objectsRetainedByContexts filterName 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
45897,makeCallable,closure,5,0.0 kB,0.2 kB
46147,makeCallable,closure,5,0.0 kB,0.2 kB
45893,Apply,closure,5,0.0 kB,0.0 kB
45895,Save,closure,5,0.0 kB,0.0 kB
46143,Apply,closure,5,0.0 kB,0.0 kB
46145,Save,closure,5,0.0 kB,0.0 kB
Showing 1-6 of 6 (Page 1 of 1).
`;
exports[`memory > get_heapsnapshot_details > with default options 1`] = `
## Heap Snapshot Data
Showing 1-157 of 157 (Page 1 of 1).
@@ -212,6 +224,16 @@ id,name,count,selfSize,maxRetainedSize
108,HTMLBodyElement (internal cache) / https://example.com,1,0.0 kB,0.0 kB
`;
exports[`memory > get_heapsnapshot_details > with objectsRetainedByContexts filterName 1`] = `
## Heap Snapshot Data
Showing 1-4 of 4 (Page 1 of 1).
id,name,count,selfSize,maxRetainedSize
1,system / Context,116,2.5 kB,3.5 kB
3,Function,6,0.2 kB,0.7 kB
2,(compiled code),22,0.5 kB,0.5 kB
4,Array,4,0.3 kB,0.3 kB
`;
exports[`memory > get_heapsnapshot_dominators > with valid nodeId 1`] = `
## Heap Snapshot Data
### Dominator Chain
+63
View File
@@ -24,6 +24,7 @@ import {
compareHeapSnapshots,
getHeapSnapshotDuplicateStrings,
} from '../../src/tools/memory.js';
import {stableIdSymbol} from '../../src/utils/id.js';
import {withMcpContext} from '../utils.js';
describe('memory', () => {
@@ -104,6 +105,31 @@ describe('memory', () => {
t.assert.snapshot(output);
});
});
it('with objectsRetainedByContexts filterName', async t => {
await withMcpContext(async (response, context) => {
const filePath = join(
process.cwd(),
'tests/fixtures/example.heapsnapshot',
);
await getHeapSnapshotDetails.handler(
{params: {filePath, filterName: 'objectsRetainedByContexts'}},
response,
context,
);
const responseData = await response.handle(
getHeapSnapshotDetails.name,
context,
);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
});
describe('get_heapsnapshot_class_nodes', () => {
@@ -135,6 +161,43 @@ describe('memory', () => {
});
});
it('with objectsRetainedByContexts filterName', async t => {
await withMcpContext(async (response, context) => {
const filePath = join(
process.cwd(),
'tests/fixtures/example.heapsnapshot',
);
const aggregates = await context.getHeapSnapshotAggregates(
filePath,
'objectsRetainedByContexts',
);
const aggregate = Object.values(aggregates).find(
a => a.name === 'Function',
);
assert.ok(aggregate);
const id = aggregate[stableIdSymbol];
assert.ok(id);
await getHeapSnapshotClassNodes.handler(
{params: {filePath, id, filterName: 'objectsRetainedByContexts'}},
response,
context,
);
const responseData = await response.handle(
getHeapSnapshotClassNodes.name,
context,
);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
it('with non-existent class name', async () => {
await withMcpContext(async (response, context) => {
const filePath = join(