Skip to content

Commit 37bdaba

Browse files
committed
ui: Add bidirectional navigation between flamegraph and Heapdump Explorer
Adds flamegraph-to-Heapdump Explorer navigation ("Open in Heapdump Explorer") from the heap graph flamegraph context menu, and Heapdump Explorer-to-flamegraph navigation ("View in Timeline") from the object detail view. Introduces the flamegraph_objects_view for browsing objects matching a flamegraph node selection. Change-Id: I6b3f522f5d5a82822decf247ce9b672941fe086a
1 parent cf51568 commit 37bdaba

File tree

8 files changed

+403
-275
lines changed

8 files changed

+403
-275
lines changed

ui/src/components/query_flamegraph.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,25 @@ async function computeFlamegraphTree(
296296
const unaggCols = unagg.map((x) => x.name);
297297

298298
const matchingColumns = ['name', ...unaggCols];
299-
const matchExpr = (x: string) =>
300-
matchingColumns.map(
299+
// Aggregatable properties using CONCAT_WITH_COMMA store comma-separated
300+
// values when nodes are merged. For exact match filters (^...$), we use
301+
// comma-delimited search to match individual elements.
302+
const concatCols = agg
303+
.filter((a) => a.mergeAggregation === 'CONCAT_WITH_COMMA')
304+
.map((a) => a.name);
305+
const matchExpr = (x: string) => {
306+
const likeFilter = sqliteString(makeSqlFilter(x));
307+
const standard = matchingColumns.map(
308+
(c) => `(IFNULL(${c}, '') like ${likeFilter} escape '\\')`,
309+
);
310+
// For comma-separated columns, wrap in delimiters for exact element match:
311+
// ',' || col || ',' LIKE '%,value,%'
312+
const csvMatch = concatCols.map(
301313
(c) =>
302-
`(IFNULL(${c}, '') like ${sqliteString(makeSqlFilter(x))} escape '\\')`,
314+
`(',' || IFNULL(${c}, '') || ',' like '%,' || ${likeFilter} || ',%' escape '\\')`,
303315
);
316+
return [...standard, ...csvMatch];
317+
};
304318

305319
const showStackFilter =
306320
showStackAndPivot.length === 0

ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {Engine} from '../../trace_processor/engine';
1717
import type {Trace} from '../../public/trace';
1818
import {Spinner} from '../../widgets/spinner';
1919
import {EmptyState} from '../../widgets/empty_state';
20+
import HeapProfilePlugin from '../dev.perfetto.HeapProfile';
2021
import type {NavState} from './nav_state';
2122
import type {OverviewData} from './types';
2223
import {Breadcrumbs} from './components';
@@ -38,6 +39,45 @@ import InstancesView from './views/instances_view';
3839
import BitmapGalleryView from './views/bitmap_gallery_view';
3940
import ClassesView from './views/classes_view';
4041
import StringsView from './views/strings_view';
42+
import FlamegraphObjectsView from './views/flamegraph_objects_view';
43+
import {
44+
consumeFlamegraphHeapdumpSelection,
45+
type FlamegraphHeapdumpSelection,
46+
} from '../dev.perfetto.HeapProfile/flamegraph_to_heapdump';
47+
48+
// ─── View in Timeline ─────────────────────────────────────────────────────────
49+
50+
async function viewInTimeline(objectId: number): Promise<void> {
51+
const trace = HeapDumpPage.trace;
52+
if (!trace) return;
53+
54+
const info = await queries.getHeapGraphTrackInfo(trace.engine, objectId);
55+
if (!info) return;
56+
57+
const filter = info.pathHash
58+
? `^${info.pathHash}$`
59+
: info.className
60+
? `^${info.className}$`
61+
: undefined;
62+
if (filter) {
63+
const hp = trace.plugins.getPlugin(HeapProfilePlugin);
64+
hp.setJavaHeapGraphFlamegraphFilter(filter);
65+
}
66+
67+
const uri = `/process_${info.upid}/java_heap_graph_heap_profile`;
68+
trace.navigate('#!/viewer');
69+
trace.selection.selectTrackEvent(uri, info.eventId);
70+
trace.scrollTo({track: {uri, expandGroup: true}});
71+
}
72+
73+
// Cached flamegraph selection consumed from HeapProfile. Consumed once on first
74+
// render of the flamegraph-objects view, then reused for subsequent renders.
75+
let cachedFlamegraphSelection: FlamegraphHeapdumpSelection | null = null;
76+
77+
/** Reset cached selection on trace change to prevent stale cross-trace data. */
78+
export function resetCachedFlamegraphSelection(): void {
79+
cachedFlamegraphSelection = null;
80+
}
4181

4282
// Module-level overview cache. Survives component remounts (e.g. theme toggle).
4383
let cachedOverview: OverviewData | null = null;
@@ -95,6 +135,7 @@ function renderContentView(
95135
heaps: overview.heaps,
96136
navigate,
97137
params: state.params,
138+
onViewInTimeline: viewInTimeline,
98139
});
99140
case 'instances':
100141
return m(InstancesView, {engine, navigate, params: state.params});
@@ -112,8 +153,22 @@ function renderContentView(
112153
initialQuery: state.params.q,
113154
hasFieldValues: overview.hasFieldValues,
114155
});
115-
default:
116-
return null;
156+
case 'flamegraph-objects': {
157+
const pending = consumeFlamegraphHeapdumpSelection();
158+
if (pending) cachedFlamegraphSelection = pending;
159+
const sel = cachedFlamegraphSelection;
160+
return m(FlamegraphObjectsView, {
161+
engine,
162+
navigate,
163+
nodeName: state.params.name,
164+
pathHashes: sel?.pathHashes,
165+
isDominator: sel?.isDominator,
166+
onBackToTimeline: () => {
167+
const trace = HeapDumpPage.trace;
168+
if (trace) trace.navigate('#!/viewer');
169+
},
170+
});
171+
}
117172
}
118173
}
119174

