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