blob: 52a3435d3d5a103ab2ca1246d5c6539fa20ee65c [file] [view] [edit]
# Bundling Analysis and Visualization Extensions in Trace Files
**Authors:** @LalitMaganti
**Status:** Draft
**PR:** N/A
## Problem
Trace producers (AndroidX Benchmark, game engines, CI systems, internal tooling)
generate traces whose interpretation requires domain-specific logic:
- PerfettoSQL modules computing tool-specific metrics.
- UI macros and startup commands that set up the timeline (pin tracks, create
debug tracks, open query tabs) for the workflow the trace was recorded for.
- Proto descriptors for custom `TrackEvent` extension fields.
Today that logic must be delivered out-of-band: documentation pages with
copy-pasted queries, extension servers every consumer must configure manually,
or fragile deep-link URLs. Two long-standing feature requests capture this:
- [#1342](https://github.com/google/perfetto/issues/1342): allow including UI
macros/startup commands in trace files. The settings-based automation
machinery (macros, startup commands, the command allowlist) now exists, but
there is no way for the *trace itself* to supply them.
- [#6228](https://github.com/google/perfetto/issues/6228): a trace processor
API to bundle "v2 metrics" (PerfettoSQL) into traces, so that the same
queries work in both `trace_processor_shell` and the Perfetto UI without a
side channel for distributing the SQL.
Two additional constraints shape the solution:
- **The chrome://tracing lesson.** Perfetto deliberately separates trace
contents from visualization. Baking viewer behavior into the trace data
stream makes it unstrippable and forces backwards compatibility at the wrong
layer forever. Whatever carries this information must be a *sidecar* that
tooling can add, inspect, and remove without touching trace data.
- **It must work where trace processor works.** Extension servers and UI
settings only help users who open the trace in the UI. SQL distributed that
way is invisible to `trace_processor_shell` and batch pipelines, and proto
descriptors must be configured before the trace is parsed. The mechanism
must be consumed by trace processor itself, not just the UI.
## Decision
Pending.
## Design
### Overview
We extend the `perfetto_metadata` JSON sidecar (introduced in
[RFC-0016](0016-merged-trace-clock.md)) with a new `extensions` section. The
sidecar is the first member of a zip/tar archive, is content-sniffed via its
`{"perfetto_metadata"` prefix, and is parsed by trace processor before any
other archive member. The schema version stays at `1`: the existing reader
ignores unknown keys, so old trace processor builds load new traces unchanged,
simply without extensions.
The `extensions` section comes in two mutually exclusive variants, expressed
as a discriminated union:
- **`inline`**: the content (macros, SQL modules, proto descriptors, startup
commands) travels with the trace, either embedded in the JSON or as separate
archive members.
- **`server`**: the trace references an
[extension server](https://perfetto.dev/docs/visualization/extension-servers)
which provides the content; the UI prompts the user to install it via the
existing add-server flow.
Trace processor consumes the parts it understands (SQL modules, proto
descriptors) directly this is what makes bundled metrics work in
`trace_processor_shell` and exposes the whole section to the UI via the
`metadata` table, so the UI never needs to parse archives itself and the
feature works identically over Wasm and HTTP-RPC.
### Schema
#### Top level: `inline` vs `server`
```json
{
"perfetto_metadata": {
"version": 1,
"extensions": {
"type": "inline",
"namespace": "androidx.benchmark",
"macros": [],
"sql_modules": [],
"proto_descriptors": [],
"startup_commands": []
}
}
}
```
```json
{
"perfetto_metadata": {
"version": 1,
"extensions": {
"type": "server",
"server": {
"type": "https",
"url": "https://extensions.example.com",
"enabled_modules": ["benchmarks"]
},
"startup_commands": []
}
}
}
```
- `type` (required): `"inline"` or `"server"`. A trace either carries its
content or points at a server; never both. This keeps the loading story
simple (no dedup/merge semantics between a trace's own content and a
server's) and keeps provenance unambiguous.
- `namespace` (required for `inline`): the generating tool's namespace in
reverse-domain-style dotted notation. Every inline macro `id` and every
inline SQL module name must start with `namespace + "."`. This mirrors the
namespace enforcement extension servers already have. The namespace is also
the name of the PerfettoSQL package registered in trace processor. Reserved
prefixes (`perfetto`, `dev.perfetto`, and the names of stdlib packages) are
rejected.
- `server` (required for `server`): mirrors the UI's extension server
configuration schema, minus fields that make no sense in a trace
(`auth`, `origin`, `enabled`):
- `{"type": "https", "url": ..., "enabled_modules": [...]}`
- `{"type": "github", "repo": ..., "ref": ..., "path": ...,
"enabled_modules": [...]}`
Authentication always starts as "none"; if the server needs credentials the
user supplies them in the install dialog, exactly as with shared server
links today.
- `startup_commands` is valid in **both** variants. Startup commands are a
per-trace concept and deliberately *not* an extension server feature (see
Alternatives); a `server`-variant trace still needs them to, say, run a
macro that the referenced server provides.
#### Per-entry: `inline` vs `file`
Every content list (`macros`, `sql_modules`, `proto_descriptors`,
`startup_commands`) holds tagged entries that either embed their payload or
reference a separate archive member. Mixing is allowed within a list:
```json
"sql_modules": [
{
"type": "inline",
"name": "androidx.benchmark.startup",
"sql": "CREATE PERFETTO FUNCTION ..."
},
{
"type": "file",
"name": "androidx.benchmark.frames",
"path": "extensions/frames.sql"
}
],
"proto_descriptors": [
{"type": "inline", "data": "<base64 FileDescriptorSet>"},
{"type": "file", "path": "extensions/track_event_ext.pb"}
],
"macros": [
{
"type": "inline",
"id": "androidx.benchmark.FocusFrameTimeline",
"name": "Focus frame timeline",
"run": [
{"id": "dev.perfetto.PinTracksByRegex", "args": ["Frame.*"]},
{"id": "dev.perfetto.ExpandTracksByRegex", "args": ["Frame.*"]}
]
},
{"type": "file", "path": "extensions/macros.json"}
],
"startup_commands": [
{"type": "inline", "id": "androidx.benchmark.FocusFrameTimeline", "args": []},
{"type": "file", "path": "extensions/startup.json"}
]
```
`file` entry payloads by list:
| List | Referenced member contains |
| ------------------- | --------------------------------------------------- |
| `sql_modules` | UTF-8 PerfettoSQL source (the module body) |
| `proto_descriptors` | raw binary `FileDescriptorSet` (no base64) |
| `macros` | JSON array of macro objects (same shape as inline) |
| `startup_commands` | JSON array of command invocations (same as inline) |
Macro and command shapes are exactly the UI's existing settings shapes:
a macro is `{id, name, run: [{id, args}]}` and a command invocation is
`{id, args: string[]}`, so content is copy-pasteable between trace sidecars,
settings, and extension server responses.
#### Worked example
An AndroidX Benchmark output archive:
```text
benchmark-trace.zip
├── perfetto_metadata.json ← must be first member; sniffed by content
├── trace.perfetto-trace
└── extensions/
├── frames.sql
├── track_event_ext.pb
└── macros.json
```
```json
{
"perfetto_metadata": {
"version": 1,
"extensions": {
"type": "inline",
"namespace": "androidx.benchmark",
"sql_modules": [
{
"type": "file",
"name": "androidx.benchmark.frames",
"path": "extensions/frames.sql"
}
],
"proto_descriptors": [
{"type": "file", "path": "extensions/track_event_ext.pb"}
],
"macros": [
{"type": "file", "path": "extensions/macros.json"}
],
"startup_commands": [
{
"type": "inline",
"id": "androidx.benchmark.FocusFrameTimeline",
"args": []
},
{
"type": "inline",
"id": "dev.perfetto.RunQueryAndShowTab",
"args": [
"SELECT * FROM androidx_benchmark_frame_metrics"
]
}
]
}
}
}
```
After loading this archive:
- `trace_processor_shell benchmark-trace.zip` can immediately run
`INCLUDE PERFETTO MODULE androidx.benchmark.frames;` — the bundled metrics
work in batch pipelines with zero extra configuration (#6228).
- Custom `TrackEvent` extension fields decode into the `args` table because
the descriptors registered before the proto trace was parsed.
- The UI registers the macros for the session, asks the user whether to run
the trace's startup commands, and (on accept) pins/expands the frame tracks
and opens the metrics query tab (#1342).
### Trace processor design
#### Parsing and validation
The `perfetto_metadata` reader
(`src/trace_processor/plugins/perfetto_metadata/`) parses `extensions` into
`TraceMetadataState`. Validation enforced at parse time:
- the discriminated-union shapes above (unknown `type` values, `server`
together with inline content lists, missing `namespace`, etc.);
- namespace prefix rules for macro ids and SQL module names;
- well-formed base64 for inline descriptors.
Validation failures fail the load. Unlike trace *data*, the sidecar is an
authored artifact; a malformed one is an authoring bug that should be loud.
#### Claiming `file`-referenced archive members
Today, an archive member of unknown type is a hard error: the forwarding
parser rejects it with "Unknown trace type". A bundled `.sql` file or raw
descriptor would fail the whole load. The archive readers (zip, tar) therefore
*claim* members referenced by `file` entries: because the metadata member
sorts first (archive entries are parsed in priority order with
`perfetto_metadata` at the front), the set of claimed paths is known before
any other member is processed. Claimed members are routed into the extensions
state instead of being type-sniffed and forwarded as traces.
Errors:
- a `file` entry referencing a member not present in the archive;
- a claimed path that also appears in the sidecar's `files` array;
- `file` entries in a non-archive context (e.g. a standalone metadata file
passed alongside individual trace files) — inline entries still work there.
#### SQL module registration (#6228)
At end-of-file processing, trace processor registers the trace's SQL modules
as a PerfettoSQL package named after `namespace`, through the same path as the
existing `TraceProcessor::RegisterSqlPackage()` API. That path already rejects
name collisions with the stdlib and previously registered packages; for
trace-bundled packages the error is converted into "skip + stat" (a new
`stats` entry) rather than failing the load, and overriding is never allowed
trace content can never shadow the stdlib or user-registered packages.
Modules are *registered*, not executed: SQL only runs when something issues
`INCLUDE PERFETTO MODULE`. Trace processor never executes anything from the
sidecar of its own accord; the complete list of effects in trace processor is
(a) lazy SQL package registration and (b) descriptor pool merges. Macros,
startup commands, and server references are inert outside the UI.
#### Proto descriptor registration
Descriptors register into the trace processor descriptor pool via the same
mechanism as the in-stream `extension_descriptor` packet
(`AddFromFileDescriptorSet` with message merging). Because the metadata member
is parsed before all other members, the descriptors are live before any proto
trace tokenizes, so extended `TrackEvent` fields decode into `args` in trace
processor itself.
Unlike extension-server descriptors which the UI must deliver to trace
processor before parsing starts trace-bundled descriptors need no delivery
step at all: they register wherever and however the trace is parsed,
including `trace_processor_shell` invocations that involve no UI.
#### Exposing extensions to the UI
Trace processor stores the *resolved* extensions section `file` entries
materialized into their inline equivalents, except descriptor payloads which
are replaced by `{"size": N}` stubs since trace processor already consumed
them as a row in the `metadata` table under the key `trace_extensions`,
using the existing dynamic-metadata mechanism.
The UI reads it with plain SQL after load, the same way it already reads
`timezone_off_mins` and friends:
```sql
SELECT str_value FROM metadata WHERE name = 'trace_extensions'
```
This needs no new RPC surface, works identically in Wasm and HTTP-RPC mode,
and the JSON passthrough means UI-facing schema growth (new keys) requires no
trace processor changes.
### UI design
#### Loading
After the trace loads, the UI queries `trace_extensions` and parses it with a
zod schema reusing the existing macro / command-invocation / extension-server
schemas. A malformed row is logged and ignored (trace processor already
validated the authored file; this is defense in depth only).
#### Macros (passive — no prompt)
Inline macros register as **trace-scoped** commands (auto-disposed when the
trace closes), unlike extension-server macros which are app-lifetime. If a
macro's id collides with an existing command — a core command, a settings
macro, or an installed server's macro the trace's macro is skipped with a
console warning: trace content never shadows existing definitions.
Registering a macro has no effect until something invokes it, which is why no
consent is needed at this stage.
#### Server references
The `server` variant reuses the existing share-link install flow
(`addServer=<base64>`) verbatim:
- Server already configured (matched by location) with the needed modules
enabled → nothing to do, no prompt; its content is already loaded.
- Already configured but some `enabled_modules` missing → the edit-server
modal opens with the module sets merged for review.
- Unknown server → the add-server modal opens prefilled with the trace's
reference (auth empty).
The dialog *is* the consent: nothing is fetched or persisted until the user
confirms, and the resulting entry is an ordinary user-added server in
settings. Declining simply means the server's macros/SQL are unavailable;
dependent startup commands will fail visibly (below).
#### Startup commands (gated — prompt)
Startup commands auto-execute, so they are the one part of the sidecar behind
a consent gate. A new global tri-state setting controls trace-sourced startup
commands:
- `ask` (default): a per-trace dialog lists the commands (`id` + `args`) with
Run / Skip, plus shortcuts to flip the global setting to `always` or
`never`.
- `always`: run without prompting.
- `never`: ignore, with a notification that the trace carried commands.
Independent of the setting, trace-sourced commands are **always** run with
allowlist enforcement (the existing startup-command allowlist). The user
setting that can disable allowlist enforcement applies only to URL- and
settings-sourced commands. One nuance inherited from the existing
implementation, stated here explicitly: any registered macro is a valid
startup-command entry point, so a trace-bundled macro can be invoked — but
each nested command inside the macro is individually allowlist-checked when
prompts are disabled. A trace cannot smuggle a non-allowlisted command inside
a macro.
#### Ordering
On trace load:
1. Parse metadata, register descriptors / SQL packages (trace processor).
2. Resolve the server reference (prompt if needed) and register macros — all
passive content is in place first, so commands can reference it.
3. Run startup commands, sources in order: **trace → URL → settings**. Later
sources are more user-specific and should win: producer defaults, then the
sharer's intent encoded in the URL, then the user's own preferences. All
three run inside the existing prompts-disabled block, and blocked/failed
commands from all sources are reported in the existing issues dialog.
### Failure modes and edge cases
- **SQL package name collision** → skipped, recorded as a stat, surfaced
through the existing stats-based error UI.
- **Macro id collision** → skipped with a warning; existing definitions win.
- **Missing `file` member / member claimed twice / claimed member also in
`files[]`** → load error (authoring bug).
- **Server unreachable after install** → existing extension-server error
handling (toast, cached fallback); dependent startup commands fail into the
issues dialog.
- **`trace_processor_shell` / batch** → macros, startup commands, and server
refs are inert; only SQL modules and descriptors take effect.
- **Old trace processor** → `extensions` ignored entirely (unknown key under
version 1); trace loads as before.
- **Embedded UIs / automation** → embedders that disable the startup-command
machinery are unaffected; the gate sits in the same code path as the
existing URL/settings sources.
### Documentation
This feature touches several existing doc surfaces and finally forces a
reference page for the sidecar itself (currently undocumented):
- **New** `docs/reference/perfetto-metadata.md`: full format reference for the
`perfetto_metadata` sidecar (`version`, `trace_time_clock`, `files`,
`extensions`).
- **New** `docs/getting-started/bundling-analysis.md`: walkthrough for trace
producers — building the zip, writing the sidecar, authoring macros/SQL —
linked from the "Converting arbitrary data" guide's next steps.
(`docs/reference/synthetic-track-event.md` already serves as the advanced
synthetic-trace reference, so no restructuring of `converting.md` is
needed.)
- **Update** `docs/instrumentation/extensions.md`: bundled `proto_descriptors`
as a third descriptor-delivery path (alongside in-stream
`extension_descriptor` packets and extension servers).
- **Update** `docs/visualization/ui-automation.md`: trace-sourced startup
commands, the trust setting, and source ordering.
- **Update** `docs/visualization/extension-servers.md`: referencing a server
from a trace and the install flow.
- **Update** `docs/visualization/extending-the-ui.md`: add trace-bundled
extensions to the overview of extension points.
## Alternatives considered
### A proto packet inside the trace stream (UiState-style)
Define a packet (or extend `UiState`) carrying macros/commands/SQL in the
trace data itself.
Pro:
- Works for bare `.perfetto-trace` files without a zip envelope.
Con:
- Bakes viewer configuration into trace data the chrome://tracing failure
mode. Unstrippable without rewriting the trace, invisible to archive
tooling, and locks UI concepts into the stable trace proto schema.
- Requires proto schema changes for every future extension-content type.
### UI reads the sidecar itself
Have the UI unzip the archive and parse `perfetto_metadata` before handing
bytes to trace processor.
Pro:
- No trace processor changes for UI-only content.
Con:
- Duplicates zip + JSON + validation logic in TypeScript.
- Breaks entirely in HTTP-RPC mode, where the UI never sees trace bytes.
- Trace processor needs the data anyway for SQL modules and descriptors
(#6228), so the parsing would exist on both sides.
### Inline extension-server manifest
Model the trace as an extension server: embed a manifest (name, namespace,
features, modules) where features carry `type: "inline"` payloads, reusing the
server manifest format wholesale.
This was seriously considered it maximizes schema reuse and namespace rules
come from the manifest for free but rejected:
- The manifest/modules/features indirection exists to let users subscribe to
subsets of a long-lived server. A trace is a flat, one-shot payload; the
indirection is pure overhead (we had already reduced it to "only a `default`
module allowed" before abandoning it).
- It conflates two lifetimes: installed servers persist across sessions,
trace content is scoped to one trace.
- Startup commands don't fit a server manifest. Making them a server feature
would mean an installed server can auto-run commands on *every* future
trace — a standing-consent trap. They must stay a per-trace concept.
### A separate sidecar file format
A new `perfetto_extensions.json` next to `perfetto_metadata.json`.
Con:
- A second content sniffer, a second must-be-early ordering rule, and a second
versioning story, for no benefit. `perfetto_metadata` already owns
"configure how this archive is interpreted" and its ignore-unknown-keys
behavior under version 1 provides backwards compatibility for free.
## Open questions
- Should the server reference support an integrity pin (e.g. a manifest hash)
so a trace cannot silently start pulling different content over time?
- Multiple server references per trace: defer until a real producer needs it?
- Severity and surfacing of the skipped-SQL-package stat (info vs error), and
whether the UI should toast on it.
- Should `trace_extensions` expose full descriptor payloads to the UI instead
of size stubs (e.g. for a future "inspect bundled extensions" page)?
- Should `trace_processor_shell` grow a flag to ignore or dump the extensions
section (e.g. `--extensions=ignore|dump`)?