• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2025 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
15import {NUM, STR} from '../../trace_processor/query_result';
16import {Trace} from '../../public/trace';
17import {PerfettoPlugin} from '../../public/plugin';
18import {TrackNode, Workspace} from '../../public/workspace';
19
20const TRACKS_TO_COPY: string[] = [
21  'L<',
22  'UI Events',
23  'IKeyguardService',
24  'Transition:',
25];
26const SYSTEM_UI_PROCESS: string = 'com.android.systemui';
27
28// Plugin that creates an opinionated Workspace specific for SysUI
29export default class implements PerfettoPlugin {
30  static readonly id = 'dev.perfetto.SysUIWorkspace';
31
32  async onTraceLoad(ctx: Trace): Promise<void> {
33    ctx.commands.registerCommand({
34      id: 'dev.perfetto.SysUIWorkspace#CreateSysUIWorkspace',
35      name: 'Create System UI workspace',
36      callback: () =>
37        ProcessWorkspaceFactory.create(
38          ctx,
39          SYSTEM_UI_PROCESS,
40          'System UI',
41          TRACKS_TO_COPY,
42        ),
43    });
44  }
45}
46
47/**
48 *  Creates a workspace for a process with the following tracks:
49 *  - timelines
50 *  - main thread and render thread
51 *  - All other ui threads in a group
52 *  - List of tracks having name manually provided to this class constructor
53 *  - groups tracks having the "/(?<groupName>.*)##(?<trackName>.*)/" format
54 *    (e.g. "notifications##visible" will create a "visible" track inside the
55 *    "notification" group)
56 *
57 *  This is useful to reduce the clutter when focusing on a single process, and
58 *  organizing tracks related to the same area in groups.
59 */
60class ProcessWorkspaceFactory {
61  private readonly ws: Workspace;
62  private readonly processTracks: TrackNode[];
63
64  constructor(
65    private readonly trace: Trace,
66    private readonly process: ProcessIdentifier,
67    private readonly workspaceName: string,
68    private readonly topLevelTracksToPin: string[] = [],
69  ) {
70    // We're going to iterate them often: let's filter the process ones.
71    this.processTracks = this.findProcessTracks();
72    this.ws = this.trace.workspaces.createEmptyWorkspace(this.workspaceName);
73  }
74
75  /**
76   * Creates a new workspace for a specific process in a trace.
77   *
78   * No workspace is created if it was there already.
79   * This is expected to be called from the default workspace.
80   *
81   * @param trace
82   * @param packageName Name of the Android package to create the workspace for.
83   * @param workspaceName Desired name for the new workspace.
84   * @param tracksToCopy - An optional list of track names to be added to
85   *                              the new workspace
86   * @returns A `Promise` that resolves when the workspace has been created.
87   */
88  public static async create(
89    trace: Trace,
90    packageName: string,
91    workspaceName: string,
92    tracksToCopy: string[] = [],
93  ) {
94    const exists = trace.workspaces.all.find(
95      (ws) => ws.title === workspaceName,
96    );
97    if (exists) return;
98
99    const process = await getProcessInfo(trace, packageName);
100    if (!process) return;
101    const factory = new ProcessWorkspaceFactory(
102      trace,
103      process,
104      workspaceName,
105      tracksToCopy,
106    );
107    await factory.createWorkspace();
108  }
109
110  private async createWorkspace() {
111    this.pinTracksContaining('Actual Timeline', 'Expected Timeline');
112    this.pinMainThread();
113    this.pinFirstRenderThread();
114    await this.pinUiThreads();
115    this.topLevelTracksToPin.forEach((s) =>
116      this.pinTracksContainingInGroupIfNeeded(s),
117    );
118    this.createGroups();
119    this.trace.workspaces.switchWorkspace(this.ws);
120  }
121
122  private findProcessTracks(): TrackNode[] {
123    return this.trace.workspace.flatTracks.filter((track) => {
124      if (!track.uri) return false;
125      const descriptor = this.trace.tracks.getTrack(track.uri);
126      return descriptor?.tags?.upid === this.process.upid;
127    });
128  }
129
130  private pinTracksContaining(...args: string[]) {
131    args.forEach((s) => this.pinTrackContaining(s));
132  }
133
134  private pinTrackContaining(titleSubstring: string) {
135    this.getTracksContaining(titleSubstring).forEach((track) =>
136      this.ws.addChildLast(track.clone()),
137    );
138  }
139
140  private pinTracksContainingInGroupIfNeeded(
141    titleSubstring: string,
142    minSizeToGroup: number = 2,
143  ) {
144    const tracks = this.getTracksContaining(titleSubstring);
145    if (tracks.length == 0) return;
146    if (tracks.length >= minSizeToGroup) {
147      const newGroup = new TrackNode({title: titleSubstring, isSummary: true});
148      this.ws.addChildLast(newGroup);
149      tracks.forEach((track) => newGroup.addChildLast(track.clone()));
150    } else {
151      tracks.forEach((track) => this.ws.addChildLast(track.clone()));
152    }
153  }
154
155  private getTracksContaining(titleSubstring: string): TrackNode[] {
156    return this.processTracks.filter((track) =>
157      track.title.includes(titleSubstring),
158    );
159  }
160
161  private pinMainThread() {
162    const tracks = this.processTracks.filter((track) => {
163      return this.getTrackUtid(track) == this.process.upid;
164    });
165    tracks.forEach((track) => this.ws.addChildLast(track.clone()));
166  }
167
168  // In traces there might be many short-lived threads called "render thread"
169  // used to allocate stuff. We don't care about them, but only of the first one
170  // (that has lower thread id)
171  private pinFirstRenderThread() {
172    const tracks = this.getTracksContaining('RenderThread');
173    const utids = tracks
174      .map((t) => this.getTrackUtid(t))
175      .filter((utid): utid is number => utid !== undefined);
176    const minUtid = Math.min(...utids);
177
178    const toPin = tracks.filter((track) => this.getTrackUtid(track) == minUtid);
179    toPin.forEach((track) => this.ws.addChildLast(track.clone()));
180  }
181
182  private async pinUiThreads() {
183    const result = await this.trace.engine.query(`
184      INCLUDE PERFETTO MODULE slices.with_context;
185      SELECT DISTINCT utid FROM thread_or_process_slice
186      WHERE upid = ${this.process.upid}
187       AND upid != utid -- main thread excluded
188       AND name GLOB "Choreographer#doFrame*"
189    `);
190    if (result.numRows() === 0) {
191      return;
192    }
193    const uiThreadUtidsSet = new Set<number>();
194    const it = result.iter({utid: NUM});
195    for (; it.valid(); it.next()) {
196      uiThreadUtidsSet.add(it.utid);
197    }
198
199    const toPin = this.processTracks.filter((track) => {
200      const utid = this.getTrackUtid(track);
201      return utid != undefined && uiThreadUtidsSet.has(utid);
202    });
203    toPin.sort((a, b) => {
204      return a.title.localeCompare(b.title);
205    });
206    const uiThreadTrack = new TrackNode({title: 'UI Threads', isSummary: true});
207    this.ws.addChildLast(uiThreadTrack);
208    toPin.forEach((track) => uiThreadTrack.addChildLast(track.clone()));
209  }
210
211  private getTrackUtid(node: TrackNode): number | undefined {
212    return this.trace.tracks.getTrack(node.uri!)?.tags?.utid;
213  }
214
215  private createGroups() {
216    const groupRegex = /(?<groupName>.*)##(?<trackName>.*)/;
217    const trackGroups = new Map<string, TrackNode>();
218
219    this.processTracks.forEach((track) => {
220      const match = track.title.match(groupRegex);
221      if (!match?.groups) return;
222
223      const {groupName, trackName} = match.groups;
224
225      const newTrack = track.clone();
226      newTrack.title = trackName;
227
228      if (!trackGroups.has(groupName)) {
229        const newGroup = new TrackNode({title: groupName, isSummary: true});
230        this.ws.addChildLast(newGroup);
231        trackGroups.set(groupName, newGroup);
232      }
233      trackGroups.get(groupName)!.addChildLast(newTrack);
234    });
235  }
236}
237
238type ProcessIdentifier = {
239  upid: number;
240  name: string;
241};
242
243async function getProcessInfo(
244  ctx: Trace,
245  processName: string,
246): Promise<ProcessIdentifier | undefined> {
247  const result = await ctx.engine.query(`
248      INCLUDE PERFETTO MODULE android.process_metadata;
249      select
250        _process_available_info_summary.upid,
251        process.name
252      from _process_available_info_summary
253      join process using(upid)
254      where process.name = '${processName}';
255    `);
256  if (result.numRows() === 0) {
257    return undefined;
258  }
259  return result.firstRow({
260    upid: NUM,
261    name: STR,
262  });
263}
264