UI Plugins

UI plugins allow developers to add new visualizations and analysis tools directly into the Perfetto interface. By leveraging a rich set of extension points, plugins can tailor Perfetto to specific use cases.

This guide provides comprehensive instructions on how to create and contribute UI plugins to Perfetto.

If this is your first time contributing to Perfetto, please first follow Perfetto getting started and then UI getting started.

Note: All plugins are currently in-tree, that is they're located in the open source Perfetto codebase and served along with the public build of Perfetto located at https://ui.perfetto.dev. If you wish to add closed-source plugins, you will need to fork and host your own version of Perfetto. There is no way, currently, to side-load closed-source plugins.

The plugins that start with 'com.example' here provide live examples of the features listed in this doc, so please do take a look at those if one is present for your particular feature.

The public plugin API, which you'll be using in this doc, can be browsed here.

Getting started

Copy the skeleton plugin:

cp -r ui/src/plugins/com.example.Skeleton ui/src/plugins/<your-plugin-name>

Now edit ui/src/plugins/<your-plugin-name>/index.ts. Search for all instances of SKELETON: <instruction> in the file and follow the instructions.

Notes on naming:

Start the dev server

ui/run-dev-server

Now navigate to localhost:10000

Enabling your plugin

You can request for your plugin to be enabled by default. Follow the default plugins section for this.

Upload your plugin for review

Plugin lifecycle

onActivate is called once when the app first starts up, passing in the App object. This object can be used to register core extensions such as pages, commands and sidebar links, which will be available before a trace is loaded.

When the user loads a trace the plugin class is instantiated and onTraceLoad is called passing in the Trace object. This object can be used to register extensions which are scoped to the lifetime of that particular trace such as tracks, tabs and workspaces.

All extensions that can be registered on the app object can also be registered on the trace object, but these extensions only last for the lifetime of the trace. For example, a command registered on the trace object will only be available while that trace is loaded, and will disappear when switching traces. Typically, if this is done in the onTraceLoad() hook then the extension is re-registered automatically with every new trace that is loaded.

Note: Don't put any code the main body of the plugin file as there is no guarantee the core will be set up by that point. Instead, wait for the core to call the plugin either via onActivate or onTraceLoad.

To demonstrate the lifecycle of a plugin, let's examine a minimal plugin that implements the key lifecycle hooks and logs to the terminal:

