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: 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: 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) 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 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

{
  "perfetto_metadata": {
    "version": 1,
    "extensions": {
      "type": "inline",
      "namespace": "androidx.benchmark",
      "macros": [],
      "sql_modules": [],
      "proto_descriptors": [],
      "startup_commands": []
    }
  }
}
{
  "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:

"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:

ListReferenced member contains
sql_modulesUTF-8 PerfettoSQL source (the module body)
proto_descriptorsraw binary FileDescriptorSet (no base64)
macrosJSON array of macro objects (same shape as inline)
startup_commandsJSON 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:

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
{
  "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:

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 processorextensions 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)?