| # UI plugins |
| The Perfetto UI can be extended with plugins. These plugins are shipped |
| part of Perfetto. |
| |
| ## Create a plugin |
| The guide below explains how to create a plugin for the Perfetto UI. |
| |
| ### Prepare for UI development |
| First we need to prepare the UI development environment. |
| You will need to use a MacOS or Linux machine. |
| Follow the steps below or see the |
| [Getting Started](./getting-started) guide for more detail. |
| |
| ```sh |
| git clone https://android.googlesource.com/platform/external/perfetto/ |
| cd perfetto |
| ./tools/install-build-deps --ui |
| ``` |
| |
| ### Copy the plugin skeleton |
| ```sh |
| 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: |
| - Don't name the directory `XyzPlugin` just `Xyz`. |
| - The `pluginId` and directory name must match. |
| - Plugins should be prefixed with the reversed components of a domain |
| name you control. For example if `example.com` is your domain your |
| plugin should be named `com.example.Foo`. |
| - Core plugins maintained by the Perfetto team should use |
| `dev.perfetto.Foo`. |
| - Commands should have ids with the pattern `example.com#DoSomething` |
| - Command's ids should be prefixed with the id of the plugin which |
| provides them. |
| - Command names should have the form "Verb something something", and should be |
| in normal sentence case. I.e. don't capitalize the first letter of each word. |
| - Good: "Pin janky frame timeline tracks" |
| - Bad: "Tracks are Displayed if Janky" |
| |
| ### Start the dev server |
| ```sh |
| ./ui/run-dev-server |
| ``` |
| Now navigate to [localhost:10000](http://localhost:10000/) |
| |
| ### Enable your plugin |
| - Navigate to the plugins page: [localhost:10000/#!/plugins](http://localhost:10000/#!/plugins). |
| - Ctrl-F for your plugin name and enable it. |
| |
| Later you can request for your plugin to be enabled by default. |
| Follow the [default plugins](#default-plugins) section for this. |
| |
| ### Upload your plugin for review |
| - Update `ui/src/plugins/<your-plugin-name>/OWNERS` to include your email. |
| - Follow the [Contributing](./getting-started#contributing) |
| instructions to upload your CL to the codereview tool. |
| - Once uploaded add `stevegolton@google.com` as a reviewer for your CL. |
| |
| ## Plugin extension points |
| Plugins can extend a handful of specific places in the UI. The sections |
| below show these extension points and give examples of how they can be |
| used. |
| |
| ### Commands |
| Commands are user issuable shortcuts for actions in the UI. |
| They can be accessed via the omnibox. |
| |
| Follow the [create a plugin](#create-a-plugin) to get an initial |
| skeleton for your plugin. |
| |
| To add your first command, add a call to `ctx.registerCommand()` in either |
| your `onActivate()` or `onTraceLoad()` hooks. The recommendation is to register |
| commands in `onActivate()` by default unless they require something from |
| `PluginContextTrace` which is not available on `PluginContext`. |
| |
| The tradeoff is that commands registered in `onTraceLoad()` are only available |
| while a trace is loaded, whereas commands registered in `onActivate()` are |
| available all the time the plugin is active. |
| |
| ```typescript |
| class MyPlugin implements Plugin { |
| onActivate(ctx: PluginContext): void { |
| ctx.registerCommand( |
| { |
| id: 'dev.perfetto.ExampleSimpleCommand#LogHelloPlugin', |
| name: 'Log "Hello, plugin!"', |
| callback: () => console.log('Hello, plugin!'), |
| }, |
| ); |
| } |
| |
| onTraceLoad(ctx: PluginContextTrace): void { |
| ctx.registerCommand( |
| { |
| id: 'dev.perfetto.ExampleSimpleTraceCommand#LogHelloTrace', |
| name: 'Log "Hello, trace!"', |
| callback: () => console.log('Hello, trace!'), |
| }, |
| ); |
| } |
| } |
| ``` |
| |
| Here `id` is a unique string which identifies this command. |
| The `id` should be prefixed with the plugin id followed by a `#`. All command |
| `id`s must be unique system-wide. |
| `name` is a human readable name for the command, which is shown in the command |
| palette. |
| Finally `callback()` is the callback which actually performs the |
| action. |
| |
| Commands are removed automatically when their context disappears. Commands |
| registered with the `PluginContext` are removed when the plugin is deactivated, |
| and commands registered with the `PluginContextTrace` are removed when the trace |
| is unloaded. |
| |
| Examples: |
| - [dev.perfetto.ExampleSimpleCommand](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts). |
| - [dev.perfetto.CoreCommands](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.CoreCommands/index.ts). |
| - [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts). |
| |
| #### Hotkeys |
| |
| A default hotkey may be provided when registering a command. |
| |
| ```typescript |
| ctx.registerCommand({ |
| id: 'dev.perfetto.ExampleSimpleCommand#LogHelloWorld', |
| name: 'Log "Hello, World!"', |
| callback: () => console.log('Hello, World!'), |
| defaultHotkey: 'Shift+H', |
| }); |
| ``` |
| |
| Even though the hotkey is a string, it's format checked at compile time using |
| typescript's [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html). |
| |
| See [hotkey.ts](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/hotkeys.ts) |
| for more details on how the hotkey syntax works, and for the available keys and |
| modifiers. |
| |
| ### Tracks |
| #### Defining Tracks |
| Tracks describe how to render a track and how to respond to mouse interaction. |
| However, the interface is a WIP and should be considered unstable. |
| This documentation will be added to over the next few months after the design is |
| finalised. |
| |
| #### Reusing Existing Tracks |
| Creating tracks from scratch is difficult and the API is currently a WIP, so it |
| is strongly recommended to use one of our existing base classes which do a lot |
| of the heavy lifting for you. These base classes also provide a more stable |
| layer between your track and the (currently unstable) track API. |
| |
| For example, if your track needs to show slices from a given a SQL expression (a |
| very common pattern), extend the `NamedSliceTrack` abstract base class and |
| implement `getSqlSource()`, which should return a query with the following |
| columns: |
| |
| - `id: INTEGER`: A unique ID for the slice. |
| - `ts: INTEGER`: The timestamp of the start of the slice. |
| - `dur: INTEGER`: The duration of the slice. |
| - `depth: INTEGER`: Integer value defining how deep the slice should be drawn in |
| the track, 0 being rendered at the top of the track, and increasing numbers |
| being drawn towards the bottom of the track. |
| - `name: TEXT`: Text to be rendered on the slice and in the popup. |
| |
| For example, the following track describes a slice track that displays all |
| slices that begin with the letter 'a'. |
| ```ts |
| class MyTrack extends NamedSliceTrack { |
| getSqlSource(): string { |
| return ` |
| SELECT |
| id, |
| ts, |
| dur, |
| depth, |
| name |
| from slice |
| where name like 'a%' |
| `; |
| } |
| } |
| ``` |
| |
| #### Registering Tracks |
| Plugins may register tracks with Perfetto using |
| `PluginContextTrace.registerTrack()`, usually in their `onTraceLoad` function. |
| |
| ```ts |
| class MyPlugin implements Plugin { |
| onTraceLoad(ctx: PluginContextTrace): void { |
| ctx.registerTrack({ |
| uri: 'dev.MyPlugin#ExampleTrack', |
| displayName: 'My Example Track', |
| trackFactory: ({trackKey}) => { |
| return new MyTrack({engine: ctx.engine, trackKey}); |
| }, |
| }); |
| } |
| } |
| ``` |
| |
| #### Default Tracks |
| The "default" tracks are a list of tracks that are added to the timeline when a |
| fresh trace is loaded (i.e. **not** when loading a trace from a permalink). |
| This list is copied into the timeline after the trace has finished loading, at |
| which point control is handed over to the user, allowing them add, remove and |
| reorder tracks as they please. |
| Thus it only makes sense to add default tracks in your plugin's `onTraceLoad` |
| function, as adding a default track later will have no effect. |
| |
| ```ts |
| class MyPlugin implements Plugin { |
| onTraceLoad(ctx: PluginContextTrace): void { |
| ctx.registerTrack({ |
| // ... as above ... |
| }); |
| |
| ctx.addDefaultTrack({ |
| uri: 'dev.MyPlugin#ExampleTrack', |
| displayName: 'My Example Track', |
| sortKey: PrimaryTrackSortKey.ORDINARY_TRACK, |
| }); |
| } |
| } |
| ``` |
| |
| Registering and adding a default track is such a common pattern that there is a |
| shortcut for doing both in one go: `PluginContextTrace.registerStaticTrack()`, |
| which saves having to repeat the URI and display name. |
| |
| ```ts |
| class MyPlugin implements Plugin { |
| onTraceLoad(ctx: PluginContextTrace): void { |
| ctx.registerStaticTrack({ |
| uri: 'dev.MyPlugin#ExampleTrack', |
| displayName: 'My Example Track', |
| trackFactory: ({trackKey}) => { |
| return new MyTrack({engine: ctx.engine, trackKey}); |
| }, |
| sortKey: PrimaryTrackSortKey.COUNTER_TRACK, |
| }); |
| } |
| } |
| ``` |
| |
| #### Adding Tracks Directly |
| Sometimes plugins might want to add a track to the timeline immediately, usually |
| as a result of a command or on some other user action such as a button click. |
| We can do this using `PluginContext.timeline.addTrack()`. |
| |
| ```ts |
| class MyPlugin implements Plugin { |
| onTraceLoad(ctx: PluginContextTrace): void { |
| ctx.registerTrack({ |
| // ... as above ... |
| }); |
| |
| // Register a command that directly adds a new track to the timeline |
| ctx.registerCommand({ |
| id: 'dev.MyPlugin#AddMyTrack', |
| name: 'Add my track', |
| callback: () => { |
| ctx.timeline.addTrack( |
| 'dev.MyPlugin#ExampleTrack', |
| 'My Example Track' |
| ); |
| }, |
| }); |
| } |
| } |
| ``` |
| |
| ### 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 `PluginContextTrace.registerTab` |
| method. |
| |
| ```ts |
| import m from 'mithril'; |
| import {Tab, Plugin, PluginContext, PluginContextTrace} from '../../public'; |
| |
| class MyTab implements Tab { |
| render(): m.Children { |
| return m('div', 'Hello from my tab'); |
| } |
| |
| getTitle(): string { |
| return 'My Tab'; |
| } |
| } |
| |
| class MyPlugin implements Plugin { |
| onActivate(_: PluginContext): void {} |
| async onTraceLoad(ctx: PluginContextTrace): Promise<void> { |
| ctx.registerTab({ |
| uri: 'dev.MyPlugin#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. |
| |
| ```ts |
| ctx.tabs.showTab('dev.MyPlugin#MyTab'); |
| ctx.tabs.hideTab('dev.MyPlugin#MyTab'); |
| ``` |
| |
| Tabs have the following properties: |
| - Each tab has a unique URI. |
| - Only once instance of the tab may be open at a time. Calling showTab multiple |
| times with the same URI will only activate the tab, not add a new instance of |
| the tab to the tab bar. |
| |
| #### Ephemeral Tabs |
| |
| By default, tabs are registered as 'permanent' tabs. These tabs have the |
| following additional properties: |
| - They appear in the tab dropdown. |
| - They remain once closed. The plugin controls the lifetime of the tab object. |
| |
| Ephemeral tabs, by contrast, have the following properties: |
| - They do not appear in the tab dropdown. |
| - When they are hidden, they will be automatically unregistered. |
| |
| Ephemeral tabs can be registered by setting the `isEphemeral` flag when |
| registering the tab. |
| |
| ```ts |
| ctx.registerTab({ |
| isEphemeral: true, |
| uri: 'dev.MyPlugin#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: |
| ```ts |
| import m from 'mithril'; |
| import {uuidv4} from '../../base/uuid'; |
| import { |
| Plugin, |
| PluginContext, |
| PluginContextTrace, |
| PluginDescriptor, |
| Tab, |
| } from '../../public'; |
| |
| class MyNameTab implements Tab { |
| constructor(private name: string) {} |
| render(): m.Children { |
| return m('h1', `Hello, ${this.name}!`); |
| } |
| getTitle(): string { |
| return 'My Name Tab'; |
| } |
| } |
| |
| class MyPlugin implements Plugin { |
| onActivate(_: PluginContext): void {} |
| async onTraceLoad(ctx: PluginContextTrace): Promise<void> { |
| ctx.registerCommand({ |
| id: 'dev.MyPlugin#AddNewEphemeralTab', |
| name: 'Add new ephemeral tab', |
| callback: () => handleCommand(ctx), |
| }); |
| } |
| } |
| |
| function handleCommand(ctx: PluginContextTrace): void { |
| const name = prompt('What is your name'); |
| if (name) { |
| const uri = 'dev.MyPlugin#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); |
| } |
| } |
| |
| export const plugin: PluginDescriptor = { |
| pluginId: 'dev.MyPlugin', |
| plugin: MyPlugin, |
| }; |
| ``` |
| |
| ### Details Panels & The Current Selection Tab |
| The "Current Selection" tab is a special tab that cannot be hidden. It remains |
| permanently in the left-most tab position in the tab bar. Its purpose is to |
| display details about the current selection. |
| |
| Plugins may register interest in providing content for this tab using the |
| `PluginContentTrace.registerDetailsPanel()` method. |
| |
| For example: |
| |
| ```ts |
| class MyPlugin implements Plugin { |
| onActivate(_: PluginContext): void {} |
| async onTraceLoad(ctx: PluginContextTrace): Promise<void> { |
| ctx.registerDetailsPanel({ |
| render(selection: Selection) { |
| if (canHandleSelection(selection)) { |
| return m('div', 'Details for selection'); |
| } else { |
| return undefined; |
| } |
| } |
| }); |
| } |
| } |
| ``` |
| |
| This function takes an object that implements the `DetailsPanel` interface, |
| which only requires a render function to be implemented that takes the current |
| selection object and returns either mithril vnodes or a falsy value. |
| |
| Every render cycle, render is called on all registered details panels, and the |
| first registered panel to return a truthy value will be used. |
| |
| Currently the winning details panel takes complete control over this tab. Also, |
| the order that these panels are called in is not defined, so if we have multiple |
| details panels competing for the same selection, the one that actually shows up |
| is undefined. This is a limitation of the current approach and will be updated |
| to a more democratic contribution model in the future. |
| |
| ### Metric Visualisations |
| TBD |
| |
| Examples: |
| - [dev.perfetto.AndroidBinderViz](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts). |
| |
| ### 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](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/store.ts). |
| `Store` allows for reading and writing `T`. |
| Reading: |
| ```typescript |
| 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: |
| ```typescript |
| 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. |
| ```typescript |
| interface MyState { |
| favouriteSlices: MySliceInfo[]; |
| } |
| ``` |
| |
| To access permalink state, call `mountStore()` on your `PluginContextTrace` |
| object, passing in a migration function. |
| ```typescript |
| class MyPlugin implements Plugin { |
| async onTraceLoad(ctx: PluginContextTrace): Promise<void> { |
| const store = ctx.mountStore(migrate); |
| } |
| } |
| |
| function migrate(initialState: unknown): MyState { |
| // ... |
| } |
| ``` |
| |
| When it comes to migration, there are two cases to consider: |
| - Loading a new trace |
| - Loading from a permalink |
| |
| In case of a new trace, your migration function is called with `undefined`. In |
| this case you should return a default version of `MyState`: |
| ```typescript |
| 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. |
| |
| ```typescript |
| 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: |
| - [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts). |
| |
| ## Guide to the plugin API |
| The plugin interfaces are defined in [ui/src/public/index.ts](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public/index.ts). |
| |
| ## 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](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/common/default_plugins.ts). |
| |
| ## Misc notes |
| - Plugins must be licensed under |
| [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) |
| the same as all other code in the repository. |
| - Plugins are the responsibility of the OWNERS of that plugin to |
| maintain, not the responsibility of the Perfetto team. All |
| efforts will be made to keep the plugin API stable and existing |
| plugins working however plugins that remain unmaintained for long |
| periods of time will be disabled and ultimately deleted. |
| |