blob: e1d92341e09ca14c26ebdda8d233687aac18399a [file]
// 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 protos from '../../protos';
import {traceSummarySpecToPb} from '../../base/proto_utils_wasm';
import type {DataExplorerState} from './data_explorer';
import {
type SerializedGraph,
type SerializedNode,
deserializeState,
} from './json_handler';
import {NodeType, singleNodeOperation} from './query_node';
import type {Trace} from '../../public/trace';
import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules';
// Counter for generating unique node IDs during import.
let importNodeCounter = 0;
function nextImportNodeId(): string {
return `import_${++importNodeCounter}`;
}
// Layout constants for positioning imported graph nodes.
const NODE_GAP_Y = 80;
const METRIC_CHAIN_GAP_X = 350;
const START_X = 50;
const START_Y = 50;
// Accumulator for building up decompose results incrementally.
interface ResultAccumulator {
nodes: SerializedNode[];
rootNodeIds: string[];
layouts: Map<string, {x: number; y: number}>;
}
// Merges a child result into a parent accumulator.
function mergeResult(
target: ResultAccumulator,
source: ResultAccumulator,
): void {
target.nodes.push(...source.nodes);
target.rootNodeIds.push(...source.rootNodeIds);
for (const [id, pos] of source.layouts) {
target.layouts.set(id, pos);
}
}
// Appends a new node to a chain: wires the previous node's nextNodes,
// adds the node, sets its layout, and returns the new last node ID.
function chainNode(
acc: ResultAccumulator,
lastNodeId: string,
nodeId: string,
type: NodeType,
state: Record<string, unknown>,
x: number,
y: number,
): void {
const prevNode = acc.nodes.find((n) => n.nodeId === lastNodeId);
if (prevNode !== undefined) {
prevNode.nextNodes.push(nodeId);
}
acc.nodes.push({nodeId, type, state, nextNodes: []});
// Dockable nodes (modifications, metrics, etc.) must NOT have a layout
// position — the graph renderer docks them to their parent only when
// they have no entry in nodeLayouts.
if (!singleNodeOperation(type)) {
acc.layouts.set(nodeId, {x, y});
}
}
/**
* Parses a pbtxt string (TraceSummarySpec or single metric_template_spec)
* and converts it into a DataExplorerState with proper graph nodes.
*/
export async function parsePbtxtToState(
pbtxtText: string,
trace: Trace,
sqlModules: SqlModules,
): Promise<DataExplorerState> {
// Detect if this is a single metric_template_spec (no wrapping)
// vs a full TraceSummarySpec.
const wrappedText = detectAndWrapPbtxt(pbtxtText);
const pbResult = await traceSummarySpecToPb(wrappedText);
if (!pbResult.ok) {
throw new Error(`Failed to parse pbtxt: ${pbResult.error}`);
}
const spec = protos.TraceSummarySpec.decode(pbResult.value);
const allTemplateSpecs = spec.metricTemplateSpec ?? [];
const allMetricSpecs = spec.metricSpec ?? [];
// Build a map of shared queries from the top-level `query` repeated field.
const sharedQueries = new Map<string, protos.IPerfettoSqlStructuredQuery>();
for (const q of spec.query ?? []) {
if (q.id !== undefined && q.id !== null && q.id !== '') {
sharedQueries.set(q.id, q);
}
}
if (allTemplateSpecs.length === 0 && allMetricSpecs.length === 0) {
throw new Error(
'No metric_template_spec or metric_spec found in the pbtxt file.',
);
}
// Reset the import node counter for each import.
importNodeCounter = 0;
const acc: ResultAccumulator = {
nodes: [],
rootNodeIds: [],
layouts: new Map(),
};
// Process metric_template_specs.
for (let i = 0; i < allTemplateSpecs.length; i++) {
const chainX = START_X + i * METRIC_CHAIN_GAP_X;
mergeResult(
acc,
buildNodesFromMetricConfig(
{
query: allTemplateSpecs[i].query,
metricsState: templateSpecToMetricsState(allTemplateSpecs[i]),
},
chainX,
sharedQueries,
sqlModules,
),
);
}
// Process metric_specs (simpler, single-value metrics).
const metricSpecOffset = allTemplateSpecs.length;
for (let i = 0; i < allMetricSpecs.length; i++) {
const chainX = START_X + (metricSpecOffset + i) * METRIC_CHAIN_GAP_X;
mergeResult(
acc,
buildNodesFromMetricConfig(
{
query: allMetricSpecs[i].query,
metricsState: metricSpecToMetricsState(allMetricSpecs[i]),
},
chainX,
sharedQueries,
sqlModules,
),
);
}
// If there are multiple metrics, create a TraceSummaryNode.
const metricsNodeIds = acc.nodes
.filter((n) => n.type === NodeType.kMetrics)
.map((n) => n.nodeId);
if (metricsNodeIds.length > 1) {
const traceSummaryId = nextImportNodeId();
const maxX = Math.max(...[...acc.layouts.values()].map((p) => p.x));
const maxY = Math.max(...[...acc.layouts.values()].map((p) => p.y));
acc.nodes.push({
nodeId: traceSummaryId,
type: NodeType.kTraceSummary,
state: {
secondaryInputNodeIds: metricsNodeIds,
},
nextNodes: [],
});
// Add forward links from each Metrics node to the TraceSummary so
// that deserialization wires up nextNodes (matching JSON export).
for (const mId of metricsNodeIds) {
const metricsNode = acc.nodes.find((n) => n.nodeId === mId);
if (metricsNode !== undefined) {
metricsNode.nextNodes.push(traceSummaryId);
}
}
acc.layouts.set(traceSummaryId, {
x: maxX / 2,
y: maxY + NODE_GAP_Y,
});
acc.rootNodeIds.push(traceSummaryId);
}
const serializedGraph: SerializedGraph = {
nodes: acc.nodes,
rootNodeIds: acc.rootNodeIds,
nodeLayouts: Object.fromEntries(acc.layouts),
};
const json = JSON.stringify(serializedGraph);
return deserializeState(json, trace, sqlModules);
}
// ============================================================================
// Metric Config → Graph Nodes
// ============================================================================
interface MetricBuildConfig {
query: protos.IPerfettoSqlStructuredQuery | undefined | null;
metricsState: Record<string, unknown>;
}
interface BuildResult {
nodes: SerializedNode[];
rootNodeIds: string[];
layouts: Map<string, {x: number; y: number}>;
/** The final node ID in the chain (the MetricsNode). */
finalNodeId: string;
}
/**
* Builds graph nodes from a metric config (either template or simple spec).
* Creates: source node → [modification nodes] → MetricsNode
*/
function buildNodesFromMetricConfig(
config: MetricBuildConfig,
startX: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): BuildResult {
const acc: ResultAccumulator = {
nodes: [],
rootNodeIds: [],
layouts: new Map(),
};
let currentY = START_Y;
let lastNodeId: string;
if (config.query !== undefined && config.query !== null) {
const queryResult = decomposeStructuredQuery(
config.query,
startX,
currentY,
sharedQueries,
sqlModules,
);
mergeResult(acc, queryResult);
lastNodeId = queryResult.finalNodeId;
currentY = queryResult.nextY;
} else {
const sqlId = nextImportNodeId();
acc.nodes.push({
nodeId: sqlId,
type: NodeType.kSqlSource,
state: {sql: '-- No query specified in pbtxt'},
nextNodes: [],
});
acc.rootNodeIds.push(sqlId);
acc.layouts.set(sqlId, {x: startX, y: currentY});
lastNodeId = sqlId;
currentY += NODE_GAP_Y;
}
const metricsId = nextImportNodeId();
const metricsState = {...config.metricsState, primaryInputId: lastNodeId};
chainNode(
acc,
lastNodeId,
metricsId,
NodeType.kMetrics,
metricsState,
startX,
currentY,
);
return {...acc, finalNodeId: metricsId};
}
/**
* Builds graph nodes from a TraceMetricV2TemplateSpec.
* Creates: source node → [modification nodes] → MetricsNode
*/
export function buildNodesFromTemplateSpec(
spec: protos.ITraceMetricV2TemplateSpec,
startX: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): BuildResult {
importNodeCounter = 0;
return buildNodesFromMetricConfig(
{query: spec.query, metricsState: templateSpecToMetricsState(spec)},
startX,
sharedQueries,
sqlModules,
);
}
// ============================================================================
// Generic Multi-Source Decomposition
// ============================================================================
export interface DecomposeResult {
nodes: SerializedNode[];
rootNodeIds: string[];
layouts: Map<string, {x: number; y: number}>;
finalNodeId: string;
nextY: number;
}
// Describes one sub-query input to decompose.
interface SubQueryInput {
query: protos.IPerfettoSqlStructuredQuery | undefined | null;
xOffset: number; // relative to startX
}
// Configuration for decomposeMultiSource.
interface MultiSourceSpec {
inputs: SubQueryInput[];
nodeType: NodeType;
buildState: (
inputNodeIds: Array<string | undefined>,
) => Record<string, unknown>;
// Override the output node's X position. Defaults to startX + GAP/2.
layoutX?: number;
// If true, omit layout entry (for dockable nodes like FilterDuring).
skipLayout?: boolean;
}
// Node types that need the inputNodeIds field on SerializedNode.
function needsInputNodeIds(type: NodeType): boolean {
return type === NodeType.kIntervalIntersect || type === NodeType.kUnion;
}
/**
* Generic helper that decomposes N sub-queries and creates a single output
* node wired to all of them. Replaces all the individual decompose*
* functions that previously duplicated this pattern.
*/
function decomposeMultiSource(
spec: MultiSourceSpec,
startX: number,
startY: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): DecomposeResult {
const acc: ResultAccumulator = {
nodes: [],
rootNodeIds: [],
layouts: new Map(),
};
let maxY = startY;
const resolvedIds: Array<string | undefined> = [];
for (const input of spec.inputs) {
if (input.query !== undefined && input.query !== null) {
const r = decomposeStructuredQuery(
input.query,
startX + input.xOffset,
startY,
sharedQueries,
sqlModules,
);
mergeResult(acc, r);
resolvedIds.push(r.finalNodeId);
maxY = Math.max(maxY, r.nextY);
} else {
resolvedIds.push(undefined);
}
}
const nodeId = nextImportNodeId();
const state = spec.buildState(resolvedIds);
const definedIds = resolvedIds.filter((id): id is string => id !== undefined);
acc.nodes.push({
nodeId,
type: spec.nodeType,
state,
nextNodes: [],
...(needsInputNodeIds(spec.nodeType) ? {inputNodeIds: definedIds} : {}),
});
// Wire input nodes' nextNodes to point to this node.
for (const id of definedIds) {
const node = acc.nodes.find((n) => n.nodeId === id);
if (node !== undefined) node.nextNodes.push(nodeId);
}
if (spec.skipLayout !== true) {
const x = spec.layoutX ?? startX + METRIC_CHAIN_GAP_X / 2;
acc.layouts.set(nodeId, {x, y: maxY});
}
acc.rootNodeIds.push(nodeId);
return {...acc, finalNodeId: nodeId, nextY: maxY + NODE_GAP_Y};
}
// ============================================================================
// Structured Query Decomposition
// ============================================================================
/**
* Recursively decomposes a PerfettoSqlStructuredQuery into graph nodes.
* Returns source nodes at the root and modification nodes chained on top.
*/
export function decomposeStructuredQuery(
sq: protos.IPerfettoSqlStructuredQuery,
startX: number,
startY: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): DecomposeResult {
const acc: ResultAccumulator = {
nodes: [],
rootNodeIds: [],
layouts: new Map(),
};
let currentY = startY;
// Helper to add a leaf source node (no children to decompose).
function addSourceNode(
type: NodeType,
state: Record<string, unknown>,
): string {
const id = nextImportNodeId();
acc.nodes.push({nodeId: id, type, state, nextNodes: []});
acc.rootNodeIds.push(id);
acc.layouts.set(id, {x: startX, y: currentY});
currentY += NODE_GAP_Y;
return id;
}
// Helper to merge a recursive decomposition result and advance currentY.
function mergeSubQuery(subResult: DecomposeResult): string {
mergeResult(acc, subResult);
currentY = subResult.nextY;
return subResult.finalNodeId;
}
// Common args for multi-source decomposition.
const msArgs = [startX, startY, sharedQueries, sqlModules] as const;
// 1. Process the source.
let sourceNodeId: string;
if (sq.table !== undefined && sq.table !== null) {
const tableName = sq.table.tableName || undefined;
const moduleName = sq.table.moduleName || undefined;
// The SimpleSlices source node uses thread_or_process_slice internally.
// Map it back to a kSimpleSlices node for a faithful round-trip.
// The module may appear in moduleName or in referencedModules.
const allModules = [moduleName, ...(sq.referencedModules ?? [])].filter(
(m) => m !== undefined && m !== '',
);
if (
tableName === 'thread_or_process_slice' &&
allModules.includes('slices.with_context')
) {
sourceNodeId = addSourceNode(NodeType.kSimpleSlices, {});
} else if (
sqlModules === undefined ||
(tableName !== undefined && sqlModules.getTable(tableName) !== undefined)
) {
sourceNodeId = addSourceNode(NodeType.kTable, {
sqlTable: tableName,
moduleName,
});
} else {
const parts: string[] = [];
if (moduleName !== undefined && moduleName !== '') {
parts.push(`INCLUDE PERFETTO MODULE ${moduleName};`);
}
parts.push(`SELECT * FROM ${tableName ?? 'unknown_table'}`);
sourceNodeId = addSourceNode(NodeType.kSqlSource, {
sql: parts.join('\n'),
});
}
} else if (sq.simpleSlices !== undefined && sq.simpleSlices !== null) {
sourceNodeId = addSourceNode(NodeType.kSimpleSlices, {});
// SimpleSlices glob fields become GLOB filters.
const globFilters = buildSimpleSlicesFilters(sq.simpleSlices);
if (globFilters.length > 0) {
const filterId = nextImportNodeId();
const filterState: Record<string, unknown> = {
primaryInputId: sourceNodeId,
filters: globFilters,
};
chainNode(
acc,
sourceNodeId,
filterId,
NodeType.kFilter,
filterState,
startX,
currentY,
);
sourceNodeId = filterId;
currentY += NODE_GAP_Y;
}
} else if (sq.sql !== undefined && sq.sql !== null) {
sourceNodeId = addSourceNode(NodeType.kSqlSource, {sql: sq.sql.sql ?? ''});
} else if (sq.innerQuery !== undefined && sq.innerQuery !== null) {
sourceNodeId = mergeSubQuery(
decomposeStructuredQuery(
sq.innerQuery,
startX,
currentY,
sharedQueries,
sqlModules,
),
);
} else if (
sq.experimentalTimeRange !== undefined &&
sq.experimentalTimeRange !== null
) {
sourceNodeId = addSourceNode(NodeType.kTimeRangeSource, {
start: sq.experimentalTimeRange.ts?.toString(),
end: undefined,
isDynamic: false,
});
} else if (
sq.intervalIntersect !== undefined &&
sq.intervalIntersect !== null
) {
const ii = sq.intervalIntersect;
const secondaries = ii.intervalIntersect ?? [];
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: [
{query: ii.base, xOffset: 0},
...secondaries.map((q, i) => ({
query: q,
xOffset: (i + 1) * METRIC_CHAIN_GAP_X,
})),
],
nodeType: NodeType.kIntervalIntersect,
buildState: (ids) => ({
inputNodeIds: ids.filter((id): id is string => id !== undefined),
}),
},
...msArgs,
),
);
} else if (
sq.experimentalJoin !== undefined &&
sq.experimentalJoin !== null
) {
sourceNodeId = mergeSubQuery(decomposeJoin(sq.experimentalJoin, ...msArgs));
} else if (
sq.experimentalUnion !== undefined &&
sq.experimentalUnion !== null
) {
const queries = sq.experimentalUnion.queries ?? [];
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: queries.map((q, i) => ({
query: q,
xOffset: i * METRIC_CHAIN_GAP_X,
})),
nodeType: NodeType.kUnion,
buildState: (ids) => ({
unionNodes: ids.filter((id): id is string => id !== undefined),
selectedColumns: [],
}),
layoutX:
queries.length > 1
? startX + ((queries.length - 1) * METRIC_CHAIN_GAP_X) / 2
: startX,
},
...msArgs,
),
);
} else if (
sq.experimentalAddColumns !== undefined &&
sq.experimentalAddColumns !== null
) {
sourceNodeId = mergeSubQuery(
decomposeAddColumns(sq.experimentalAddColumns, ...msArgs),
);
} else if (
sq.experimentalFilterToIntervals !== undefined &&
sq.experimentalFilterToIntervals !== null
) {
const fti = sq.experimentalFilterToIntervals;
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: [
{query: fti.base, xOffset: 0},
{query: fti.intervals, xOffset: METRIC_CHAIN_GAP_X},
],
nodeType: NodeType.kFilterDuring,
buildState: ([primaryInputId, intervalsId]) => ({
primaryInputId,
secondaryInputNodeIds:
intervalsId !== undefined ? [intervalsId] : [],
clipToIntervals: fti.clipToIntervals ?? true,
}),
skipLayout: true,
},
...msArgs,
),
);
} else if (
sq.experimentalCreateSlices !== undefined &&
sq.experimentalCreateSlices !== null
) {
const cs = sq.experimentalCreateSlices;
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: [
{query: cs.startsQuery, xOffset: 0},
{query: cs.endsQuery, xOffset: METRIC_CHAIN_GAP_X},
],
nodeType: NodeType.kCreateSlices,
buildState: ([startsNodeId, endsNodeId]) => ({
startsNodeId,
endsNodeId,
startsTsColumn: cs.startsTsColumn ?? 'ts',
endsTsColumn: cs.endsTsColumn ?? 'ts',
}),
},
...msArgs,
),
);
} else if (
sq.experimentalCounterIntervals !== undefined &&
sq.experimentalCounterIntervals !== null
) {
const ci = sq.experimentalCounterIntervals;
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: [{query: ci.inputQuery, xOffset: 0}],
nodeType: NodeType.kCounterToIntervals,
buildState: ([primaryInputId]) => ({primaryInputId}),
layoutX: startX,
},
...msArgs,
),
);
} else if (
sq.experimentalFilterIn !== undefined &&
sq.experimentalFilterIn !== null
) {
const fi = sq.experimentalFilterIn;
sourceNodeId = mergeSubQuery(
decomposeMultiSource(
{
inputs: [
{query: fi.base, xOffset: 0},
{query: fi.matchValues, xOffset: METRIC_CHAIN_GAP_X},
],
nodeType: NodeType.kFilterIn,
buildState: ([primaryInputId, matchId]) => ({
primaryInputId,
secondaryInputNodeIds: matchId !== undefined ? [matchId] : [],
baseColumn: fi.baseColumn ?? '',
matchColumn: fi.matchColumn ?? '',
}),
},
...msArgs,
),
);
} else if (
sq.innerQueryId !== undefined &&
sq.innerQueryId !== null &&
sq.innerQueryId !== ''
) {
const sharedQuery = sharedQueries?.get(sq.innerQueryId);
if (sharedQuery !== undefined) {
sourceNodeId = mergeSubQuery(
decomposeStructuredQuery(
sharedQuery,
startX,
currentY,
sharedQueries,
sqlModules,
),
);
} else {
sourceNodeId = addSourceNode(NodeType.kSqlSource, {
sql: '-- Imported from pbtxt (unsupported source type)',
});
}
} else {
sourceNodeId = addSourceNode(NodeType.kSqlSource, {
sql: '-- Imported from pbtxt (unsupported source type)',
});
}
// 2. Apply operations as modification nodes.
let lastNodeId = sourceNodeId;
// Helper to chain a modification node onto the current chain.
function chainModification(
type: NodeType,
state: Record<string, unknown>,
): void {
const id = nextImportNodeId();
state.primaryInputId = lastNodeId;
chainNode(acc, lastNodeId, id, type, state, startX, currentY);
lastNodeId = id;
currentY += NODE_GAP_Y;
}
// Filters. Each proto filter may have multiple RHS values (IN semantics).
// Filters with single values are grouped into one AND FilterNode.
// Filters with multiple values each get their own OR FilterNode (chained
// with AND between them).
const filters = sq.filters ?? [];
if (filters.length > 0) {
const singleValueFilters: protos.PerfettoSqlStructuredQuery.IFilter[] = [];
const multiValueFilters: protos.PerfettoSqlStructuredQuery.IFilter[] = [];
for (const f of filters) {
if (protoFilterRhsCount(f) > 1) {
multiValueFilters.push(f);
} else {
singleValueFilters.push(f);
}
}
if (singleValueFilters.length > 0) {
chainModification(
NodeType.kFilter,
protoFiltersToFilterState(singleValueFilters),
);
}
for (const f of multiValueFilters) {
chainModification(NodeType.kFilter, protoFilterToExpandedOrState(f));
}
}
// Group by / aggregation.
if (sq.groupBy !== undefined && sq.groupBy !== null) {
chainModification(
NodeType.kAggregation,
protoGroupByToAggregationState(sq.groupBy),
);
}
// Select columns (modify columns).
// Skip if the select just re-lists the group_by output (redundant).
const selectCols = sq.selectColumns ?? [];
if (selectCols.length > 0 && !isSelectRedundantAfterGroupBy(sq)) {
chainModification(
NodeType.kModifyColumns,
protoSelectColumnsToModifyState(selectCols),
);
}
// Order by (sort).
if (sq.orderBy !== undefined && sq.orderBy !== null) {
chainModification(NodeType.kSort, protoOrderByToSortState(sq.orderBy));
}
// Limit / offset. Protobufjs with --force-number sets unset numeric
// fields to 0, so treat 0 as "unset" to avoid creating spurious nodes.
const limitVal =
sq.limit !== undefined && sq.limit !== null ? Number(sq.limit) : 0;
const offsetVal =
sq.offset !== undefined && sq.offset !== null ? Number(sq.offset) : 0;
if (limitVal > 0 || offsetVal > 0) {
const limitState: Record<string, unknown> = {};
if (limitVal > 0) {
limitState.limit = limitVal;
}
if (offsetVal > 0) {
limitState.offset = offsetVal;
}
chainModification(NodeType.kLimitAndOffset, limitState);
}
// Experimental filter group.
if (
sq.experimentalFilterGroup !== undefined &&
sq.experimentalFilterGroup !== null
) {
const filterGroupResult = decomposeFilterGroup(
sq.experimentalFilterGroup,
lastNodeId,
);
for (const filterNode of filterGroupResult) {
const prevNode = acc.nodes.find((n) => n.nodeId === lastNodeId);
if (prevNode !== undefined) {
prevNode.nextNodes.push(filterNode.nodeId);
}
acc.nodes.push(filterNode);
// Don't set layout for dockable nodes — they dock to their parent.
if (!singleNodeOperation(filterNode.type)) {
acc.layouts.set(filterNode.nodeId, {x: startX, y: currentY});
}
lastNodeId = filterNode.nodeId;
currentY += NODE_GAP_Y;
}
}
return {...acc, finalNodeId: lastNodeId, nextY: currentY};
}
// ============================================================================
// Join Decomposition (kept as named function due to state complexity)
// ============================================================================
function decomposeJoin(
join: protos.PerfettoSqlStructuredQuery.IExperimentalJoin,
startX: number,
startY: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): DecomposeResult {
let joinType = 'INNER';
if (
join.type === protos.PerfettoSqlStructuredQuery.ExperimentalJoin.Type.LEFT
) {
joinType = 'LEFT';
}
let conditionType: 'equality' | 'freeform' = 'equality';
let leftColumn = '';
let rightColumn = '';
let sqlExpression = '';
if (join.equalityColumns !== undefined && join.equalityColumns !== null) {
leftColumn = join.equalityColumns.leftColumn ?? '';
rightColumn = join.equalityColumns.rightColumn ?? '';
} else if (
join.freeformCondition !== undefined &&
join.freeformCondition !== null
) {
conditionType = 'freeform';
sqlExpression = join.freeformCondition.sqlExpression ?? '';
}
return decomposeMultiSource(
{
inputs: [
{query: join.leftQuery, xOffset: 0},
{query: join.rightQuery, xOffset: METRIC_CHAIN_GAP_X},
],
nodeType: NodeType.kJoin,
buildState: ([leftNodeId, rightNodeId]) => ({
leftNodeId,
rightNodeId,
leftQueryAlias: 'left',
rightQueryAlias: 'right',
conditionType,
joinType,
leftColumn,
rightColumn,
sqlExpression,
}),
},
startX,
startY,
sharedQueries,
sqlModules,
);
}
// ============================================================================
// AddColumns Decomposition (kept as named function due to state complexity)
// ============================================================================
function decomposeAddColumns(
addCols: protos.PerfettoSqlStructuredQuery.IExperimentalAddColumns,
startX: number,
startY: number,
sharedQueries?: Map<string, protos.IPerfettoSqlStructuredQuery>,
sqlModules?: SqlModules,
): DecomposeResult {
let leftColumn = '';
let rightColumn = '';
if (
addCols.equalityColumns !== undefined &&
addCols.equalityColumns !== null
) {
leftColumn = addCols.equalityColumns.leftColumn ?? '';
rightColumn = addCols.equalityColumns.rightColumn ?? '';
}
const selectedColumns = (addCols.inputColumns ?? []).map(
(sc) => sc.columnNameOrExpression ?? sc.columnName ?? sc.alias ?? '',
);
return decomposeMultiSource(
{
inputs: [
{query: addCols.coreQuery, xOffset: 0},
{query: addCols.inputQuery, xOffset: METRIC_CHAIN_GAP_X},
],
nodeType: NodeType.kAddColumns,
buildState: ([primaryInputId, secondaryInputNodeId]) => ({
primaryInputId,
secondaryInputNodeId,
selectedColumns,
leftColumn,
rightColumn,
}),
},
startX,
startY,
sharedQueries,
sqlModules,
);
}
// ============================================================================
// ExperimentalFilterGroup Decomposition
// ============================================================================
/**
* Checks whether an ExperimentalFilterGroup is "simple" — meaning it contains
* only `filters` (no nested `groups` and no `sql_expressions`).
*/
function isSimpleFilterGroup(
group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): boolean {
const groups = group.groups ?? [];
const sqlExprs = group.sqlExpressions ?? [];
const filters = group.filters ?? [];
return filters.length > 0 && groups.length === 0 && sqlExprs.length === 0;
}
/**
* Checks whether an ExperimentalFilterGroup contains only nested `groups`
* (no direct `filters` and no `sql_expressions`).
*/
function isGroupsOnlyFilterGroup(
group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): boolean {
const groups = group.groups ?? [];
const sqlExprs = group.sqlExpressions ?? [];
const filters = group.filters ?? [];
return groups.length > 0 && filters.length === 0 && sqlExprs.length === 0;
}
/**
* Converts a single proto Filter to a SQL expression string.
*/
function protoFilterToSql(
f: protos.PerfettoSqlStructuredQuery.IFilter,
): string {
const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
const column = f.columnName ?? '';
// IS NULL / IS NOT NULL don't need a RHS value.
if (op === 'IS NULL' || op === 'IS NOT NULL') {
return `${column} ${op}`;
}
// Extract value.
const stringRhs = f.stringRhs ?? [];
const doubleRhs = f.doubleRhs ?? [];
const int64Rhs = f.int64Rhs ?? [];
if (stringRhs.length > 0) {
// Quote string values.
return `${column} ${op} '${stringRhs[0]}'`;
} else if (doubleRhs.length > 0) {
return `${column} ${op} ${doubleRhs[0]}`;
} else if (int64Rhs.length > 0) {
return `${column} ${op} ${int64Rhs[0]}`;
}
return `${column} ${op} ''`;
}
/**
* Recursively converts an ExperimentalFilterGroup into a SQL WHERE expression.
*/
function filterGroupToSql(
group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
): string {
const opEnum =
protos.PerfettoSqlStructuredQuery.ExperimentalFilterGroup.Operator;
const joiner = group.op === opEnum.OR ? ' OR ' : ' AND ';
const parts: string[] = [];
// Convert each filter to SQL.
for (const f of group.filters ?? []) {
parts.push(protoFilterToSql(f));
}
// Add raw SQL expressions.
for (const expr of group.sqlExpressions ?? []) {
parts.push(expr);
}
// Recurse into nested groups, wrapping each in parentheses.
for (const subGroup of group.groups ?? []) {
const subSql = filterGroupToSql(subGroup);
if (subSql.length > 0) {
parts.push(`(${subSql})`);
}
}
return parts.join(joiner);
}
/**
* Decomposes an ExperimentalFilterGroup into one or more SerializedNode[]
* (FilterNodes) to chain onto the graph.
*
* Strategy:
* 1. Simple flat AND/OR of filters only → structured FilterNode
* 2. AND of sub-groups only (no direct filters/sql) → one FilterNode per
* sub-group (each sub-group that is simple becomes structured; complex
* ones become freeform)
* 3. Everything else → single freeform FilterNode with the full SQL expression
*/
function decomposeFilterGroup(
group: protos.PerfettoSqlStructuredQuery.IExperimentalFilterGroup,
primaryInputId: string,
): SerializedNode[] {
const opEnum =
protos.PerfettoSqlStructuredQuery.ExperimentalFilterGroup.Operator;
// Case 1: Simple flat group — only filters, no groups, no sql_expressions.
if (isSimpleFilterGroup(group)) {
const filterState = protoFiltersToFilterState(group.filters ?? []);
filterState.primaryInputId = primaryInputId;
if (group.op === opEnum.OR) {
filterState.filterOperator = 'OR';
}
const nodeId = nextImportNodeId();
return [
{
nodeId,
type: NodeType.kFilter,
state: filterState,
nextNodes: [],
},
];
}
// Case 2: AND of sub-groups only — decompose each sub-group into its own
// FilterNode (chained sequentially, giving implicit AND).
if (group.op === opEnum.AND && isGroupsOnlyFilterGroup(group)) {
const result: SerializedNode[] = [];
let currentInputId = primaryInputId;
for (const subGroup of group.groups ?? []) {
const subNodes = decomposeFilterGroup(subGroup, currentInputId);
result.push(...subNodes);
// The last node from the sub-group becomes the input for the next.
if (subNodes.length > 0) {
currentInputId = subNodes[subNodes.length - 1].nodeId;
}
}
return result;
}
// Case 3: Complex / mixed — fall back to freeform SQL.
const sql = filterGroupToSql(group);
if (sql.length === 0) {
return [];
}
const nodeId = nextImportNodeId();
return [
{
nodeId,
type: NodeType.kFilter,
state: {
primaryInputId,
filterMode: 'freeform',
sqlExpression: sql,
},
nextNodes: [],
},
];
}
// ============================================================================
// Proto → Node State Conversion Helpers
// ============================================================================
const FILTER_OP_MAP: Record<number, string> = {
[protos.PerfettoSqlStructuredQuery.Filter.Operator.EQUAL]: '=',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.NOT_EQUAL]: '!=',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.LESS_THAN]: '<',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.LESS_THAN_EQUAL]: '<=',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.GREATER_THAN]: '>',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.GREATER_THAN_EQUAL]: '>=',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.IS_NULL]: 'IS NULL',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.IS_NOT_NULL]:
'IS NOT NULL',
[protos.PerfettoSqlStructuredQuery.Filter.Operator.GLOB]: 'GLOB',
};
/** Returns the total number of RHS values across all RHS fields. */
function protoFilterRhsCount(
f: protos.PerfettoSqlStructuredQuery.IFilter,
): number {
return (
(f.stringRhs ?? []).length +
(f.doubleRhs ?? []).length +
(f.int64Rhs ?? []).length
);
}
/** Extracts the first RHS value as a string. */
function protoFilterFirstRhsValue(
f: protos.PerfettoSqlStructuredQuery.IFilter,
): string {
const stringRhs = f.stringRhs ?? [];
const doubleRhs = f.doubleRhs ?? [];
const int64Rhs = f.int64Rhs ?? [];
if (stringRhs.length > 0) return stringRhs[0];
if (doubleRhs.length > 0) return String(doubleRhs[0]);
if (int64Rhs.length > 0) return String(int64Rhs[0]);
return '';
}
/** Extracts ALL RHS values as strings. */
function protoFilterAllRhsValues(
f: protos.PerfettoSqlStructuredQuery.IFilter,
): string[] {
const values: string[] = [];
for (const v of f.stringRhs ?? []) values.push(v);
for (const v of f.doubleRhs ?? []) values.push(String(v));
for (const v of f.int64Rhs ?? []) values.push(String(v));
return values;
}
function protoFiltersToFilterState(
filters: protos.PerfettoSqlStructuredQuery.IFilter[],
): Record<string, unknown> {
const uiFilters = filters.map((f) => {
const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
const column = f.columnName ?? '';
const value = protoFilterFirstRhsValue(f);
return {column, op, value, enabled: true};
});
return {filters: uiFilters};
}
/**
* Expands a single proto filter with multiple RHS values into an OR
* FilterNode state. Each RHS value becomes a separate filter entry.
*/
function protoFilterToExpandedOrState(
f: protos.PerfettoSqlStructuredQuery.IFilter,
): Record<string, unknown> {
const op = FILTER_OP_MAP[f.op ?? 0] ?? '=';
const column = f.columnName ?? '';
const values = protoFilterAllRhsValues(f);
const uiFilters = values.map((value) => ({
column,
op,
value,
enabled: true,
}));
return {filters: uiFilters, filterOperator: 'OR'};
}
const AGG_OP_MAP: Record<number, string> = {
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.COUNT]: 'COUNT',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.SUM]: 'SUM',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MIN]: 'MIN',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MAX]: 'MAX',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MEAN]: 'MEAN',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.MEDIAN]: 'MEDIAN',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op
.DURATION_WEIGHTED_MEAN]: 'DURATION_WEIGHTED_MEAN',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.COUNT_DISTINCT]:
'COUNT_DISTINCT',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.PERCENTILE]:
'PERCENTILE',
[protos.PerfettoSqlStructuredQuery.GroupBy.Aggregate.Op.CUSTOM]: 'CUSTOM',
};
function protoGroupByToAggregationState(
groupBy: protos.PerfettoSqlStructuredQuery.IGroupBy,
): Record<string, unknown> {
const groupByColumns = (groupBy.columnNames ?? []).map((name) => ({
name,
checked: true,
}));
const aggregations = (groupBy.aggregates ?? []).map((agg) => ({
column: agg.columnName !== undefined ? {name: agg.columnName} : undefined,
aggregationOp: AGG_OP_MAP[agg.op ?? 0] ?? 'COUNT',
newColumnName: agg.resultColumnName || undefined,
percentile: agg.percentile ?? undefined,
isValid: true,
}));
return {groupByColumns, aggregations};
}
// Returns true when select_columns just re-lists the group_by output columns
// without aliases. In that case the ModifyColumns node would be a no-op.
function isSelectRedundantAfterGroupBy(
sq: protos.IPerfettoSqlStructuredQuery,
): boolean {
if (sq.groupBy === undefined || sq.groupBy === null) return false;
const selectCols = sq.selectColumns ?? [];
if (selectCols.length === 0) return false;
// Collect the column names the group_by produces.
const groupByCols = new Set(sq.groupBy.columnNames ?? []);
for (const agg of sq.groupBy.aggregates ?? []) {
if (agg.resultColumnName) groupByCols.add(agg.resultColumnName);
}
// If any select column has an alias or expression, it's not redundant.
for (const sc of selectCols) {
const name = sc.columnName ?? sc.columnNameOrExpression ?? '';
if (sc.alias) return false;
if (!groupByCols.has(name)) return false;
}
return selectCols.length === groupByCols.size;
}
function protoSelectColumnsToModifyState(
selectColumns: protos.PerfettoSqlStructuredQuery.ISelectColumn[],
): Record<string, unknown> {
const selectedColumns = selectColumns.map((sc) => ({
name: sc.columnNameOrExpression ?? sc.columnName ?? sc.alias ?? '',
checked: true,
alias: sc.alias || undefined,
}));
return {selectedColumns};
}
function protoOrderByToSortState(
orderBy: protos.PerfettoSqlStructuredQuery.IOrderBy,
): Record<string, unknown> {
const sortCriteria = (orderBy.orderingSpecs ?? []).map((spec) => ({
colName: spec.columnName ?? '',
direction:
spec.direction ===
protos.PerfettoSqlStructuredQuery.OrderBy.Direction.DESC
? 'DESC'
: 'ASC',
}));
return {sortCriteria};
}
// ============================================================================
// Metric Spec → MetricsNode State Conversion
// ============================================================================
const UNIT_ENUM_TO_STRING: Record<number, string> = {
[protos.TraceMetricV2Spec.MetricUnit.COUNT]: 'COUNT',
[protos.TraceMetricV2Spec.MetricUnit.TIME_NANOS]: 'TIME_NANOS',
[protos.TraceMetricV2Spec.MetricUnit.TIME_MICROS]: 'TIME_MICROS',
[protos.TraceMetricV2Spec.MetricUnit.TIME_MILLIS]: 'TIME_MILLIS',
[protos.TraceMetricV2Spec.MetricUnit.TIME_SECONDS]: 'TIME_SECONDS',
[protos.TraceMetricV2Spec.MetricUnit.BYTES]: 'BYTES',
[protos.TraceMetricV2Spec.MetricUnit.KILOBYTES]: 'KILOBYTES',
[protos.TraceMetricV2Spec.MetricUnit.MEGABYTES]: 'MEGABYTES',
[protos.TraceMetricV2Spec.MetricUnit.PERCENTAGE]: 'PERCENTAGE',
[protos.TraceMetricV2Spec.MetricUnit.BOUNDED_PERCENTAGE]:
'BOUNDED_PERCENTAGE',
[protos.TraceMetricV2Spec.MetricUnit.MILLI_AMPS]: 'MILLI_AMPS',
[protos.TraceMetricV2Spec.MetricUnit.MILLI_WATTS]: 'MILLI_WATTS',
[protos.TraceMetricV2Spec.MetricUnit.MILLI_WATT_HOURS]: 'MILLI_WATT_HOURS',
[protos.TraceMetricV2Spec.MetricUnit.MILLI_AMP_HOURS]: 'MILLI_AMP_HOURS',
[protos.TraceMetricV2Spec.MetricUnit.CELSIUS]: 'CELSIUS',
[protos.TraceMetricV2Spec.MetricUnit.MILLI_VOLTS]: 'MILLI_VOLTS',
};
const POLARITY_ENUM_TO_STRING: Record<number, string> = {
[protos.TraceMetricV2Spec.MetricPolarity.NOT_APPLICABLE]: 'NOT_APPLICABLE',
[protos.TraceMetricV2Spec.MetricPolarity.HIGHER_IS_BETTER]:
'HIGHER_IS_BETTER',
[protos.TraceMetricV2Spec.MetricPolarity.LOWER_IS_BETTER]: 'LOWER_IS_BETTER',
};
function resolveUnit(spec: {
unit?: protos.TraceMetricV2Spec.MetricUnit | null;
customUnit?: string | null;
}): {unit: string; customUnit?: string} {
if (
spec.customUnit !== undefined &&
spec.customUnit !== null &&
spec.customUnit !== ''
) {
return {unit: 'CUSTOM', customUnit: spec.customUnit};
}
if (spec.unit !== undefined && spec.unit !== null) {
return {unit: UNIT_ENUM_TO_STRING[spec.unit] ?? 'COUNT'};
}
return {unit: 'COUNT'};
}
function resolvePolarity(
polarity: protos.TraceMetricV2Spec.MetricPolarity | null | undefined,
): string {
if (polarity !== undefined && polarity !== null) {
return POLARITY_ENUM_TO_STRING[polarity] ?? 'NOT_APPLICABLE';
}
return 'NOT_APPLICABLE';
}
export function templateSpecToMetricsState(
spec: protos.ITraceMetricV2TemplateSpec,
): Record<string, unknown> {
const metricIdPrefix = spec.idPrefix ?? '';
// Value columns from valueColumnSpecs or simple valueColumns.
const valueColumns: Array<Record<string, unknown>> = [];
const valueColumnSpecs = spec.valueColumnSpecs ?? [];
const simpleValueColumns = spec.valueColumns ?? [];
if (valueColumnSpecs.length > 0) {
for (const vcs of valueColumnSpecs) {
const {unit, customUnit} = resolveUnit(vcs);
valueColumns.push({
column: vcs.name ?? '',
unit,
customUnit,
polarity: resolvePolarity(vcs.polarity),
displayName: vcs.displayName || undefined,
displayHelp: vcs.displayHelp || undefined,
});
}
} else if (simpleValueColumns.length > 0) {
for (const colName of simpleValueColumns) {
valueColumns.push({
column: colName,
unit: 'COUNT',
polarity: 'NOT_APPLICABLE',
});
}
}
// Dimension configs from dimensionsSpecs.
const dimensionConfigs: Record<string, Record<string, unknown>> = {};
const dimSpecs = spec.dimensionsSpecs ?? [];
for (const ds of dimSpecs) {
if (ds.name !== undefined && ds.name !== null) {
const cfg: Record<string, unknown> = {};
if (ds.displayName) cfg.displayName = ds.displayName;
if (ds.displayHelp) cfg.displayHelp = ds.displayHelp;
if (Object.keys(cfg).length > 0) {
dimensionConfigs[ds.name] = cfg;
}
}
}
// Dimension uniqueness.
let dimensionUniqueness = 'NOT_UNIQUE';
if (
spec.dimensionUniqueness ===
protos.TraceMetricV2Spec.DimensionUniqueness.UNIQUE
) {
dimensionUniqueness = 'UNIQUE';
}
return {
metricIdPrefix,
valueColumns,
dimensionConfigs:
Object.keys(dimensionConfigs).length > 0 ? dimensionConfigs : undefined,
dimensionUniqueness,
};
}
export function metricSpecToMetricsState(
spec: protos.ITraceMetricV2Spec,
): Record<string, unknown> {
const metricIdPrefix = spec.id ?? '';
const valueColumns: Array<Record<string, unknown>> = [];
if (spec.value !== undefined && spec.value !== null && spec.value !== '') {
const {unit, customUnit} = resolveUnit(spec);
valueColumns.push({
column: spec.value,
unit,
customUnit,
polarity: resolvePolarity(spec.polarity),
});
}
const dimensionConfigs: Record<string, Record<string, unknown>> = {};
const dimSpecs = spec.dimensionsSpecs ?? [];
for (const ds of dimSpecs) {
if (ds.name !== undefined && ds.name !== null) {
const cfg: Record<string, unknown> = {};
if (ds.displayName) cfg.displayName = ds.displayName;
if (ds.displayHelp) cfg.displayHelp = ds.displayHelp;
if (Object.keys(cfg).length > 0) {
dimensionConfigs[ds.name] = cfg;
}
}
}
let dimensionUniqueness = 'NOT_UNIQUE';
if (
spec.dimensionUniqueness ===
protos.TraceMetricV2Spec.DimensionUniqueness.UNIQUE
) {
dimensionUniqueness = 'UNIQUE';
}
return {
metricIdPrefix,
valueColumns,
dimensionConfigs:
Object.keys(dimensionConfigs).length > 0 ? dimensionConfigs : undefined,
dimensionUniqueness,
};
}
// ============================================================================
// Pbtxt Detection & Wrapping
// ============================================================================
/**
* Detects whether the pbtxt is a single metric_template_spec, a single
* metric_spec, or a full TraceSummarySpec. Wraps single specs if needed.
*/
export function detectAndWrapPbtxt(text: string): string {
const trimmed = text.trim();
// If it already contains top-level TraceSummarySpec fields, use as-is.
if (/^(metric_template_spec|metric_spec|query)\s*[:{]/m.test(trimmed)) {
return trimmed;
}
// If it looks like a single TraceMetricV2TemplateSpec (has id_prefix).
if (/^id_prefix\s*:/m.test(trimmed)) {
return `metric_template_spec {\n${trimmed}\n}`;
}
// If it looks like a single TraceMetricV2Spec (has id: field).
if (/^id\s*:/m.test(trimmed)) {
return `metric_spec {\n${trimmed}\n}`;
}
// Otherwise, assume it's a full TraceSummarySpec.
return trimmed;
}
/**
* Converts SimpleSlices glob fields into GLOB filter entries so they are
* preserved as FilterNode state. The SlicesSourceNode itself does not
* support glob fields, so without this the filters would be silently lost.
*/
function buildSimpleSlicesFilters(
slices: protos.PerfettoSqlStructuredQuery.ISimpleSlices,
): Array<Record<string, unknown>> {
const GLOB_COLUMN_MAP: Array<{
field: string | null | undefined;
column: string;
}> = [
{field: slices.sliceNameGlob, column: 'name'},
{field: slices.processNameGlob, column: 'process_name'},
{field: slices.threadNameGlob, column: 'thread_name'},
{field: slices.trackNameGlob, column: 'track_name'},
];
return GLOB_COLUMN_MAP.filter(
(entry) =>
entry.field !== undefined && entry.field !== null && entry.field !== '',
).map((entry) => ({
column: entry.column,
op: 'GLOB',
value: entry.field,
enabled: true,
}));
}