blob: c50316918a0626b74dfd6b9b4e0437a93a2c7ff6 [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.
// Documentation for each node designed to be consumed by an LLM.
// TODO(stevegolton): Find a better way to keep this in sync with the actual
// JSON model for each node - this may involve defining a zod schema or similar
// on each node directly.
export const GRAPH_FORMAT = `
================================================================================
DATA EXPLORER GRAPH JSON FORMAT
================================================================================
Send the whole graph as a JSON string. Top-level shape:
{
"nodes": [ <node>, ... ], // every node in the graph
"rootNodeIds": [ "<id>", ... ], // ids of nodes with no input feeding them
"nodeLayouts": { "<id>": {"x":0,"y":0} }, // OPTIONAL - omit for auto-layout
"selectedNodeId": "<id>" // OPTIONAL - node to select after loading
}
Each <node>:
{
"nodeId": "<unique string id, e.g. \\"0\\", \\"1\\">",
"type": "<one of the node types below>",
"state": { ...type-specific fields, see catalog... },
"nextNodes": [ "<downstream nodeId>", ... ],
"primaryInputId": "<nodeId>", // OPTIONAL: the node above this one
"secondaryInputIds": { "0": "<nodeId>", ... } // OPTIONAL: side inputs, keyed by port
}
--------------------------------------------------------------------------------
CONNECTION MODEL (read carefully - this is where graphs break)
--------------------------------------------------------------------------------
Edges are stored BOTH directions and must agree:
- The upstream node lists the downstream id in its "nextNodes".
- The downstream node names the upstream id in EITHER "primaryInputId" (the
single input that flows in from above) OR "secondaryInputIds" (side inputs,
keyed by port number "0","1",... ).
If only one side is set the edge is dropped on load. Always set both.
- "rootNodeIds" = exactly the nodes that have NO input (no primaryInputId and no
secondaryInputIds). Source nodes are always roots. Every other node must be
reachable from a root via nextNodes, or it is lost on load.
- No cycles.
Which input a node uses:
- Source nodes (table, simple_slices, sql_source, time_range_source): no input.
- Single-input operations (aggregation, modify_columns, filter, sort,
limit_and_offset, counter_to_intervals, visualisation, dashboard, metrics):
use "primaryInputId".
- add_columns, filter_during, filter_in: "primaryInputId" for the main data,
PLUS "secondaryInputIds":{"0": <the other input>}.
- Multi-source nodes (join, union, interval_intersect, create_slices,
trace_summary): NO primaryInputId; all inputs go in "secondaryInputIds" by
port. join/create_slices use ports "0" and "1"; union/interval_intersect take
two or more ports "0","1","2",...
--------------------------------------------------------------------------------
SHARED SUB-TYPES (used inside several nodes' "state")
--------------------------------------------------------------------------------
ColumnInfo = {
"name": "<column name>",
"checked": true, // true = keep/use this column (usually what you want)
"alias": "<rename>", // OPTIONAL
"type": { "kind": "<k>" } // OPTIONAL; kind is one of:
// int double boolean string bytes timestamp duration arg_set_id
}
You can omit "type"; it is re-derived from the upstream schema on load.
--------------------------------------------------------------------------------
RECOMMENDED DEFAULT: real nodes (STRONGLY PREFERRED)
--------------------------------------------------------------------------------
STRONGLY prefer building the graph out of real, structured nodes (a "table" or
"slice" source feeding "filter", "sort", "aggregation", "modify_columns",
"limit_and_offset", joins, etc.) rather than packing everything into one
"sql_source" node. Each real node shows up in the UI as its own editable step:
the user can see exactly what every stage does, tweak it, reorder it, and learn
from it. A single "sql_source" collapses all of that into an opaque blob of SQL
the user cannot inspect or adjust without reading raw SQL.
So: decompose the request into one node per logical operation. Use a source node
for the data, then a separate node for each filter / sort / group-by / column
selection / limit.
Fall back to "sql_source" ONLY when no real node can express the operation (an
exotic SQL construct, a CTE, a window function, etc.), or when the user
explicitly asks for raw SQL. type "sql_source", state:
{ "sql": "<a single PerfettoSQL SELECT>" }
Rules for "sql":
- Exactly ONE SELECT (a leading "WITH ... SELECT" is allowed). No trailing ";".
- To use stdlib tables, prepend "INCLUDE PERFETTO MODULE <name>;" statements
(each ending in ";"), then the SELECT last.
- It can read upstream nodes connected via secondaryInputIds as $input_0,
$input_1, ... (port number).
- Confirm tables/columns with run_query / get_schema first; do not guess schema.
Preferred shape (the common case) - real nodes, one operation each:
{
"nodes": [
{ "nodeId": "0", "type": "table",
"state": { "sqlTable": "slice" },
"nextNodes": ["1"] },
{ "nodeId": "1", "type": "sort",
"state": { "sortCriteria": [ { "colName": "dur", "direction": "DESC" } ] },
"primaryInputId": "0", "nextNodes": ["2"] },
{ "nodeId": "2", "type": "limit_and_offset",
"state": { "limit": 20, "offset": 0 },
"primaryInputId": "1", "nextNodes": [] }
],
"rootNodeIds": ["0"]
}
Equivalent with sql_source (use ONLY as a fallback, less visible to the user):
{
"nodes": [
{ "nodeId": "0", "type": "sql_source",
"state": { "sql": "SELECT name, dur FROM slice ORDER BY dur DESC LIMIT 20" },
"nextNodes": [] }
],
"rootNodeIds": ["0"]
}
================================================================================
FULL NODE CATALOG (the "state" object for each "type")
================================================================================
SOURCES (no input; always go in rootNodeIds)
- "sql_source" -> see RECOMMENDED DEFAULT above. state: { "sql": string }
- "table" -> read a whole trace table.
state: { "sqlTable": "<table name, e.g. \\"slice\\">" }
- "simple_slices" -> all slices in the trace. state: {} (no fields)
- "time_range_source" -> a time window (ns).
state: { "start": "<ns as string>", "end": "<ns as string>", "isDynamic": false }
isDynamic true = follows the timeline selection; false = fixed snapshot.
SINGLE-INPUT OPERATIONS (set "primaryInputId" to the upstream node)
- "filter" -> keep rows matching a condition.
Freeform (preferred, simplest):
state: { "filterMode": "freeform", "sqlExpression": "dur > 1000000 AND name GLOB 'foo*'" }
Structured:
state: {
"filterMode": "structured",
"filterOperator": "AND", // or "OR"
"filters": [
{ "column": "dur", "op": ">", "value": 1000000 }
]
}
op is one of: "=" "!=" "<" "<=" ">" ">=" "glob" | "in" "not in" (value is an
array) | "is null" "is not null" (no value).
- "sort" -> order rows.
state: { "sortCriteria": [ { "colName": "dur", "direction": "DESC" } ] } // ASC | DESC
- "limit_and_offset" -> cap row count.
state: { "limit": 100, "offset": 0 }
- "aggregation" -> GROUP BY + aggregate.
state: {
"groupByColumns": [ { "name": "name", "checked": true } ], // ColumnInfo[]
"aggregations": [
{ "column": { "name": "dur" }, "aggregationOp": "SUM", "newColumnName": "total_dur" }
]
}
aggregationOp: COUNT | COUNT(*) | COUNT_DISTINCT | SUM | MIN | MAX | MEAN |
MEDIAN | DURATION_WEIGHTED_MEAN | PERCENTILE. For COUNT(*) omit "column". For
PERCENTILE add "percentile": <number 0-100>.
- "modify_columns" -> select / rename columns.
state: { "selectedColumns": [ { "name": "name", "checked": true, "alias": "slice_name" } ] } // ColumnInfo[]
- "counter_to_intervals" -> turn counter rows (ts, value) into intervals
(adds dur, next_value, delta_value). Input must have id, ts, track_id, value
and NOT already have dur. state: {} (no fields)
- "visualisation" -> charts over the input rows.
state: {
"chartConfigs": [
{ "id": "c1", "column": "name", "chartType": "bar", "aggregation": "COUNT" }
]
}
chartType: bar | histogram | line | scatter | pie | treemap | boxplot |
heatmap | cdf | scorecard. aggregation (bar/pie/treemap): COUNT (default) |
SUM | MIN | MAX | MEAN ... For non-count add "measureColumn". histogram uses
"column" (+ optional "binCount"); line/scatter need "yColumn".
SINGLE-INPUT + ONE SIDE INPUT (set "primaryInputId" AND "secondaryInputIds":{"0":...})
- "add_columns" -> LEFT JOIN columns from the side node onto the main rows.
state: {
"selectedColumns": [ "<col from side node>", ... ],
"leftColumn": "<join key in main input>",
"rightColumn": "<join key in side input>",
"columnAliases": { "<col>": "<alias>" } // OPTIONAL
}
- "filter_during" -> keep only main-input intervals that overlap intervals from
the side node. Both inputs need ts and dur.
state: { "partitionColumns": ["utid"], "clipToIntervals": true }
clipToIntervals true (default) = output uses the intersected ts/dur.
- "filter_in" -> keep main rows whose value appears in the side node.
state: { "baseColumn": "<col in main input>", "matchColumn": "<col in side input>" }
MULTI-SOURCE (no primaryInputId; inputs in "secondaryInputIds" by port)
- "join" -> join two inputs. Ports "0" (left) and "1" (right).
state: {
"joinType": "INNER", // or "LEFT"
"conditionType": "equality", // or "freeform"
"leftColumn": "<left key>", // for equality
"rightColumn": "<right key>", // for equality
"sqlExpression": "", // for freeform, e.g. "left.id = right.parent_id"
"leftQueryAlias": "left",
"rightQueryAlias": "right",
"leftColumns": [ { "name": "...", "checked": true } ], // ColumnInfo[], columns to keep
"rightColumns": [ { "name": "...", "checked": true } ] // ColumnInfo[]
}
- "union" -> stack rows from 2+ inputs (ports "0","1",...).
state: { "selectedColumns": [ { "name": "...", "checked": true } ] } // ColumnInfo[] common cols
- "interval_intersect" -> intersect intervals from 2+ inputs (ports "0","1",...).
All inputs need ts and dur.
state: { "partitionColumns": ["utid"], "tsDurSource": "intersection" }
tsDurSource: "intersection" or an input index number.
- "create_slices" -> build slices by pairing start/end timestamps from two
inputs. Ports "0" (starts) and "1" (ends).
state: { "startsTsColumn": "ts", "endsTsColumn": "ts" }
( optional: "startsDurColumn", "endsDurColumn", "startsMode", "endsMode" )
EXPORT NODES (advanced; usually only when the user asks for metrics/dashboards)
- "metrics" -> define a metric (primaryInputId = the data).
state: {
"metricIdPrefix": "my_metric",
"valueColumns": [ { "column": "dur", "unit": "NS", "polarity": "POSITIVE" } ],
"dimensionConfigs": { "<dim col>": { "displayName": "..." } },
"dimensionUniqueness": ""
}
- "trace_summary" -> bundle metrics. Inputs are metrics nodes via
"secondaryInputIds" ("0","1",...). state: {} (no fields)
- "dashboard" -> export the input for use on dashboards (primaryInputId = data).
state: { "exportName": "<name>" }
================================================================================
WORKFLOW
================================================================================
- Call get_graph FIRST when editing an existing graph; copy its exact shape and
reuse its nodeIds rather than rebuilding from scratch.
- Prefer a single sql_source node for most requests.
- Use real node types only when the user explicitly wants that operation/UI.
- An invalid graph comes back as a tool error - read it, fix the JSON, retry.
`.trim();