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 {sqliteString} from '../../base/string_utils'; 16import {uuidv4} from '../../base/uuid'; 17import {DatasetSliceTrack} from '../../components/tracks/dataset_slice_track'; 18import { 19 createQueryCounterTrack, 20 SqlTableCounterTrack, 21} from '../../components/tracks/query_counter_track'; 22import {createQuerySliceTrack} from '../../components/tracks/query_slice_track'; 23import {Trace} from '../../public/trace'; 24import {TrackNode} from '../../public/workspace'; 25import {ColumnType, NUM_NULL} from '../../trace_processor/query_result'; 26 27/** 28 * Aggregation types for the BreakdownTracks. 29 * These aggregations will be displayed in a set of counter tracks. 30 */ 31export enum BreakdownTrackAggType { 32 COUNT = 'COUNT', 33 MAX = 'MAX', 34 SUM = 'SUM', 35} 36 37/** 38 * Breakdown Tracks will always be shown first as 39 * a counter track with the aggregation. 40 * 41 * Slice and pivot tracks will be slice tracks. 42 */ 43enum BreakdownTrackType { 44 AGGREGATION, 45 SLICE, 46 PIVOT, 47} 48 49interface BreakdownTrackSqlInfo { 50 /** 51 * Table columns of interest.Tracks are always filtered/evaluated 52 * based on the ordering within this array. 53 */ 54 columns: string[]; 55 /** 56 * Table name for the data to be queried from. 57 */ 58 tableName: string; 59 /** 60 * This is the value that should be displayed in the 61 * aggregation counter track. 62 */ 63 valueCol?: string; 64 /** 65 * Timestamp column name. Usually this is `ts` in a table but 66 * it could also be something else such as `client_ts`, etc. 67 */ 68 tsCol?: string; 69 /** 70 * Duration column name. Usually this is `dur` in a table 71 * but it could also be something like `client_dur`, etc. 72 */ 73 durCol?: string; 74 /** 75 * Optional join values for values tables that should 76 * be joined to the one specified in `tableName`. 77 * 78 * Usage: 79 * pivots: { 80 columns: [`(aidl_name || ' blocked on ' || reason)`], 81 tableName: 'android_binder_txns', 82 tsCol: 'ts', 83 durCol: 'dur', 84 joins: [ 85 { 86 joinTableName: 'android_binder_client_server_breakdown', 87 joinColumns: ['binder_txn_id'], 88 }, 89 ], 90 }, 91 */ 92 joins?: BreakdownTrackJoins[]; // To be used for joining with other tables 93} 94 95interface BreakdownTrackJoins { 96 joinTableName: string; 97 joinColumns: string[]; 98} 99 100export interface BreakdownTrackProps { 101 trace: Trace; 102 /** 103 * This title will display only at the top most (root) track. 104 * Best practice is to include the aggregation type. 105 * 106 * Ex: Max RSS Usage or Binder Txn Counts 107 */ 108 trackTitle: string; 109 /** 110 * This is the aggregation type used for the counter tracks 111 * (described below). For example: COUNT, SUM, MAX, etc. 112 */ 113 aggregationType: BreakdownTrackAggType; 114 /** 115 * Specified aggregation values are then used to populate 116 * a set of counter tracks where values for each counter track 117 * will be filtered to the values specified in the columns array. 118 */ 119 aggregation: BreakdownTrackSqlInfo; 120 /** 121 * The Perfetto modules that should be included in order 122 * to query the specified tables in the aggregation, slice, or 123 * pivot tracks. 124 */ 125 modules?: string[]; 126 /** 127 * Data that should be displayed as slices after the aggregation 128 * tracks are shown. The aggregation tracks will always display first 129 * and data specified in this property will be displayed as a child 130 * slice track. 131 */ 132 slice?: BreakdownTrackSqlInfo; 133 /** 134 * Data to be pivoted. This is simlar to the debug pivot tracks where 135 * the values of each column will be displayed in a separate track with 136 * the corresponding slices. 137 */ 138 pivots?: BreakdownTrackSqlInfo; 139} 140 141interface Filter { 142 columnName: string; 143 value?: string; 144} 145 146export class BreakdownTracks { 147 private readonly props; 148 private uri: string; 149 private modulesClause: string; 150 private sliceJoinClause?: string; 151 private pivotJoinClause?: string; 152 153 constructor(props: BreakdownTrackProps) { 154 this.props = props; 155 this.uri = `/breakdown_tracks_${this.props.aggregation.tableName}`; 156 157 this.modulesClause = props.modules 158 ? props.modules.map((m) => `INCLUDE PERFETTO MODULE ${m};`).join('\n') 159 : ''; 160 161 if (this.props.aggregationType === BreakdownTrackAggType.COUNT) { 162 this.modulesClause += `\nINCLUDE PERFETTO MODULE intervals.overlap;`; 163 } 164 165 if (this.props.slice?.joins !== undefined) { 166 this.sliceJoinClause = this.getJoinClause(this.props.slice.joins); 167 } 168 169 if (this.props.pivots?.joins !== undefined) { 170 this.pivotJoinClause = this.getJoinClause(this.props.pivots.joins); 171 } 172 } 173 174 private getAggregationQuery(filtersClause: string) { 175 if (this.props.aggregationType === BreakdownTrackAggType.COUNT) { 176 return ` 177 intervals_overlap_count 178 !(( 179 SELECT ${this.props.aggregation.tsCol} AS ts, 180 ${this.props.aggregation.durCol} AS dur 181 FROM ${this.props.aggregation.tableName} 182 ${filtersClause} 183 ), ts, dur) 184 `; 185 } 186 187 return ` 188 SELECT 189 ${this.props.aggregation.tsCol} AS ts, 190 ${this.props.aggregation.durCol} dur, 191 ${this.props.aggregationType}(${this.props.aggregation.valueCol}) AS value 192 FROM _ui_dev_perfetto_breakdown_tracks_intervals 193 ${filtersClause} 194 GROUP BY ${this.props.aggregation.tsCol} 195 `; 196 } 197 198 // TODO: Modify this to use self_interval_intersect when it is available. 199 private getIntervals() { 200 const {tsCol, durCol, valueCol, columns, tableName} = 201 this.props.aggregation; 202 203 return ` 204 CREATE OR REPLACE PERFETTO TABLE _ui_dev_perfetto_breakdown_tracks_intervals 205 AS 206 WITH 207 x AS ( 208 SELECT overlap.*, 209 lead(${tsCol}) OVER (PARTITION BY group_name ORDER BY ${tsCol}) - ${tsCol} AS dur 210 FROM intervals_overlap_count_by_group!(${tableName}, ${tsCol}, ${durCol}, ${columns[columns.length - 1]}) overlap 211 ) 212 SELECT x.ts, x.dur, 213 ${columns.map((col) => `${tableName}.${col}`).join(', ')}, 214 ${tableName}.${valueCol} 215 FROM x 216 JOIN ${tableName} 217 ON 218 ${tableName}.${columns[columns.length - 1]} = x.group_name 219 AND _ui_dev_perfetto_breakdown_tracks_is_spans_overlapping(x.ts, x.ts + x.dur, ${tableName}.${tsCol}, ${tableName}.${tsCol} + ${tableName}.${durCol}); 220 `; 221 } 222 223 private getJoinClause(joins: BreakdownTrackJoins[]) { 224 return joins 225 .map( 226 ({joinTableName, joinColumns}) => 227 `JOIN ${joinTableName} USING(${joinColumns.join(', ')})`, 228 ) 229 .join('\n'); 230 } 231 232 async createTracks() { 233 if (this.modulesClause !== '') { 234 await this.props.trace.engine.query(this.modulesClause); 235 } 236 237 if (this.props.aggregationType !== BreakdownTrackAggType.COUNT) { 238 await this.props.trace.engine.query(` 239 CREATE OR REPLACE PERFETTO FUNCTION _ui_dev_perfetto_breakdown_tracks_is_spans_overlapping( 240 ts1 LONG, 241 ts_end1 LONG, 242 ts2 LONG, 243 ts_end2 LONG) 244 RETURNS BOOL 245 AS 246 SELECT (IIF($ts1 < $ts2, $ts2, $ts1) < IIF($ts_end1 < $ts_end2, $ts_end1, $ts_end2)); 247 248 ${this.getIntervals()} 249 `); 250 } 251 252 const rootTrackNode = await this.createCounterTrackNode( 253 `${this.props.trackTitle}`, 254 [], 255 ); 256 257 this.createBreakdownHierarchy( 258 [], 259 rootTrackNode, 260 this.props.aggregation, 261 0, 262 BreakdownTrackType.AGGREGATION, 263 ); 264 265 return rootTrackNode; 266 } 267 268 private async createBreakdownHierarchy( 269 filters: Filter[], 270 parent: TrackNode, 271 sqlInfo: BreakdownTrackSqlInfo, 272 colIndex: number, 273 trackType: BreakdownTrackType, 274 ) { 275 const {columns} = sqlInfo; 276 if (colIndex === columns.length) { 277 return; 278 } 279 280 const currColName = columns[colIndex]; 281 const joinClause = this.getTrackSpecificJoinClause(trackType); 282 283 const query = ` 284 ${this.modulesClause} 285 286 SELECT DISTINCT ${currColName} 287 FROM ${this.props.aggregation.tableName} 288 ${joinClause !== undefined ? joinClause : ''} 289 ${filters.length > 0 ? `WHERE ${buildFilterSqlClause(filters)}` : ''} 290 `; 291 292 const res = await this.props.trace.engine.query(query); 293 294 for (const iter = res.iter({}); iter.valid(); iter.next()) { 295 const colRaw = iter.get(currColName); 296 const colValue = colRaw === null ? 'NULL' : colRaw.toString(); 297 const title = colValue; 298 299 const newFilters = [ 300 ...filters, 301 { 302 columnName: currColName, 303 value: colValue, 304 }, 305 ]; 306 307 let currNode; 308 let nextTrackType = trackType; 309 let nextColIndex = colIndex + 1; 310 let nextSqlInfo = sqlInfo; 311 312 switch (trackType) { 313 case BreakdownTrackType.AGGREGATION: 314 currNode = await this.createCounterTrackNode(title, newFilters); 315 if (this.props.slice && colIndex === columns.length - 1) { 316 nextTrackType = BreakdownTrackType.SLICE; 317 nextColIndex = 0; 318 nextSqlInfo = this.props.slice; 319 } 320 break; 321 case BreakdownTrackType.SLICE: 322 currNode = await this.createSliceTrackNode( 323 title, 324 newFilters, 325 colIndex, 326 sqlInfo, 327 trackType, 328 ); 329 if (this.props.pivots && colIndex === columns.length - 1) { 330 nextTrackType = BreakdownTrackType.PIVOT; 331 nextColIndex = 0; 332 nextSqlInfo = this.props.pivots; 333 } 334 break; 335 default: 336 currNode = await this.createSliceTrackNode( 337 title, 338 newFilters, 339 colIndex, 340 sqlInfo, 341 trackType, 342 ); 343 } 344 345 parent.addChildInOrder(currNode); 346 this.createBreakdownHierarchy( 347 newFilters, 348 currNode, 349 nextSqlInfo, 350 nextColIndex, 351 nextTrackType, 352 ); 353 } 354 } 355 356 private getTrackSpecificJoinClause(trackType: BreakdownTrackType) { 357 switch (trackType) { 358 case BreakdownTrackType.SLICE: 359 return this.sliceJoinClause; 360 case BreakdownTrackType.PIVOT: 361 return this.pivotJoinClause; 362 default: 363 return undefined; 364 } 365 } 366 367 private async createSliceTrackNode( 368 title: string, 369 newFilters: Filter[], 370 columnIndex: number, 371 sqlInfo: BreakdownTrackSqlInfo, 372 trackType: BreakdownTrackType, 373 ) { 374 let joinClause = ''; 375 376 if (this.sliceJoinClause && trackType === BreakdownTrackType.SLICE) { 377 joinClause = this.sliceJoinClause; 378 } else if (this.pivotJoinClause && trackType === BreakdownTrackType.PIVOT) { 379 joinClause = this.pivotJoinClause; 380 } 381 382 return await this.createTrackNode( 383 title, 384 newFilters, 385 (uri: string, filtersClause: string) => { 386 return createQuerySliceTrack({ 387 trace: this.props.trace, 388 uri, 389 data: { 390 sqlSource: ` 391 SELECT ${sqlInfo.tsCol} AS ts, 392 ${sqlInfo.durCol} AS dur, 393 ${sqlInfo.columns[columnIndex]} AS name 394 FROM ${this.props.aggregation.tableName} 395 ${joinClause} 396 ${filtersClause} 397 `, 398 columns: ['ts', 'dur', 'name'], 399 }, 400 }); 401 }, 402 ); 403 } 404 405 private async getCounterTrackSortOrder(filtersClause: string) { 406 const aggregationQuery = this.getAggregationQuery(filtersClause); 407 const result = await this.props.trace.engine.query(` 408 SELECT MAX(value) as max_value FROM (${aggregationQuery}) 409 `); 410 const maxValue = result.firstRow({max_value: NUM_NULL}).max_value; 411 return maxValue === null ? 0 : maxValue; 412 } 413 414 private async createCounterTrackNode(title: string, newFilters: Filter[]) { 415 return await this.createTrackNode( 416 title, 417 newFilters, 418 (uri: string, filtersClause: string) => { 419 return createQueryCounterTrack({ 420 trace: this.props.trace, 421 uri, 422 data: { 423 sqlSource: ` 424 SELECT ts, value FROM 425 (${this.getAggregationQuery(filtersClause)}) 426 `, 427 }, 428 columns: { 429 ts: 'ts', 430 value: 'value', 431 }, 432 }); 433 }, 434 (filterClause) => this.getCounterTrackSortOrder(filterClause), 435 ); 436 } 437 438 private async createTrackNode( 439 title: string, 440 filters: Filter[], 441 createTrack: ( 442 uri: string, 443 filtersClause: string, 444 ) => Promise< 445 | SqlTableCounterTrack 446 | DatasetSliceTrack<{ 447 id: number; 448 ts: bigint; 449 dur: bigint; 450 name: string; 451 }> 452 >, 453 getSortOrder?: (filterClause: string) => Promise<number>, 454 ) { 455 const filtersClause = 456 filters.length > 0 ? `\nWHERE ${buildFilterSqlClause(filters)}` : ''; 457 const uri = `${this.uri}_${uuidv4()}`; 458 459 const track = await createTrack(uri, filtersClause); 460 461 this.props.trace.tracks.registerTrack({ 462 uri, 463 title, 464 track, 465 }); 466 467 const sortOrder = await getSortOrder?.(filtersClause); 468 469 return new TrackNode({ 470 title, 471 uri, 472 sortOrder: sortOrder !== undefined ? -sortOrder : undefined, 473 }); 474 } 475} 476 477function buildFilterSqlClause(filters: Filter[]) { 478 return filters.map((filter) => `${filterToSql(filter)}`).join(' AND '); 479} 480 481function filterToSql(filter: Filter) { 482 const {columnName, value} = filter; 483 484 const filterValue: ColumnType | undefined = toSqlValue(value); 485 return `${columnName} = ${filterValue === undefined ? '' : filterValue}`; 486} 487 488function toSqlValue(input: string | undefined): string | number | bigint { 489 if (input === undefined || !input.trim()) { 490 return ''; 491 } 492 493 const num = Number(input); 494 if (!isNaN(num) && String(num) == input.trim()) { 495 return num; 496 } 497 498 try { 499 return BigInt(input); 500 } catch { 501 return sqliteString(input); 502 } 503} 504