blob: ee9593258b91754f5d2497d4a319ad0c0553f68b [file] [view] [edit]
# PerfettoGraph Language Specification
## Overview
PerfettoGraph (`.pfgraph.yaml`) is a YAML-based dataflow language for trace
analysis. Unlike SQL, which treats a trace as a generic relational database,
PerfettoGraph is **trace-domain-aware**: it knows that slices belong to threads
and processes, that counters are intervals, that flows link causally related
events, and that thread state breakdowns are a common analysis pattern.
The design principles:
1. **YAML format with JSON Schema validation.** The format is standard YAML,
validated against `pfgraph-schema.json`. Any YAML tooling works.
2. **The language knows what a trace is.** Operations like `span_join`,
`interval_intersect`, `find_ancestor`, and `flow_reachable` encode trace
domain knowledge.
3. **Small primitive set, large standard library.** ~20 operation types.
Complex patterns are composed from simple operations.
4. **Flat operation lists.** Each pipeline is a `table:` source followed by
a list of transform operations. Reads top-to-bottom like a recipe.
## Format
PerfettoGraph files are YAML documents with this top-level structure:
```yaml
module: android.anrs # Module name (dotted path)
imports: [android.process_metadata] # Dependencies
# Function definitions
_my_helper:
type: function
args: {x: INT, y: STRING}
returns: STRING
body: |
SELECT CASE WHEN $x > 0 THEN $y ELSE NULL END
# Pipeline definitions
_intermediate_step: # No type = intermediate
ops:
- table: source_table # First op: source
- filter: "dur > 1000" # Operations follow
- select: [id, ts, dur]
public_output: # type: table = CREATE PERFETTO TABLE
type: table
ops:
- table: _intermediate_step
- sort: ts
```
## Pipelines
A pipeline is a named transformation defined under `ops:`:
```yaml
pipeline_name:
type: table # Optional: table | view | (omit for intermediate)
index: [col1, col2] # Optional: create index
ops:
- table: source # First item: the data source
- filter: "expr" # Transform operations
- select: [cols]
- sort: col DESC
```
The first item in `ops:` is always the source (`table:`). Everything after
is a transform applied in sequence.
## Sources (`table:` — first item in ops)
| Source | Example |
|--------|---------|
| Table/view name | `table: slice` |
| Pipeline reference | `table: _my_pipeline` |
| Raw SQL | `table: {sql: "SELECT ..."}` |
| Slices with context | `table: {slices: {name: "GC*", process: "com.*"}}` |
| Interval intersection | `table: {interval_intersect: {inputs: [a, b], partition: [col]}}` |
| Union | `table: {union: [pipeline_a, pipeline_b]}` |
## Operations
### Filtering and Selection
```yaml
- filter: "dur > 1000 AND name GLOB 'binder*'"
- select: [id, ts, dur, "name AS slice_name"]
- distinct: true
- limit: 10
- offset: 20
- sort: dur DESC # or: sort: [dur DESC, ts ASC]
```
### Adding Columns
```yaml
# Computed columns (keeps existing + adds new)
- computed:
end_ts: "ts + dur"
is_long: "iif(dur > 1000000, 1, 0)"
# Columns from another table via LEFT JOIN
- add_columns:
from: process
on: upid = upid
cols: [name AS process_name, pid]
# Bulk arg extraction
- extract_args:
event_type: "event.type"
event_seq: "event.seq"
```
### Aggregation
```yaml
- group_by:
columns: [process_name, state]
agg:
total_dur: "sum(dur)"
count: "count()"
avg_dur: "avg(dur)"
```
### Window Functions
```yaml
- window:
prev_state:
expr: "lag(state)"
partition: [utid]
order: ts
next_ts:
expr: "lead(ts, 1, trace_end())"
partition: [track_id]
order: ts
```
### Joins
```yaml
# Chainable join (current pipeline is left side)
- join:
right: other_table
on: "id = other_table.id"
type: LEFT # INNER (default) or LEFT
# Cross join
- cross_join: other_table
```
### Trace-Specific Operations
```yaml
# SPAN_JOIN for temporal intersection
- span_join:
right: thread_state
partition: [utid]
type: LEFT # INNER (default) or LEFT
# Filter to intervals
- filter_during:
intervals: startup_events
partition: [upid]
clip: true
# Semi-join (WHERE col IN)
- filter_in:
match: valid_ids
base_col: id
match_col: id
# Set difference
- except: broken_entries
# Counter to intervals
- counter_to_intervals: true
# Classify by pattern matching
- classify:
column: gc_type
from: gc_name
rules:
"*NativeAlloc*": native_alloc
"*young*": young
"_": full # default
# Slice tree traversal
- find_ancestor:
where: "_anc.name = 'binder reply'"
cols: [id AS binder_reply_id, ts AS binder_reply_ts]
- find_descendant:
where: "_desc.name = 'ScopedSetIpcHash'"
cols: [name AS hash_name]
# Flow-based reachability
- flow_reachable:
direction: out # out (default) or in
# Interval operations
- flatten_intervals: true
- merge_overlapping:
epsilon: 0
partition: [utid]
# Graph reachability
- graph_reachable:
edges: _ownership_edges
method: dfs # dfs (default) or bfs
# Index creation
- index: [blocking_utid, ts]
```
## Functions
```yaml
# Scalar function
_my_helper:
type: function
args: {x: INT, y: STRING}
returns: STRING
body: |
SELECT CASE WHEN $x > 0 THEN $y ELSE NULL END
# Table-returning function
_get_slices:
type: function
args: {min_dur: INT}
returns: "TABLE(id INT, ts INT, dur INT)"
body: |
SELECT id, ts, dur FROM slice WHERE dur > $min_dur
```
## Module System
```yaml
module: android.binder # Declares this file's module
imports: # Dependencies (INCLUDE PERFETTO MODULE)
- android.process_metadata
- slices.with_context
```
A module is either `.sql` or `.pfgraph.yaml`. The engine auto-detects the
format at load time. Both formats can coexist a `.pfgraph.yaml` module
can import `.sql` modules and vice versa.
## Schema Validation
The canonical schema is `docs/design-docs/pfgraph-schema.json`. It can be
used with any JSON Schema validator (e.g., Python `jsonschema`, VS Code
YAML extension) for editor support and CI validation.
## Design Rationale
### Why YAML?
- Standard format with existing tooling (editors, linters, validators)
- JSON Schema provides formal validation
- No custom parser maintenance (uses a minimal YAML subset parser)
- Familiar to developers (GitHub Actions, Docker Compose, Kubernetes)
### Why `ops:` as a flat list?
Operations have ORDER filter before group_by produces different results
than filter after. YAML sequences preserve order. YAML mappings don't
(in spec, though most parsers do). A flat list makes the order explicit
and reads like a recipe.
### Why `table:` as the source keyword?
The first operation names the data source. `table:` is clear and
source-agnostic — it can be a table name, view name, pipeline reference,
or `{sql: "..."}` for raw SQL.
### Why keep `{sql: "..."}` as escape hatch?
Some patterns are genuinely irreducible to the dataflow model:
- `ancestor_slice()` correlated lateral joins
- Recursive CTEs for graph traversal
- `cat_stacks()`/`experimental_profile()` for pprof
- Self-joins with complex aliased column selection
- `_slice_following_flow!()` macro calls
These use `{sql: "..."}` as the source, keeping the SQL within the
pipeline system (not a standalone block).