blob: c06aec42a10d578cde2d6885c221b3509e7b8f05 [file] [edit]
// Copyright (C) 2025 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 {assertUnreachable} from '../../base/assert';
import {sqliteString} from '../../base/string_utils';
import {uuidv4} from '../../base/uuid';
import {SliceTrack} from './slice_track';
import {CounterTrack} from './counter_track';
import type {Trace} from '../../public/trace';
import {TrackNode} from '../../public/workspace';
import {SourceDataset} from '../../trace_processor/dataset';
import {
type SqlValue,
LONG,
NUM_NULL,
STR,
type QueryResult,
NUM,
} from '../../trace_processor/query_result';
import type {TrackRenderer} from '../../public/track';
import type {TrackEventDetailsPanel} from '../../public/details_panel';
/**
* Aggregation types for the BreakdownTracks.
* These aggregations will be displayed in a set of counter tracks.
*/
export enum BreakdownTrackAggType {
COUNT = 'COUNT',
MAX = 'MAX',
SUM = 'SUM',
}
interface BreakdownTrackSqlInfo {
/**
* Table columns of interest.Tracks are always filtered/evaluated
* based on the ordering within this array.
*/
columns: string[];
/**
* Table name for the data to be queried from.
*/
tableName: string;
/**
* This is the value that should be displayed in the
* aggregation counter track. Required for MAX / SUM aggregation; ignored for
* COUNT.
*/
valueCol?: string;
/**
* Timestamp column name. Usually this is `ts` in a table but
* it could also be something else such as `client_ts`, etc.
*/
tsCol?: string;
/**
* Duration column name. Usually this is `dur` in a table
* but it could also be something like `client_dur`, etc.
*/
durCol?: string;
/**
* Optional join values for values tables that should
* be joined to the one specified in `tableName`.
*
* Usage:
* pivots: {
columns: [`(aidl_name || ' blocked on ' || reason)`],
tableName: 'android_binder_txns',
tsCol: 'ts',
durCol: 'dur',
joins: [
{
joinTableName: 'android_binder_client_server_breakdown',
joinColumns: ['binder_txn_id'],
},
],
},
*/
joins?: BreakdownTrackJoins[]; // To be used for joining with other tables
}
interface BreakdownTrackJoins {
joinTableName: string;
joinColumns: string[];
}
export interface BreakdownTrackProps {
trace: Trace;
/**
* This title will display only at the top most (root) track.
* Best practice is to include the aggregation type.
*
* Ex: Max RSS Usage or Binder Txn Counts
*/
trackTitle: string;
/**
* This is the aggregation type used for the counter tracks
* (described below). For example: COUNT, SUM, MAX, etc.
*/
aggregationType: BreakdownTrackAggType;
/**
* Specified aggregation values are then used to populate
* a set of counter tracks where values for each counter track
* will be filtered to the values specified in the columns array.
*/
aggregation: BreakdownTrackSqlInfo;
/**
* The Perfetto modules that should be included in order
* to query the specified tables in the aggregation, slice, or
* pivot tracks.
*/
modules?: string[];
/**
* Data that should be displayed as slices after the aggregation
* tracks are shown. The aggregation tracks will always display first
* and data specified in this property will be displayed as a child
* slice track.
*/
slice?: BreakdownTrackSqlInfo;
/**
* The column name that uniquely identifies a slice in the slice table.
*/
sliceIdColumn?: string;
/**
* Data to be pivoted. This is simlar to the debug pivot tracks where
* the values of each column will be displayed in a separate track with
* the corresponding slices.
*/
pivots?: BreakdownTrackSqlInfo;
/**
* Whether to sort the tracks based on their maximum value.
*/
sortTracks?: boolean;
/**
* Optional custom details panel for the slice tracks.
*/
detailsPanel?: (trace: Trace) => TrackEventDetailsPanel;
/**
* Optional description for the root track.
*/
description?: string;
}
interface Filter {
columnName: string;
value?: string;
}
/**
* BreakdownTracks builds a hierarchy of tracks from a single interval table:
*
* aggregation columns -> counter tracks (overlap COUNT / MAX / SUM)
* slice columns -> slice tracks
* pivot columns -> slice tracks (sourced via an optional join)
*
* All counters are driven by ONE shared `interval_self_intersect` segments
* table built once per instance, so the expensive overlap computation is paid
* a single time instead of once per node. The per-node counter query is then a
* cheap GROUP BY over those segments, and the counter renderers are lazy
* (CounterTrack.create), so the work scales with what the user views rather
* than with the size of the hierarchy.
*/
export class BreakdownTracks {
private readonly props: BreakdownTrackProps;
private readonly uri: string;
// Precomputed SQL fragments, assembled once in the constructor: the
// `INCLUDE PERFETTO MODULE` preamble, and the LEFT JOIN clauses attaching the
// slice / pivot tables to the projection (each empty when not configured).
private readonly modulesClause: string;
private readonly sliceJoinClause: string;
private readonly pivotJoinClause: string;
// The three tables built once per BreakdownTracks instance (see buildTables);
// each name gets a UUID suffix so independent grids — e.g. the binder server
// and client trees — never collide on a table name:
// intervals one row per source interval (no joins).
// segments interval_self_intersect output: the atomic overlap segments.
// projected source rows + slice/pivot joins (may fan out 1:N) + ts/dur.
private readonly intervalsTableName: string;
private readonly segmentsTableName: string;
private readonly projectedTableName: string;
// The breakdown / slice / pivot columns the caller supplies are arbitrary SQL
// *expressions* (e.g. `IFNULL(interface, 'unknown')`). We evaluate each once
// when building the tables and alias it to a stable plain column name, so
// every later GROUP BY / filter is a cheap raw-column reference (k0 = 'x')
// rather than re-evaluating the whole expression each time. k* are the
// breakdown (aggregation) columns, s* the slice columns, p* the pivot columns.
private readonly aggColNames: readonly string[]; // k0..kN
private readonly sliceColNames: readonly string[]; // s0..sM
private readonly pivotColNames: readonly string[]; // p0..pP
constructor(props: BreakdownTrackProps) {
if (
props.aggregationType !== BreakdownTrackAggType.COUNT &&
props.aggregation.valueCol === undefined
) {
throw new Error(
`BreakdownTracks: aggregation.valueCol is required for ` +
`${props.aggregationType} aggregation`,
);
}
this.props = props;
this.uri = `/breakdown_tracks_${props.aggregation.tableName}`;
const unique = uuidv4().replace(/-/g, '_');
this.intervalsTableName = `_breakdown_intervals_${unique}`;
this.segmentsTableName = `_breakdown_segments_${unique}`;
this.projectedTableName = `_breakdown_projected_${unique}`;
this.aggColNames = props.aggregation.columns.map((_, i) => `k${i}`);
this.sliceColNames = (props.slice?.columns ?? []).map((_, i) => `s${i}`);
this.pivotColNames = (props.pivots?.columns ?? []).map((_, i) => `p${i}`);
this.modulesClause = [
...(props.modules ?? []).map((m) => `INCLUDE PERFETTO MODULE ${m};`),
'INCLUDE PERFETTO MODULE intervals.intersect;',
].join('\n');
this.sliceJoinClause = props.slice?.joins
? this.getJoinClause(props.slice.joins)
: '';
this.pivotJoinClause = props.pivots?.joins
? this.getJoinClause(props.pivots.joins)
: '';
}
// The aggregate evaluated over each atomic segment's active (non-end-marker)
// intervals. COUNT counts them; MAX/SUM reduce the denormalized agg_value.
// End-markers map to NULL / 0 so they contribute nothing and the counter
// naturally drops where intervals end. MAX maps end-markers to NULL (rather
// than a sentinel like 0) precisely because SQL aggregates skip NULL: a
// numeric sentinel could wrongly win the MAX when every active value is lower
// (e.g. all negative).
private aggValueExpr(): string {
switch (this.props.aggregationType) {
case BreakdownTrackAggType.MAX:
return 'MAX(IIF(interval_ends_at_ts = FALSE, agg_value, NULL))';
case BreakdownTrackAggType.SUM:
return 'SUM(IIF(interval_ends_at_ts = FALSE, agg_value, 0))';
case BreakdownTrackAggType.COUNT:
return 'SUM(IIF(interval_ends_at_ts = FALSE, 1, 0))';
}
assertUnreachable(this.props.aggregationType);
}
private getAggregationQuery(filtersClause: string): string {
// One row per atomic segment, aggregating that segment's active intervals.
// Filters are raw equality on the pre-projected k* columns.
return `
SELECT MIN(ts) AS ts, ${this.aggValueExpr()} AS value
FROM ${this.segmentsTableName}
${filtersClause}
GROUP BY group_id
ORDER BY ts
`;
}
private getJoinClause(joins: BreakdownTrackJoins[]): string {
// LEFT join so source rows with no join match (e.g. a binder txn with no
// breakdown row) are kept: the counter (sourced from the un-joined
// intervals table) and the slice tracks must still see them. A pivot track
// filters on its own column value, so the NULL-valued unmatched rows are
// naturally excluded from pivots.
return joins
.map(
({joinTableName, joinColumns}) =>
`LEFT JOIN ${joinTableName} USING(${joinColumns.join(', ')})`,
)
.join('\n');
}
// Projects a column group's (slice or pivot) ts/dur plus its column
// expressions into stable names (e.g. slice_ts, slice_dur, s0..). Empty when
// the column group is absent.
private projectColumnGroup(
info: BreakdownTrackSqlInfo | undefined,
tsAlias: string,
colPrefix: string,
): string[] {
if (info === undefined) return [];
return [
`${info.tsCol ?? 'ts'} AS ${tsAlias}_ts`,
`${info.durCol ?? 'dur'} AS ${tsAlias}_dur`,
...info.columns.map((col, i) => `${col} AS ${colPrefix}${i}`),
];
}
// Builds the three tables that drive every track:
// _breakdown_intervals: one row per source interval (id, ts, dur, the
// breakdown columns, [agg_value]) from the aggregation table ONLY — no
// slice/pivot joins, so the overlap count is never inflated by a 1:N join.
// _breakdown_segments: per-(atomic segment, active interval) rows from
// interval_self_intersect over the intervals, with the breakdown columns
// and agg_value inline so per-node counter queries are a plain GROUP BY
// with no JOIN back.
// _breakdown_projected: one row per source row WITH the slice/pivot joins
// applied (so it may fan out 1:N) plus the slice/pivot ts/dur. Drives the
// hierarchy enumeration and the slice/pivot tracks.
private async buildTables(): Promise<void> {
const agg = this.props.aggregation;
const aggTs = agg.tsCol ?? 'ts';
const aggDur = agg.durCol ?? 'dur';
const idExpr = this.props.sliceIdColumn ?? 'ROW_NUMBER() OVER ()';
const hasValue = agg.valueCol !== undefined;
const intervalCols = [
`${idExpr} AS id`,
`${aggTs} AS ts`,
`${aggDur} AS dur`,
...agg.columns.map((col, i) => `${col} AS k${i}`),
...(hasValue ? [`${agg.valueCol} AS agg_value`] : []),
].join(', ');
const denormCols = [
...this.aggColNames.map((n) => `i.${n}`),
...(hasValue ? ['i.agg_value'] : []),
].join(', ');
// Drop intervals that can't carry a count: a NULL id (e.g. binder_reply_id
// on oneway transactions) or a negative dur (dur = -1 marks an incomplete
// slice). ROW_NUMBER ids are never NULL, so the id check is only emitted
// when a real id column was supplied.
const idCheck = this.props.sliceIdColumn
? `${this.props.sliceIdColumn} IS NOT NULL AND `
: '';
// The projection applies the slice/pivot joins, so a plain id column would
// be ambiguous if a join table shares the name — e.g. the binder breakdown
// tables also carry binder_reply_id, which the client perspective uses as
// its id. Qualify it with the base table. The ROW_NUMBER fallback needs no
// qualification and can't be ambiguous. (The intervals table has no joins,
// so it keeps the unqualified idExpr.)
const projectedIdExpr = this.props.sliceIdColumn
? `${agg.tableName}.${this.props.sliceIdColumn}`
: 'ROW_NUMBER() OVER ()';
const projectedCols = [
`${projectedIdExpr} AS id`,
...agg.columns.map((col, i) => `${col} AS k${i}`),
...this.projectColumnGroup(this.props.slice, 'slice', 's'),
...this.projectColumnGroup(this.props.pivots, 'pivot', 'p'),
].join(', ');
await this.props.trace.engine.query(`
CREATE PERFETTO TABLE ${this.intervalsTableName} AS
SELECT ${intervalCols}
FROM ${agg.tableName}
WHERE ${idCheck}${aggTs} IS NOT NULL AND ${aggDur} >= 0;
CREATE PERFETTO TABLE ${this.segmentsTableName} AS
SELECT iss.ts, iss.group_id, iss.interval_ends_at_ts, ${denormCols}
FROM interval_self_intersect!((
SELECT id, ts, dur FROM ${this.intervalsTableName}
)) iss
JOIN ${this.intervalsTableName} i USING(id);
CREATE PERFETTO TABLE ${this.projectedTableName} AS
SELECT ${projectedCols}
FROM ${agg.tableName}
${this.sliceJoinClause}
${this.pivotJoinClause};
`);
}
async createTracks(): Promise<TrackNode> {
await this.props.trace.engine.query(this.modulesClause);
await this.buildTables();
const rootTrackNode = await this.createCounterTrackNode(
this.props.trackTitle,
[],
);
// Enumerate the distinct (k*, s*, p*) tuples in one query and build the tree
// in memory. Reads from the (possibly fanned) projection so distinct pivot
// values are enumerated; the GROUP BY collapses the fan-out.
const allColNames = [
...this.aggColNames,
...this.sliceColNames,
...this.pivotColNames,
];
const res = await this.props.trace.engine.query(`
SELECT ${allColNames.join(', ')}
FROM ${this.projectedTableName}
GROUP BY ${allColNames.join(', ')}
`);
await this.createBreakdownHierarchy(
rootTrackNode,
res,
this.aggColNames,
this.sliceColNames,
this.pivotColNames,
);
return rootTrackNode;
}
private async createBreakdownHierarchy(
rootNode: TrackNode,
queryResult: QueryResult,
aggColumns: ReadonlyArray<string>,
sliceColumns: ReadonlyArray<string>,
pivotColumns: ReadonlyArray<string>,
): Promise<void> {
const cache: Map<string, Map<string, TrackNode>> = new Map();
if (rootNode.uri) {
cache.set(rootNode.uri, new Map<string, TrackNode>());
}
const iter = queryResult.iter({});
for (; iter.valid(); iter.next()) {
let state: {currentNode: TrackNode; currentFilters: Filter[]} = {
currentNode: rootNode,
currentFilters: [],
};
state = await this.processRowForColumns(
iter,
state,
aggColumns,
cache,
(name, filters) => this.createCounterTrackNode(name, filters),
);
state = await this.processRowForColumns(
iter,
state,
sliceColumns,
cache,
(name, filters, colIndex) =>
this.createSliceTrackNode(name, filters, colIndex, false),
);
state = await this.processRowForColumns(
iter,
state,
pivotColumns,
cache,
(name, filters, colIndex) =>
this.createSliceTrackNode(name, filters, colIndex, true),
);
}
}
private async processRowForColumns(
iter: {get: (col: string) => SqlValue},
{
currentNode,
currentFilters,
}: {currentNode: TrackNode; currentFilters: Filter[]},
columns: ReadonlyArray<string>,
cache: Map<string, Map<string, TrackNode>>,
createNode: (
name: string,
filters: Filter[],
colIndex: number,
) => Promise<TrackNode>,
): Promise<{currentNode: TrackNode; currentFilters: Filter[]}> {
for (let colIndex = 0; colIndex < columns.length; colIndex++) {
let children = cache.get(currentNode.uri!);
if (!children) {
children = new Map<string, TrackNode>();
cache.set(currentNode.uri!, children);
}
const colName = columns[colIndex];
const colResRaw = iter.get(colName);
const childName = colResRaw === null ? 'NULL' : colResRaw.toString();
currentFilters.push({
columnName: colName,
value: childName,
});
let childNode = children.get(childName);
if (!childNode) {
childNode = await createNode(childName, currentFilters, colIndex);
currentNode.addChildInOrder(childNode);
children.set(childName, childNode);
}
currentNode = childNode;
}
return {currentNode, currentFilters};
}
private async createSliceTrackNode(
title: string,
newFilters: Filter[],
columnIndex: number,
isPivot: boolean,
): Promise<TrackNode> {
return this.createTrackNode(title, newFilters, (uri, filtersClause) => {
// Pivot tracks render the joined sub-intervals (one slice per joined row)
// on the pivot's own timeline; each fanned row is a distinct slice, so a
// synthetic row id is used. Slice tracks render the source intervals once,
// collapsing the pivot join's fan-out in the shared projection. When a real
// sliceIdColumn is supplied the fan-out collapses via DISTINCT keyed on
// that id (kept so the details panel can resolve the slice); otherwise the
// id is a per-row ROW_NUMBER that would defeat DISTINCT, so dedupe on the
// displayed columns and assign the synthetic id afterwards.
const sliceCol = this.sliceColNames[columnIndex];
const src = isPivot
? `
SELECT ROW_NUMBER() OVER () AS id, pivot_ts AS ts, pivot_dur AS dur,
${this.pivotColNames[columnIndex]} AS name
FROM ${this.projectedTableName}
${filtersClause}
`
: this.props.sliceIdColumn !== undefined
? `
SELECT DISTINCT id, slice_ts AS ts, slice_dur AS dur,
${sliceCol} AS name
FROM ${this.projectedTableName}
${filtersClause}
`
: `
SELECT ROW_NUMBER() OVER () AS id, ts, dur, name
FROM (
SELECT DISTINCT slice_ts AS ts, slice_dur AS dur,
${sliceCol} AS name
FROM ${this.projectedTableName}
${filtersClause}
)
`;
return SliceTrack.create({
trace: this.props.trace,
uri,
dataset: new SourceDataset({
schema: {
id: NUM,
ts: LONG,
dur: LONG,
name: STR,
},
src,
}),
detailsPanel: this.props.detailsPanel
? () => this.props.detailsPanel!(this.props.trace)
: undefined,
});
});
}
private async getCounterTrackSortOrder(
filtersClause: string,
): Promise<number> {
const aggregationQuery = this.getAggregationQuery(filtersClause);
const result = await this.props.trace.engine.query(`
SELECT MAX(value) as max_value FROM (${aggregationQuery})
`);
const maxValue = result.firstRow({max_value: NUM_NULL}).max_value;
return maxValue === null ? 0 : maxValue;
}
private async createCounterTrackNode(
name: string,
newFilters: Filter[],
): Promise<TrackNode> {
return this.createTrackNode(
name,
newFilters,
(uri, filtersClause) =>
// Lazy: getAggregationQuery is a cheap GROUP BY over the shared segments
// table, and CounterTrack's useData fires it only on render — nothing is
// materialized per node at trace load.
CounterTrack.create({
trace: this.props.trace,
uri,
sqlSource: `
SELECT ts, value
FROM (${this.getAggregationQuery(filtersClause)})
`,
}),
(filtersClause) => this.getCounterTrackSortOrder(filtersClause),
);
}
private async createTrackNode(
name: string,
filters: Filter[],
createTrack: (uri: string, filtersClause: string) => TrackRenderer,
getSortOrder?: (filterClause: string) => Promise<number>,
): Promise<TrackNode> {
const filtersClause =
filters.length > 0 ? `\nWHERE ${buildFilterSqlClause(filters)}` : '';
const uri = `${this.uri}_${uuidv4()}`;
const renderer = createTrack(uri, filtersClause);
this.props.trace.tracks.registerTrack({
uri,
renderer,
...(filters.length === 0 &&
this.props.description && {description: this.props.description}),
});
let sortOrder: number | undefined;
if (this.props.sortTracks) {
sortOrder = await getSortOrder?.(filtersClause);
}
return new TrackNode({
name,
uri,
sortOrder: sortOrder !== undefined ? -sortOrder : undefined,
});
}
}
function buildFilterSqlClause(filters: Filter[]) {
return filters.map((filter) => `${filterToSql(filter)}`).join(' AND ');
}
function filterToSql(filter: Filter) {
const {columnName, value} = filter;
const filterValue: SqlValue | undefined = toSqlValue(value);
return `${columnName} = ${filterValue === undefined ? '' : filterValue}`;
}
function toSqlValue(input: string | undefined): string | number | bigint {
if (input === undefined) {
return '';
}
const num = Number(input);
if (!isNaN(num) && String(num) == input.trim()) {
return num;
}
try {
return BigInt(input);
} catch {
return sqliteString(input);
}
}