ui/src/plugins/com.android.HeapDumpExplorer/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ import {PerfettoPlugin} from '../../public/plugin';
1717
import {Trace} from '../../public/trace';
1818
import {App} from '../../public/app';
1919
import {NUM} from '../../trace_processor/query_result';
20-
import {HeapDumpPage, resetCachedOverview} from './heap_dump_page';
20+
import HeapProfilePlugin from '../dev.perfetto.HeapProfile';
21+
import {
22+
HeapDumpPage,
23+
resetCachedFlamegraphSelection,
24+
resetCachedOverview,
25+
} from './heap_dump_page';
2126
import {resetBitmapDumpDataCache} from './queries';
2227

2328
export default class implements PerfettoPlugin {
2429
static readonly id = 'com.android.HeapDumpExplorer';
30+
static readonly dependencies = [HeapProfilePlugin];
2531

2632
static onActivate(app: App): void {
2733
app.pages.registerPage({
@@ -41,6 +47,7 @@ export default class implements PerfettoPlugin {
4147
HeapDumpPage.trace = ctx;
4248
HeapDumpPage.hasHeapData = true;
4349
resetBitmapDumpDataCache();
50+
resetCachedFlamegraphSelection();
4451
resetCachedOverview();
4552

4653
ctx.sidebar.addMenuItem({
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (C) 2026 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import m from 'mithril';
16+
import type {Engine} from '../../../trace_processor/engine';
17+
import type {SqlValue} from '../../../trace_processor/query_result';
18+
import {DataGrid} from '../../../components/widgets/datagrid/datagrid';
19+
import {SQLDataSource} from '../../../components/widgets/datagrid/sql_data_source';
20+
import {createSimpleSchema} from '../../../components/widgets/datagrid/sql_schema';
21+
import type {SchemaRegistry} from '../../../components/widgets/datagrid/datagrid_schema';
22+
import {fmtHex} from '../format';
23+
import {
24+
type NavFn,
25+
sizeRenderer,
26+
shortClassName,
27+
DOMINATOR_TREE_PREAMBLE,
28+
} from '../components';
29+
30+
interface FlamegraphObjectsViewAttrs {
31+
engine: Engine;
32+
navigate: NavFn;
33+
onBackToTimeline?: () => void;
34+
nodeName?: string;
35+
pathHashes?: string;
36+
isDominator?: boolean;
37+
}
38+
39+
function flamegraphQuery(pathHashes: string, isDominator: boolean): string {
40+
const hashTable = isDominator
41+
? '_heap_graph_dominator_path_hashes'
42+
: '_heap_graph_path_hashes';
43+
const values = pathHashes
44+
.split(',')
45+
.map((v) => `(${v.trim()})`)
46+
.join(', ');
47+
return `
48+
WITH _hde_sel(path_hash) AS (VALUES ${values})
49+
SELECT
50+
o.id,
51+
ifnull(c.deobfuscated_name, c.name) AS cls,
52+
o.self_size,
53+
o.native_size,
54+
ifnull(d.dominated_size_bytes, o.self_size) AS retained,
55+
ifnull(d.dominated_native_size_bytes, o.native_size) AS retained_native,
56+
ifnull(o.heap_type, 'default') AS heap,
57+
od.value_string AS str
58+
FROM _hde_sel f
59+
JOIN ${hashTable} h ON h.path_hash = f.path_hash
60+
JOIN heap_graph_object o ON o.id = h.id
61+
JOIN heap_graph_class c ON o.type_id = c.id
62+
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
63+
LEFT JOIN heap_graph_object_data od ON od.object_id = o.id
64+
`;
65+
}
66+
67+
function makeUiSchema(navigate: NavFn): SchemaRegistry {
68+
return {
69+
query: {
70+
id: {
71+
title: 'Object',
72+
columnType: 'identifier',
73+
cellRenderer: (value: SqlValue, row) => {
74+
const id = Number(value);
75+
const cls = String(row.cls ?? '');
76+
const display = `${shortClassName(cls)} ${fmtHex(id)}`;
77+
const str = row.str != null ? String(row.str) : null;
78+
return m('span', [
79+
m(
80+
'button',
81+
{
82+
class: 'ah-link',
83+
onclick: () =>
84+
navigate('object', {id, label: str ? `"${str}"` : display}),
85+
},
86+
display,
87+
),
88+
str
89+
? m(
90+
'span',
91+
{class: 'ah-str-badge'},
92+
` "${str.length > 40 ? str.slice(0, 40) + '\u2026' : str}"`,
93+
)
94+
: null,
95+
]);
96+
},
97+
},
98+
self_size: {
99+
title: 'Shallow',
100+
columnType: 'quantitative',
101+
cellRenderer: sizeRenderer,
102+
},
103+
native_size: {
104+
title: 'Native',
105+
columnType: 'quantitative',
106+
cellRenderer: sizeRenderer,
107+
},
108+
retained: {
109+
title: 'Retained',
110+
columnType: 'quantitative',
111+
cellRenderer: sizeRenderer,
112+
},
113+
retained_native: {
114+
title: 'Retained Native',
115+
columnType: 'quantitative',
116+
cellRenderer: sizeRenderer,
117+
},
118+
heap: {
119+
title: 'Heap',
120+
columnType: 'text',
121+
},
122+
cls: {
123+
title: 'Class',
124+
columnType: 'text',
125+
},
126+
str: {
127+
title: 'String Value',
128+
columnType: 'text',
129+
},
130+
},
131+
};
132+
}
133+
134+
function FlamegraphObjectsView(): m.Component<FlamegraphObjectsViewAttrs> {
135+
let dataSource: SQLDataSource | null = null;
136+
let lastPathHashes: string | undefined;
137+
138+
return {
139+
oninit(vnode) {
140+
const {pathHashes, isDominator, engine} = vnode.attrs;
141+
lastPathHashes = pathHashes;
142+
if (pathHashes) {
143+
dataSource = new SQLDataSource({
144+
engine,
145+
sqlSchema: createSimpleSchema(
146+
flamegraphQuery(pathHashes, isDominator ?? true),
147+
),
148+
rootSchemaName: 'query',
149+
preamble: DOMINATOR_TREE_PREAMBLE,
150+
});
151+
}
152+
},
153+
onupdate(vnode) {
154+
if (vnode.attrs.pathHashes !== lastPathHashes) {
155+
const {pathHashes, isDominator, engine} = vnode.attrs;
156+
lastPathHashes = pathHashes;
157+
if (pathHashes) {
158+
dataSource = new SQLDataSource({
159+
engine,
160+
sqlSchema: createSimpleSchema(
161+
flamegraphQuery(pathHashes, isDominator ?? true),
162+
),
163+
rootSchemaName: 'query',
164+
preamble: DOMINATOR_TREE_PREAMBLE,
165+
});
166+
} else {
167+
dataSource = null;
168+
}
169+
}
170+
},
171+
view(vnode) {
172+
const {navigate, nodeName, onBackToTimeline} = vnode.attrs;
173+
174+
if (!dataSource) {
175+
return m('div', [
176+
nodeName
177+
? m(
178+
'h2',
179+
{class: 'ah-view-heading'},
180+
'Flamegraph: ',
181+
m('span', {class: 'ah-mono'}, nodeName),
182+
)
183+
: null,
184+
m(
185+
'div',
186+
{class: 'ah-card ah-mb-3'},
187+
m(
188+
'p',
189+
'No flamegraph selection found. Select a node in the ',
190+
'flamegraph and choose "Open in Heapdump Explorer" to see objects here.',
191+
),
192+
),
193+
]);
194+
}
195+
196+
return m('div', {class: 'ah-view-content'}, [
197+
m('div', {class: 'ah-heading-row'}, [
198+
m(
199+
'h2',
200+
{class: 'ah-view-heading'},
201+
nodeName
202+
? ['Flamegraph: ', m('span', {class: 'ah-mono'}, nodeName)]
203+
: 'Flamegraph Objects',
204+
),
205+
onBackToTimeline
206+
? m(
207+
'button',
208+
{class: 'ah-download-link', onclick: onBackToTimeline},
209+
'Back to Timeline',
210+
)
211+
: null,
212+
]),
213+
m(DataGrid, {
214+
schema: makeUiSchema(navigate),
215+
rootSchema: 'query',
216+
data: dataSource,
217+
fillHeight: true,
218+
initialColumns: [
219+
{id: 'self_size', field: 'self_size'},
220+
{id: 'native_size', field: 'native_size'},
221+
{id: 'retained', field: 'retained'},
222+
{id: 'retained_native', field: 'retained_native'},
223+
{id: 'heap', field: 'heap'},
224+
{id: 'cls', field: 'cls'},
225+
{id: 'id', field: 'id'},
226+
],
227+
showExportButton: true,
228+
}),
229+
]);
230+
},
231+
};
232+
}
233+
234+
export default FlamegraphObjectsView;

0 commit comments

Comments
 (0)