| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| import {valueIfAllEqual} from '../../base/array_utils'; |
| import {Icons} from '../../base/semantic_icons'; |
| import {getColorForSlice} from '../../components/colorizer'; |
| import {MenuItem} from '../../widgets/menu'; |
| import {AsyncMemo} from '../../base/async_memo'; |
| import {uuidv4Sql} from '../../base/uuid'; |
| import {Time, type time} from '../../base/time'; |
| import {formatDuration} from '../../components/time_utils'; |
| import {SliceTrack} from '../../components/tracks/slice_track'; |
| import type {Trace} from '../../public/trace'; |
| import type { |
| Track, |
| TrackRenderContext, |
| TrackRenderer, |
| TrackMouseEvent, |
| SnapPoint, |
| TrackSetting, |
| TrackSettingDescriptor, |
| } from '../../public/track'; |
| import type {TrackEventSelection} from '../../public/selection'; |
| import type {TimeScale} from '../../base/time_scale'; |
| import {SourceDataset} from '../../trace_processor/dataset'; |
| import { |
| LONG, |
| NUM, |
| STR, |
| type InferRowType, |
| } from '../../trace_processor/query_result'; |
| import {sqlValueToSqliteString} from '../../trace_processor/sql_utils'; |
| import {FlamechartFrameDetailsPanel} from './frame_details_panel'; |
| import {sampleColorScheme} from './sample_colors'; |
| import { |
| STACK_SAMPLE_FLAMECHART_TRACK_KIND, |
| STACK_SAMPLE_TRACK_KIND, |
| } from './track_kinds'; |
| |
| type FlamechartColorBy = 'mapping' | 'function'; |
| |
| const colorByDescriptor: TrackSettingDescriptor<FlamechartColorBy> = { |
| name: 'Color by', |
| description: 'Color frames by code mapping or function name.', |
| render(setter, values) { |
| const value = valueIfAllEqual(values); |
| return m(MenuItem, {label: 'Color by'}, [ |
| m(MenuItem, { |
| label: 'Mapping', |
| icon: value === 'mapping' ? Icons.RadioChecked : Icons.RadioUnchecked, |
| onclick: () => setter('mapping'), |
| }), |
| m(MenuItem, { |
| label: 'Function name', |
| icon: value === 'function' ? Icons.RadioChecked : Icons.RadioUnchecked, |
| onclick: () => setter('function'), |
| }), |
| ]); |
| }, |
| }; |
| |
| export interface CallstackTrackConfig { |
| readonly source: string; |
| readonly utid: number; |
| readonly upid: number | undefined; |
| // Undefined means all sessions; null means samples without a session. |
| readonly sessionId?: number | null; |
| } |
| |
| const ROW_SCHEMA = { |
| id: NUM, |
| ts: LONG, |
| dur: LONG, |
| depth: NUM, |
| name: STR, |
| sampleCount: NUM, |
| category: NUM, |
| mappingName: STR, |
| frameId: NUM, |
| }; |
| |
| type FlamechartRow = InferRowType<typeof ROW_SCHEMA>; |
| |
| // Owns sampled-stack preparation, keeping the shared SliceTrack API unchanged. |
| export class CallstackTrack implements TrackRenderer { |
| private readonly inner: SliceTrack<typeof ROW_SCHEMA>; |
| private colorBy: FlamechartColorBy = 'function'; |
| private readonly initializationSlot = new AsyncMemo<boolean>(); |
| private initializationPromise?: Promise<void>; |
| private initialized = false; |
| private readonly tableName = `__flamechart_runs_${uuidv4Sql()}`; |
| |
| constructor( |
| private readonly trace: Trace, |
| uri: string, |
| private readonly config: CallstackTrackConfig, |
| ) { |
| this.inner = SliceTrack.create({ |
| trace, |
| uri, |
| dataset: new SourceDataset({schema: ROW_SCHEMA, src: this.tableName}), |
| sliceLayout: {collapsed: true}, |
| getKey: () => this.colorBy, |
| colorizer: (row: FlamechartRow) => |
| this.colorBy === 'function' |
| ? getColorForSlice(row.name, {stripTrailingDigits: false}) |
| : sampleColorScheme(row.category, row.mappingName), |
| tooltip: (slice) => { |
| return [ |
| m('div', slice.row.name), |
| m( |
| 'div', |
| slice.row.dur === -1n |
| ? 'Incomplete (sampled)' |
| : `${formatDuration(trace, slice.row.dur)} (sampled)`, |
| ), |
| m('div', `${slice.row.sampleCount} samples`), |
| ]; |
| }, |
| detailsPanel: (row: FlamechartRow) => { |
| return new FlamechartFrameDetailsPanel(trace, { |
| frameId: row.frameId, |
| name: row.name, |
| ts: Time.fromRaw(row.ts), |
| dur: row.dur, |
| category: row.category, |
| sampleCount: row.sampleCount, |
| trackUri: uri, |
| }); |
| }, |
| }); |
| } |
| |
| get settings(): ReadonlyArray<TrackSetting> { |
| const colorBy: TrackSetting<FlamechartColorBy> = { |
| descriptor: colorByDescriptor, |
| value: this.colorBy, |
| update: (value) => { |
| if (value === this.colorBy) return; |
| this.colorBy = value; |
| this.trace.raf.scheduleFullRedraw(); |
| }, |
| }; |
| return [colorBy]; |
| } |
| |
| private initialize(): Promise<void> { |
| return (this.initializationPromise ??= this.prepare().then(() => { |
| this.initialized = true; |
| this.trace.raf.scheduleFullRedraw(); |
| })); |
| } |
| |
| render(ctx: TrackRenderContext): void { |
| if (!this.initialized) { |
| this.initializationSlot.use({ |
| key: {}, |
| compute: async () => { |
| await this.initialize(); |
| return true; |
| }, |
| }); |
| return; |
| } |
| this.inner.render(ctx); |
| } |
| |
| getHeight() { |
| return this.inner.getHeight(); |
| } |
| getSliceVerticalBounds(depth: number) { |
| return this.inner.getSliceVerticalBounds(depth); |
| } |
| getTrackShellButtons() { |
| return this.inner.getTrackShellButtons(); |
| } |
| onMouseMove(event: TrackMouseEvent) { |
| this.inner.onMouseMove(event); |
| } |
| onMouseOut() { |
| this.inner.onMouseOut(); |
| } |
| onMouseClick(event: TrackMouseEvent) { |
| return this.inner.onMouseClick(event); |
| } |
| onMouseDoubleClick(event: TrackMouseEvent) { |
| return this.inner.onMouseDoubleClick(event); |
| } |
| renderTooltip() { |
| return this.inner.renderTooltip(); |
| } |
| getSnapPoint( |
| targetTime: time, |
| thresholdPx: number, |
| timescale: TimeScale, |
| ): SnapPoint | undefined { |
| return this.inner.getSnapPoint(targetTime, thresholdPx, timescale); |
| } |
| getDataset() { |
| return this.initialized ? this.inner.getDataset() : undefined; |
| } |
| async getSelectionDetails(id: number) { |
| await this.initialize(); |
| return this.inner.getSelectionDetails(id); |
| } |
| detailsPanel(selection: TrackEventSelection) { |
| return this.initialized ? this.inner.detailsPanel(selection) : undefined; |
| } |
| |
| private sampleConstraint(): string { |
| const {config} = this; |
| const source = sqlValueToSqliteString(config.source); |
| const parts = [`ss.source = ${source}`, `tc.utid = ${config.utid}`]; |
| if (config.sessionId === null) { |
| parts.push('ss.session_id is null'); |
| } else if (config.sessionId !== undefined) { |
| parts.push(`ss.session_id = ${config.sessionId}`); |
| } |
| return parts.join(' and '); |
| } |
| |
| private async prepare(): Promise<void> { |
| const {trace, tableName} = this; |
| const engine = trace.engine; |
| const constraint = this.sampleConstraint(); |
| await engine.query('include perfetto module callstacks.stack_profile;'); |
| await engine.query('include perfetto module std.trees.table_conversion;'); |
| await engine.query('include perfetto module std.stack_sample.flamechart;'); |
| await engine.query('include perfetto module std.stack_sample.mapping;'); |
| await engine.query(` |
| create perfetto table ${tableName} as |
| select row_number() over (order by ts, depth) as id, * |
| from ( |
| select |
| r.ts, |
| r.dur, |
| r.depth as depth, |
| iif(f.name = '', 'unknown', f.name) as name, |
| r.sample_count as sampleCount, |
| coalesce(mp.category, 3) as category, |
| coalesce(mp.name, '') as mappingName, |
| r.id as frameId |
| from _stack_sample_flamechart_runs!( |
| _tree_from_table!( |
| (select id, parent_id, name from _callstack_spc_forest), |
| (name) |
| ), |
| ( |
| select ss.ts, fl.id as leaf_id |
| from stack_sample ss |
| join stack_sample_task_context tc on tc.id = ss.task_context_id |
| join _callstack_spc_forest fl |
| on fl.callsite_id = ss.callsite_id |
| and fl.is_leaf_function_in_callsite_frame |
| where ${constraint} |
| order by ss.ts |
| ) |
| ) as r |
| join _callstack_spc_forest as f on f.id = r.id |
| left join _stack_sample_mapping_classification mp on mp.id = f.mapping_id |
| ) |
| `); |
| } |
| } |
| |
| export function createCallstackTrack( |
| trace: Trace, |
| uri: string, |
| config: CallstackTrackConfig, |
| ): Track { |
| const renderer = new CallstackTrack(trace, uri, config); |
| const track: Track = { |
| uri, |
| description: |
| 'This track shows sampled callstacks. Each row is a stack depth, and ' + |
| 'each block represents a frame observed in consecutive samples. ' + |
| 'Unlike instrumented slices, block boundaries and durations are inferred ' + |
| 'from samples, not recorded function entry and exit times. Calls between ' + |
| 'samples may be missed. Frames still open at the last sample extend ' + |
| 'to trace end as incomplete.', |
| tags: { |
| // Preserve stack-sample area selection for the child as well. |
| // Automatic sample-track discovery excludes the flamechart kind. |
| kinds: [STACK_SAMPLE_TRACK_KIND, STACK_SAMPLE_FLAMECHART_TRACK_KIND], |
| utid: config.utid, |
| upid: config.upid, |
| stackSampleSource: config.source, |
| ...(config.sessionId !== undefined && |
| config.sessionId !== null && { |
| stackSampleSessionId: config.sessionId, |
| }), |
| ...(config.sessionId === null && {stackSampleNullSession: true}), |
| }, |
| renderer, |
| }; |
| return track; |
| } |