export default class implements PerfettoPlugin { static readonly id = 'com.example.MyPlugin'; static onActivate(app: App): void { // Called once on app startup console.log('MyPlugin::onActivate()', app.pluginId); // Note: It's rare that plugins would need this hook as most plugins are // interested in trace details. Thus, this function can usually be omitted. } constructor(trace: Trace) { // Called each time a trace is loaded console.log('MyPlugin::constructor()', trace.traceInfo.traceTitle); } async onTraceLoad(trace: Trace): Promise<void> { // Called each time a trace is loaded console.log('MyPlugin::onTraceLoad()', trace.traceInfo.traceTitle); // Note this function returns a promise, so any any async calls should be // completed before this promise resolves as the app using this promise for // timing and plugin synchronization. } }

Run this plugin with devtools to see the log messages in the console, which should give you a feel for the plugin lifecycle. Try opening a few traces one after another.

Performance

onActivate() and onTraceLoad() should generally complete as quickly as possible, however sometimes onTraceLoad() may need to perform async operations on trace processor such as performing queries and/or creating views and tables. Thus, onTraceLoad() should return a promise (or you can simply make it an async function). When this promise resolves it tells the core that the plugin is fully initialized.

Note: It's important that any async operations done in onTraceLoad() are awaited so that all async operations are completed by the time the promise is resolved. This is so that plugins can be properly timed and synchronized.

// GOOD async onTraceLoad(trace: Trace) { await trace.engine.query(...); } // BAD async onTraceLoad(trace: Trace) { // Note the missing await! trace.engine.query(...); }

Plugin API

For more detailed information and documentation please consult the API source in ui/src/public/ or one of the many example plugins (that start with com.example.*) in ui/src/plugins/.

Getting the trace object from the app object

When a trace is loaded, app.trace will return the current trace object, or undefined if no trace is loaded.

Querying the trace

As soon as the plugin obtains a trace, it can execute queries against it using the trace's engine property.

const result = await trace.engine.query('select * from slice'); const schema = {id: NUM, ts: LONG, dur: LONG, name: STR}; for (const iter = result.iter(schema); iter.valid(); iter.next()) { console.log(iter.id, iter.ts, iter.dur, iter.name); }

Typically queries returns a list of rows, which can be iterated through like in the example.

The schema:

Note: The problem with JavaScript numbers. A javascript number type is actually a double precision float, and thus can only represent integers up to 2^53-1. Trace processor can represent 64 bit integers, so when converting to js numbers, we can lost precision. THis is a problem for large numbers such as timestamps and durations.

The possible schema types are as follows:

Selections

Plugins can programmatically control what is selected in the Perfetto UI. This is primarily done using methods available on the trace.selection object.

You typically want to select an entity to find out more information about that entity, which is displayed in the current selection panel. Selections are usually invoked by the user, but can be controlled programmatically too.

You can always access the current selection details via trace.selection.selection. This object has a kind property (e.g., 'track_event', 'area', 'note', 'empty') and other properties specific to the type of selection. The optional SelectionOpts object can be passed to selection methods to influence UI behaviors like automatically scrolling to the selection or switching to the "Current Selection" tab.

Selection Options (SelectionOpts)

The SelectionOpts object can be passed to most selection methods to customize the UI's response to a new selection. It has the following optional properties:

Selecting a Track Event (event, slice, counter sample, etc)

To select an individual event on a track:

trace.selection.selectTrackEvent('my.track', 123);

Selecting an Area (Time Range)

To select a specific time range, potentially across multiple tracks. The Area object requires start (time), end (time), and an array of trackUris (string[]).

trace.selection.selectArea({ start: Time.fromRaw(123n), // Time in nanoseconds (bigint) end: Time.fromRaw(456n), // Time in nanoseconds (bigint) trackUris: ['track.foo', 'track.bar'], // Array of track URIs to include });

Selecting an Entire Track

Selecting an entire track highlights it in the timeline and displays track details in the drawer.

trace.selection.selectTrack('my.track');

Selecting an Event via SQL Table and ID

If you have an event's ID from a specific SQL table (e.g., slice table) but not its direct track URI, Perfetto can attempt to resolve and select it. Certain tracks do directly represent rows in the well known tables but it's up to the plugin developers whether or not these are wired up properly.

trace.selection.selectSqlEvent('slice', 123);

Clearing the Current Selection

To deselect whatever is currently selected in the UI:

trace.selection.clearSelection();

Pinning Tracks

A common task for plugins are to pin certain interesting tracks (usually as a result of a command).

This can be achieved by finding the appropriate track in the workspace and calling its pin() method. This will pin it to the top of its parent workspace.

trace.workspace .flatTracks() .find((t) => t.name.startsWith('foo')) .forEach((t) => t.pin());

Workspaces

Workspaces are the primary containers for organizing and displaying tracks in the Perfetto UI. They allow users to manage different views of trace data, save track layouts, and switch between them. Plugins can interact with workspaces to add, remove, and arrange tracks, as well as create and manage custom workspaces.

The main interfaces and classes related to workspaces are WorkspaceManager, Workspace, and TrackNode. These are typically accessed via trace.workspaces (for the manager) and trace.workspace (for the current active workspace) once a trace is loaded.

Workspace Manager (trace.workspaces)

The WorkspaceManager provides an overview and control over all available workspaces. It's accessible via trace.workspaces.

Key methods and properties:

Example: Creating and Switching to a New Workspace

// Assuming 'trace' is the Trace object const newWorkspace = trace.workspaces.createEmptyWorkspace('My Custom Analysis'); trace.workspaces.switchWorkspace(newWorkspace); console.log(`Switched to workspace: ${newWorkspace.title}`);

Workspace (trace.workspace or an instance from WorkspaceManager)

A Workspace object represents a single layout of tracks, including a main track area and a pinned track area.

Key properties:

Key methods:

Track Node (TrackNode)

TrackNode is the fundamental building block for structuring tracks within a workspace. A TrackNode can represent an individual track (if it has a uri pointing to a TrackRenderer) or a group of tracks (if it has children).

Creating a TrackNode:

import {TrackNode} from '../../public'; // Adjust path as needed // Node for an actual track const myRenderableTrackNode = new TrackNode({ name: 'My Slice Track', uri: 'plugin.id#mySliceTrackUri', // URI of a registered Track sortOrder: 100, removable: true, }); // Node for a group const myGroupNode = new TrackNode({ name: 'My Analysis Group', sortOrder: 50, collapsed: false, // Start expanded });

TrackNodeArgs (Constructor Arguments):

When creating a TrackNode, you can pass an optional object with the following properties (defined in [TrackNodeArgs]):

Key TrackNode Properties:

Key TrackNode Methods:

Example: Building a Track Hierarchy

// Assuming 'trace' is the Trace object and 'workspace' is trace.workspace const parentGroup = new TrackNode({name: 'CPU Analysis'}); workspace.addChildLast(parentGroup); const cpu0FreqTrack = new TrackNode({ name: 'CPU 0 Frequency', uri: 'perfetto.CpuFrequency#cpu0', // Example URI sortOrder: 10, }); parentGroup.addChildInOrder(cpu0FreqTrack); const cpu1FreqTrack = new TrackNode({ name: 'CPU 1 Frequency', uri: 'perfetto.CpuFrequency#cpu1', // Example URI sortOrder: 20, }); parentGroup.addChildInOrder(cpu1FreqTrack); parentGroup.expand(); // Show the CPU frequency tracks cpu0FreqTrack.pin(); // Pin CPU 0 frequency track

This structure allows plugins to dynamically build complex and organized track layouts tailored to specific analysis tasks. Remember to register your actual TrackRenderers using trace.tracks.registerTrack before creating TrackNodes that reference their URIs.

Commands

Commands are user issuable shortcuts for actions in the UI. They are typically invoked by the user via the command palette which can be opened by pressing Ctrl+Shift+P (or Cmd+Shift+P on Mac), or by typing a > into the omnibox, but can also be invoked programmatically.

Registering Commands

To add a command, the CommandManager (available as app.commands or trace.commands) provides the registerCommand method for this purpose.

registerCommand(command: { id: string; name: string; callback: (...args: any[]) => any; defaultHotkey?: Hotkey }): void;

Registers a new command. Takes a Command object which looks like this:

See hotkey.ts for the available hotkey keys and modifiers.

Note: This is referred to as the 'default' hotkey because we are reserving the right to add a feature in the future where users can modify their hotkeys.

Example

appOrTrace.commands.registerCommand({ id: `${app.pluginId}#sayHello`, name: 'Say hello', callback: () => console.log('Hello, world!'), });

Notes on naming:

Invoking Commands

Besides registering their own commands, plugins can also invoke any existing command by its ID. This allows plugins to trigger actions provided by other plugins or by the Perfetto core. The CommandManager (available as app.commands or trace.commands) provides the runCommand method for this purpose.

runCommand(commandId: string, ...args: any[]): any;

Executes the command identified by commandId, passing any additional arguments to the command's callback. It returns a Promise that resolves with the result of the command's callback, if any.

Example:

// PluginA appOrTrace.commands.registerCommand({ id: 'PluginA#increment', name: 'Increment', callback: (num) => num + 1, }); // PluginB try { const result = appOrTrace.commands.runCommand('PluginA#increment', 1); // result should be 2 } catch (e) { console.error(`Failed to run command: ${(e as Error).message}`); }

Plugins can discover command IDs by looking at other plugins' registrations or by referring to documentation for core commands.

Examples:

Tracks

In order to add a new track to the timeline, you'll need to create two entities:

Tracks are the main way timeseries data is added to the UI.

To add a track use trace.tracks.registerTrack.

registerTrack(track: { uri: string; track: TrackRenderer; description?: string; subtitle?: string; tags?: TrackTags; chips?: ReadonlyArray<string>; }): void;

Registers a new track with Perfetto. Pass a Track object which includes:

Track renderers are powerful but complex so it's strongly advised not to create your own. Instead, by far the easiest way to get started with tracks is to use the createQuerySliceTrack and createQueryCounterTrack helpers.

Example:

import {createQuerySliceTrack} from '../../components/tracks/query_slice_track'; // ~~ snip ~~ const uri = `${trace.pluginId}#MyTrack`; // Create a new track renderer based on a query const renderer = await createQuerySliceTrack({ trace, uri, data: { sqlSource: 'select * from slice where track_id = 123', }, }); // Register the track renderer with the core trace.tracks.registerTrack({uri, renderer}); // Create a track node that references the track using its uri const trackNode = new TrackNode({uri, name: 'My Track'}); // Add the track node to the current workspace trace.workspace.addChildInOrder(trackNode);

See the source for detailed usage.

You can also add a counter track using createQueryCounterTrack which works in a similar way.

import {createQueryCounterTrack} from '../../components/tracks/query_counter_track'; export default class implements PerfettoPlugin { static readonly id = 'com.example.MyPlugin'; async onTraceLoad(trace: Trace) { const title = 'My Counter Track'; const uri = `${trace.pluginId}#MyCounterTrack`; const query = 'select * from counter where track_id = 123'; // Create a new track renderer based on a query const renderer = await createQueryCounterTrack({ trace, uri, data: { sqlSource: query, }, }); // Register the track renderer with the core trace.tracks.registerTrack({uri, title, renderer}); // Create a track node that references the track using its uri const trackNode = new TrackNode({uri, title}); // Add the track node to the current workspace trace.workspace.addChildInOrder(trackNode); } }

See the source for detailed usage.

Grouping Tracks

Any track can have children. Just add child nodes any TrackNode object using its addChildXYZ() methods. Nested tracks are rendered as a collapsible tree.

const group = new TrackNode({title: 'Group'}); trace.workspace.addChildInOrder(group); group.addChildLast(new TrackNode({title: 'Child Track A'})); group.addChildLast(new TrackNode({title: 'Child Track B'})); group.addChildLast(new TrackNode({title: 'Child Track C'}));

Tracks nodes with children can be collapsed and expanded manually by the user at runtime, or programmatically using their expand() and collapse() methods. By default tracks are collapsed, so to have tracks automatically expanded on startup you'll need to call expand() after adding the track node.

group.expand();

Nested tracks

Summary tracks are behave slightly differently to ordinary tracks. Summary tracks:

To create a summary track, set the isSummary: true option in its initializer list at creation time or set its isSummary property to true after creation.

const group = new TrackNode({title: 'Group', isSummary: true}); // ~~~ or ~~~ group.isSummary = true;

Summary track

Examples

Track Ordering

Tracks can be manually reordered using the addChildXYZ() functions available on the track node api, including addChildFirst(), addChildLast(), addChildBefore(), and addChildAfter().

See the workspace source for detailed usage.

However, when several plugins add tracks to the same node or the workspace, no single plugin has complete control over the sorting of child nodes within this node. Thus, the sortOrder property is be used to decentralize the sorting logic between plugins.

In order to do this we simply give the track a sortOrder and call addChildInOrder() on the parent node and the track will be placed before the first track with a higher sortOrder in the list. (i.e. lower sortOrders appear higher in the stack).

// PluginA workspace.addChildInOrder(new TrackNode({title: 'Foo', sortOrder: 10})); // Plugin B workspace.addChildInOrder(new TrackNode({title: 'Bar', sortOrder: -10}));

Now it doesn't matter which order plugin are initialized, track Bar will appear above track Foo (unless reordered later).

If no sortOrder is defined, the track assumes a sortOrder of 0.

It is recommended to always use addChildInOrder() in plugins when adding tracks to the workspace, especially if you want your plugin to be enabled by default, as this will ensure it respects the sortOrder of other plugins.

DatasetSliceTrack

DatasetSliceTrack is a versatile track renderer class that allows for more fine-grained control over the behavior and appearance of slice-based tracks. It's the underlying component used by createQuerySliceTrack but offers a richer set of customization options.

To use DatasetSliceTrack, you instantiate it with DatasetSliceTrackAttrs, which include:

Example:

const trackUri = `${trace.pluginId}#MyCustomSliceTrack`; // Define your dataset const myDataset: SourceDataset<MySliceRow> = { name: 'my_custom_slices', // A descriptive name schema: { id: NUM, ts: LONG, name: STR, category: STR, dur: LONG, // Assuming your events have duration depth: NUM, // Assuming you want to control depth }, query: ` SELECT slice_id as id, ts, dur, depth, name, category FROM my_slice_table_or_view `, }; const renderer = new DatasetSliceTrack<MySliceRow>({ trace, uri: trackUri, dataset: myDataset, sliceName: (row) => `${row.category}: ${row.name}`, colorizer: (row) => { if (row.category === 'important') { return {background: '#FF0000', foreground: '#FFFFFF'}; // Red } return {background: '#0000FF', foreground: '#FFFFFF'}; // Blue }, tooltip: (slice) => { return m('div', [ m('div', `Name: ${slice.row.name}`), m('div', `Category: ${slice.row.category}`), m('div', `Duration: ${formatDuration(trace, slice.dur)}`), ]); }, // Add other customizers like detailsPanel, fillRatio etc. }); // Register the track renderer trace.tracks.registerTrack({ uri: trackUri, title: 'My Custom Slices', renderer, }); // Add the track node to the workspace as normal const trackNode = new TrackNode({ uri: trackUri, title: 'My Custom Slices', }); trace.workspace.addChildInOrder(trackNode);

This approach gives you significant flexibility in how your track data is queried, processed, and displayed. Remember to consult the source code of DatasetSliceTrack and related interfaces for the most up-to-date details and advanced usage patterns.

Tabs

Tabs are a useful way to display contextual information about the trace, the current selection, or to show the results of an operation.

To register a tab from a plugin, use the Trace.registerTab method.

class MyTab implements Tab { render(): m.Children { return m('div', 'Hello from my tab'); } getTitle(): string { return 'My Tab'; } } export default class implements PerfettoPlugin { static readonly id = 'com.example.MyPlugin'; async onTraceLoad(trace: Trace) { trace.registerTab({ uri: `${trace.pluginId}#MyTab`, content: new MyTab(), }); } }

You'll need to pass in a tab-like object, something that implements the Tab interface. Tabs only need to define their title and a render function which specifies how to render the tab.

Registered tabs don't appear immediately - we need to show it first. All registered tabs are displayed in the tab dropdown menu, and can be shown or hidden by clicking on the entries in the drop down menu.

Tabs can also be hidden by clicking the little x in the top right of their handle.

Alternatively, tabs may be shown or hidden programmatically using the tabs API.

trace.tabs.showTab(`${trace.pluginId}#MyTab`); trace.tabs.hideTab(`${trace.pluginId}#MyTab`);

Tabs have the following properties:

Ephemeral Tabs

By default, tabs are registered as 'permanent' tabs. These tabs have the following additional properties:

Ephemeral tabs, by contrast, have the following properties:

Ephemeral tabs can be registered by setting the isEphemeral flag when registering the tab.

trace.registerTab({ isEphemeral: true, uri: `${trace.pluginId}#MyTab`, content: new MyEphemeralTab(), });

Ephemeral tabs are usually added as a result of some user action, such as running a command. Thus, it's common pattern to register a tab and show the tab simultaneously.

Motivating example:

import m from 'mithril'; import {uuidv4} from '../../base/uuid'; class MyNameTab implements Tab { constructor(private name: string) {} render(): m.Children { return m('h1', `Hello, ${this.name}!`); } getTitle(): string { return 'My Name Tab'; } } export default class implements PerfettoPlugin { static readonly id = 'com.example.MyPlugin'; async onTraceLoad(trace: Trace): Promise<void> { trace.registerCommand({ id: `${trace.pluginId}#AddNewEphemeralTab`, name: 'Add new ephemeral tab', callback: () => handleCommand(trace), }); } } function handleCommand(trace: Trace): void { const name = prompt('What is your name'); if (name) { const uri = `${trace.pluginId}#MyName${uuidv4()}`; // This makes the tab available to perfetto ctx.registerTab({ isEphemeral: true, uri, content: new MyNameTab(name), }); // This opens the tab in the tab bar ctx.tabs.showTab(uri); } }

Sidebar Menu Items

Plugins can add new entries to the sidebar menu which appears on the left hand side of the UI. These entries can include:

Commands

If a command is referenced, the command name and hotkey are displayed on the sidebar item.

trace.commands.registerCommand({ id: 'sayHi', name: 'Say hi', callback: () => window.alert('hi'), defaultHotkey: 'Shift+H', }); trace.sidebar.addMenuItem({ commandId: 'sayHi', section: 'support', icon: 'waving_hand', });

Links

If an href is present, the sidebar will be used as a link. This can be an internal link to a page, or an external link.

trace.sidebar.addMenuItem({ section: 'navigation', text: 'Plugins', href: '#!/plugins', });

Callbacks

Sidebar items can be instructed to execute arbitrary callbacks when the button is clicked.

trace.sidebar.addMenuItem({ section: 'current_trace', text: 'Copy secrets to clipboard', action: () => copyToClipboard('...'), });

If the action returns a promise, the sidebar item will show a little spinner animation until the promise returns.

trace.sidebar.addMenuItem({ section: 'current_trace', text: 'Prepare the data...', action: () => new Promise((r) => setTimeout(r, 1000)), });

Optional params for all types of sidebar items:

See the sidebar source for more detailed usage.

Pages

Pages are entities that can be routed via the URL args, and whose content take up the entire available space to the right of the sidebar and underneath the topbar. Examples of pages are the timeline, record page, and query page, just to name a few common examples.

E.g.

http://ui.perfetto.dev/#!/viewer <-- 'viewer' is is the current page.

Pages are added from a plugin by calling the pages.registerPage function.

Pages may be registered with the trace or the app contexts. Pages registered with the trace are automatically removed when switching traces. Traces registered on the app will help will appear before a trace is loaded.

Traces registered with the app should be done so in onActivate(), while traces registered with the trace should be done in onTraceLoad().

A page is simply a render function which is called every Mithril render cycle while that page is active. It should return the mithril components which will be displayed within the page area. Within the render function, just render mithril components as normal.

trace.pages.registerPage({ route: '/mypage', render: () => m('', 'Hello from my page!'), });

Subpage

The render() callback takes a single argument subpage which is an optional string that is defined which defines the sub-route if present. E.g. anything after the first / after the page #!/<route>/<subpage>. This can be used to add additional sub-sections to your page.

Examples:

Statusbar

Plugins can add items to the statusbar, which is displayed at the bottom of the UI.

To add a statusbar item from a plugin, use the trace.statusbar.registerItem method.

trace.statusbar.registerItem({ renderItem: () => ({ label: 'My Statusbar Item', icon: 'settings', onclick: () => console.log('Statusbar item clicked'), }), popupContent: () => m('div', 'Hello from my statusbar item popup'), });

The renderItem callback should return an object with the following properties:

The popupContent callback is optional and should return mithril content to be displayed in a popup when the statusbar item is clicked.

Omnibox Prompts

Plugins can leverage the omnibox to prompt users for input. This is more integrated than a standard browser window.prompt() and can be used for free-form text or selecting from a predefined list of choices. The OmniboxManager is available via app.omnibox (in onActivate) or trace.omnibox (in onTraceLoad).

The primary method is prompt():

The promise resolves with the user's input/selection, or undefined if the user dismisses the prompt (e.g., by pressing Escape).

Examples:

1. Free-form input:

// In onActivate or onTraceLoad // const appOrTrace: App | Trace = ...; async function askForName(omnibox: OmniboxManager) { const name = await omnibox.prompt( 'Enter a friendly name for the new marker:', ); if (name) { console.log(`User entered: ${name}`); // Proceed with the name } else { console.log('User cancelled the prompt.'); } } // To call it: // askForName(appOrTrace.omnibox);

2. Simple list of choices:

async function chooseColor(omnibox: OmniboxManager) { const color = await omnibox.prompt('Choose a highlight color:', [ 'red', 'green', 'blue', 'yellow', ]); if (color) { console.log(`User chose: ${color}`); // Apply the color } } // chooseColor(appOrTrace.omnibox);

3. List of custom objects:

interface ProcessChoice { pid: number; name: string; threadCount: number; } async function selectProcess( omnibox: OmniboxManager, processes: ProcessChoice[], ) { const selectedProcess = await omnibox.prompt<ProcessChoice>( 'Select a process to focus on:', { values: processes, getName: (p) => `${p.name} (PID: ${p.pid}, Threads: ${p.threadCount})`, }, ); if (selectedProcess) { console.log( `User selected process: ${selectedProcess.name} (PID: ${selectedProcess.pid})`, ); // Focus on the selected process } } // const exampleProcesses: ProcessChoice[] = [ // {pid: 123, name: 'system_server', threadCount: 150}, // {pid: 456, name: 'com.example.app', threadCount: 25}, // ]; // selectProcess(appOrTrace.omnibox, exampleProcesses);

This feature allows for creating interactive workflows directly within the omnibox, guided by your plugin.

Area Selection Tabs

Plugins can register tabs to be displayed in the details panel when an area of the timeline is selected.

To register an area selection tab, use the trace.selection.registerAreaSelectionTab method.

trace.selection.registerAreaSelectionTab({ id: 'my-area-selection-tab', name: 'My Area Selection Tab', render: (selection) => { return m('div', `Selected area: ${selection.start} - ${selection.end}`); }, });

The render callback should return mithril content to be displayed in the tab. The selection argument is an AreaSelection object, which contains information about the selected area.

Examples:

Metric Visualisations

TBD

Examples:

State

NOTE: It is important to consider version skew when using persistent state.

Plugins can persist information into permalinks. This allows plugins to gracefully handle permalinking and is an opt-in - not automatic - mechanism.

Persistent plugin state works using a Store<T> where T is some JSON serializable object. Store is implemented here. Store allows for reading and writing T. Reading:

interface Foo { bar: string; } const store: Store<Foo> = getFooStoreSomehow(); // store.state is immutable and must not be edited. const foo = store.state.foo; const bar = foo.bar; console.log(bar);

Writing:

interface Foo { bar: string; } const store: Store<Foo> = getFooStoreSomehow(); store.edit((draft) => { draft.foo.bar = 'Hello, world!'; }); console.log(store.state.foo.bar); // > Hello, world!

First define an interface for your specific plugin state.

interface MyState { favouriteSlices: MySliceInfo[]; }

To access permalink state, call mountStore() on your Trace object, passing in a migration function.

export default class implements PerfettoPlugin { static readonly id = 'com.example.MyPlugin'; async onTraceLoad(trace: Trace): Promise<void> { const store = trace.mountStore(migrate); } } function migrate(initialState: unknown): MyState { // ... }

When it comes to migration, there are two cases to consider:

In case of a new trace, your migration function is called with undefined. In this case you should return a default version of MyState:

const DEFAULT = {favouriteSlices: []}; function migrate(initialState: unknown): MyState { if (initialState === undefined) { // Return default version of MyState. return DEFAULT; } else { // Migrate old version here. } }

In the permalink case, your migration function is called with the state of the plugin store at the time the permalink was generated. This may be from an older or newer version of the plugin.

Plugins must not make assumptions about the contents of initialState!

In this case you need to carefully validate the state object. This could be achieved in several ways, none of which are particularly straight forward. State migration is difficult!

One brute force way would be to use a version number.

interface MyState { version: number; favouriteSlices: MySliceInfo[]; } const VERSION = 3; const DEFAULT = {favouriteSlices: []}; function migrate(initialState: unknown): MyState { if (initialState && (initialState as {version: any}).version === VERSION) { // Version number checks out, assume the structure is correct. return initialState as State; } else { // Null, undefined, or bad version number - return default value. return DEFAULT; } }

You'll need to remember to update your version number when making changes! Migration should be unit-tested to ensure compatibility.

Examples:

Feature Flags

Plugins can register feature flags, which allow users to toggle experimental or developmental features on or off. This is useful for rolling out new functionality gradually or providing options for advanced users. Feature flags are typically registered in the onActivate lifecycle hook using the app.featureFlags manager.

To register a feature flag, you provide FlagSettings:

The register method returns a Flag object, which provides methods to interact with the flag's state:

Example:

import {App, Flag, FlagSettings} from '../../public'; // Adjust path as needed export default class implements PerfettoPlugin { static readonly id = 'com.example.MyFeatureFlagPlugin'; private static myCoolFeatureFlag: Flag; static onActivate(app: App): void { const flagSettings: FlagSettings = { id: `${this.id}#myCoolFeature`, name: 'Enable My Cool Feature', defaultValue: false, description: 'This flag enables a super cool experimental feature that does X, Y, and Z.', devOnly: true, // Optional: only for dev builds }; this.myCoolFeatureFlag = app.featureFlags.register(flagSettings); // You can immediately check its state or use it to gate other registrations if (this.myCoolFeatureFlag.get()) { console.log('My Cool Feature is enabled!'); // Register other components that depend on this flag } } async onTraceLoad(trace: Trace): Promise<void> { // Example of using the flag later if (MyFeatureFlagPlugin.myCoolFeatureFlag.get()) { // Add tracks or tabs related to this feature trace.sidebar.addMenuItem({ section: 'current_trace', text: 'Cool Feature Action', action: () => alert('Cool feature activated!'), }); } } }

Users can typically manage these flags through a dedicated "Flags" page in the Perfetto UI, where they can see descriptions and toggle them.

Custom Settings

Plugins can define and register their own settings, allowing users to customize plugin behavior. These settings are managed through the SettingsManager, available via app.settings (typically in onActivate) or trace.settings. Registered settings appear in the main Perfetto settings page.

To register a setting, you provide a SettingDescriptor<T>:

The settings.register() method returns a Setting<T> object, which extends the descriptor and provides methods to interact with the setting:

Example:

import {App, Setting, SettingDescriptor} from '../../public'; // Adjust path import {z} from 'zod'; import m from 'mithril'; // Define a Zod schema for a complex setting const MyComplexObjectSchema = z.object({ optionA: z.string().min(1), optionB: z.number().int().positive(), }); type MyComplexObject = z.infer<typeof MyComplexObjectSchema>; export default class implements PerfettoPlugin { static readonly id = 'com.example.MySettingsPlugin'; private static simpleBooleanSetting: Setting<boolean>; private static complexObjectSetting: Setting<MyComplexObject>; static onActivate(app: App): void { // 1. A simple boolean setting const boolSettingDesc: SettingDescriptor<boolean> = { id: `${this.id}#enableSimpleFeature`, name: 'Enable Simple Feature', description: 'Toggles a basic feature on or off.', schema: z.boolean(), defaultValue: true, requiresReload: false, }; this.simpleBooleanSetting = app.settings.register(boolSettingDesc); // 2. A more complex object-based setting with a custom renderer const complexSettingDesc: SettingDescriptor<MyComplexObject> = { id: `${this.id}#complexConfig`, name: 'Complex Configuration', description: 'Configure advanced options A and B.', schema: MyComplexObjectSchema, defaultValue: {optionA: 'defaultA', optionB: 10}, render: (setting: Setting<MyComplexObject>) => { const currentValue = setting.get(); return m('div.custom-setting-container', [ m('label', 'Option A:'), m('input[type=text]', { value: currentValue.optionA, oninput: (e: Event) => { const target = e.target as HTMLInputElement; setting.set({...currentValue, optionA: target.value}); }, }), m('label', 'Option B (number):'), m('input[type=number]', { value: currentValue.optionB, oninput: (e: Event) => { const target = e.target as HTMLInputElement; setting.set({ ...currentValue, optionB: parseInt(target.value, 10) || 0, }); }, }), m('button', {onclick: () => setting.reset()}, 'Reset to Default'), setting.isDefault ? m('span', ' (Default)') : null, ]); }, }; this.complexObjectSetting = app.settings.register(complexSettingDesc); // Using the setting value if (this.simpleBooleanSetting.get()) { console.log('Simple feature is ON'); } const complexConf = this.complexObjectSetting.get(); console.log( `Complex config: A=${complexConf.optionA}, B=${complexConf.optionB}`, ); } // ... other plugin methods }

Using Zod schemas ensures that settings are type-safe and validated, preventing invalid data from being stored. Custom renderers provide a powerful way to create intuitive UIs for complex settings.

Examples:

Logging Analytics and Errors

Plugins can contribute to Perfetto's internal analytics by logging custom events and errors. This helps in understanding feature usage and identifying issues. The analytics interface is available via app.analytics (in onActivate) or trace.analytics (in onTraceLoad).

The Analytics interface provides the following methods:

Example:

import {App, Trace, Analytics, ErrorDetails} from '../../public'; // Adjust path export default class implements PerfettoPlugin { static readonly id = 'com.example.MyAnalyticsPlugin'; static onActivate(app: App): void { if (app.analytics.isEnabled()) { app.analytics.logEvent(null, `${this.id}:Activated`); } } async onTraceLoad(trace: Trace): Promise<void> { if (trace.analytics.isEnabled()) { trace.analytics.logEvent('User Actions', `${this.id}:TraceLoaded`); } // Example of logging a custom action this.performSomeAction(trace.analytics); } private performSomeAction(analytics: Analytics) { try { // ... some plugin logic ... if (analytics.isEnabled()) { analytics.logEvent(null, `${MyAnalyticsPlugin.id}:SomeActionSuccess`); } } catch (e) { if (analytics.isEnabled()) { const errorDetails: ErrorDetails = { message: `Error in ${MyAnalyticsPlugin.id}.performSomeAction: ${ (e as Error).message }`, stack: (e as Error).stack, }; analytics.logError(errorDetails); } // Optionally re-throw or handle the error } } }

By using the provided analytics interface, plugins can integrate their telemetry with the main application in a consistent way.

Adding Timeline Notes and Spans

Plugins can add visual markers (notes) and highlighted time ranges (span notes) directly onto the timeline. This is useful for drawing attention to specific points or durations based on plugin-specific logic or user actions. The NoteManager is available via trace.notes within the onTraceLoad hook or any context where the Trace object is accessible.

Key Interfaces:

Using the NoteManager:

Example:

import {Trace, time} from '../../public'; // Adjust path as needed export default class implements PerfettoPlugin { static readonly id = 'com.example.MyTimelineNotesPlugin'; async onTraceLoad(trace: Trace): Promise<void> { // Example: Add a point note at 10 seconds into the trace const noteId = trace.notes.addNote({ timestamp: time.fromSeconds(10), text: 'Interesting event occurred here!', color: '#FF00FF', // Magenta }); console.log(`Added note with ID: ${noteId}`); // Example: Add a span note from 15s to 20s const spanNoteId = trace.notes.addSpanNote({ start: time.fromSeconds(15), end: time.fromSeconds(20), text: 'Critical duration under investigation', color: 'rgba(255, 165, 0, 0.5)', // Orange, semi-transparent }); console.log(`Added span note with ID: ${spanNoteId}`); // Later, you could retrieve a note if needed const retrievedNote = trace.notes.getNote(noteId); if (retrievedNote) { console.log('Retrieved note text:', retrievedNote.text); } } }

These notes are visually represented on the timeline's marker track, providing a way for plugins to annotate the trace dynamically.

Controlling the Minimap

Plugins can provide custom data to be displayed on the global timeline minimap. This allows visualization of high-level overviews of plugin-specific data across the entire trace duration. The MinimapManager is available via trace.minimap within the onTraceLoad hook.

To contribute content, a plugin must register a MinimapContentProvider:

Using the MinimapManager:

Example:

import { Trace, MinimapContentProvider, MinimapRow, MinimapCell, HighPrecisionTimeSpan, duration, time, } from '../../public'; // Adjust path class MyMinimapDataProvider implements MinimapContentProvider { readonly priority = 10; // Example priority async getData( timeSpan: HighPrecisionTimeSpan, resolution: duration, ): Promise<MinimapRow[]> { // In a real implementation, you would query Trace Processor or use other // plugin data sources to generate cells based on the timeSpan and resolution. // This example generates a single row with some dummy data. const cells: MinimapCell[] = []; let currentTs = timeSpan.start; const step = resolution; // Use the provided resolution as step while (currentTs < timeSpan.end) { const cellEnd = time.add(currentTs, step); cells.push({ ts: currentTs, dur: step, // Generate some load, e.g., based on activity in your plugin's data load: Math.random(), // Replace with actual data calculation }); currentTs = cellEnd; if (cells.length > 1000) break; // Safety break for dummy data } // Plugins can return multiple rows if they want to represent different // layers or types of data in the minimap. return [cells]; } } export default class implements PerfettoPlugin { static readonly id = 'com.example.MyMinimapPlugin'; async onTraceLoad(trace: Trace): Promise<void> { const provider = new MyMinimapDataProvider(); trace.minimap.registerContentProvider(provider); console.log('MyMinimapDataProvider registered.'); } }

The UI will then call the getData method of registered providers as needed when the minimap needs to be redrawn, allowing plugins to contribute dynamic, trace-wide overviews.

Plugin Dependencies

Plugins can declare dependencies on other plugins. This ensures that the dependent plugins are loaded and available before the current plugin is activated and loaded. This is useful when a plugin needs to extend or utilize functionality provided by another plugin.

Declaring Dependencies:

A plugin declares its dependencies via a static dependencies array in its class definition. This array should contain direct references to the static classes of the plugins it depends on.

// plugin-a.ts import {PerfettoPlugin, PerfettoPluginStatic, App, Trace} from '../../public'; export default class PluginA implements PerfettoPlugin { static readonly id = 'com.example.PluginA'; // ... doSomething(): string { return 'Data from Plugin A'; } } // plugin-b.ts import {PerfettoPlugin, PerfettoPluginStatic, App, Trace} from '../../public'; import PluginA from './plugin-a'; // Import the static class export default class PluginB implements PerfettoPlugin { static readonly id = 'com.example.PluginB'; static readonly dependencies = [PluginA]; // Declare PluginA as a dependency private pluginAInstance?: PluginA; async onTraceLoad(ctx: Trace): Promise<void> { // Get an instance of the dependency this.pluginAInstance = ctx.plugins.getPlugin(PluginA); if (this.pluginAInstance) { const dataFromA = this.pluginAInstance.doSomething(); console.log(`${PluginB.id} received: ${dataFromA}`); // Use dataFromA or other methods from pluginAInstance } else { console.error(`${PluginB.id} could not get instance of ${PluginA.id}`); } } }

Accessing Dependencies:

Once a plugin is loaded (e.g., within onActivate or onTraceLoad), it can get an instance of a declared dependency using the plugins.getPlugin() method available on the App or Trace context object. You pass the static class of the dependency to this method.

The core ensures that onActivate and onTraceLoad for dependencies are called before they are called for the dependent plugin. If a dependency cannot be loaded, the dependent plugin might not load or might receive undefined when trying to get the plugin instance.

Example:

The dev.perfetto.TraceProcessorTrack plugin depends on ProcessThreadGroupsPlugin and StandardGroupsPlugin to organize tracks under appropriate process, thread, or standard groups.

// From ui/src/plugins/dev.perfetto.TraceProcessorTrack/index.ts import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; import StandardGroupsPlugin from '../dev.perfetto.StandardGroups'; // ... export default class TraceProcessorTrackPlugin implements PerfettoPlugin { static readonly id = 'dev.perfetto.TraceProcessorTrack'; static readonly dependencies = [ ProcessThreadGroupsPlugin, StandardGroupsPlugin, ]; // ... private addTrack( ctx: Trace, // ... ) { // ... const processGroupPlugin = ctx.plugins.getPlugin(ProcessThreadGroupsPlugin); const standardGroupPlugin = ctx.plugins.getPlugin(StandardGroupsPlugin); // Use instances of processGroupPlugin and standardGroupPlugin... } }

By declaring dependencies, plugins can build upon each other, creating a more modular and extensible system.

Default plugins

Some plugins are enabled by default. These plugins are held to a higher quality than non-default plugins since changes to those plugins effect all users of the UI. The list of default plugins is specified at ui/src/core/default_plugins.ts.

In particular the startup time of your plugin will be scrutinized and your plugin may be disabled by default if it has a significant impact on users who aren't using your plugin's features. To see a list of plugins and their startup times, visit the plugins page and sort plugins by their startup time.

The majority of default plugins are Android and Chrome related due to the lineage of the Perfetto project, ui.perfetto.dev is mostly to server the Android and Chrome telemetry teams.

Misc notes