1# UI plugins 2The Perfetto UI can be extended with plugins. These plugins are shipped 3part of Perfetto. 4 5## Create a plugin 6The guide below explains how to create a plugin for the Perfetto UI. 7 8### Prepare for UI development 9First we need to prepare the UI development environment. 10You will need to use a MacOS or Linux machine. 11Follow the steps below or see the 12[Getting Started](./getting-started) guide for more detail. 13 14```sh 15git clone https://android.googlesource.com/platform/external/perfetto/ 16cd perfetto 17./tools/install-build-deps --ui 18``` 19 20### Copy the plugin skeleton 21```sh 22cp -r ui/src/plugins/com.example.Skeleton ui/src/plugins/<your-plugin-name> 23``` 24Now edit `ui/src/plugins/<your-plugin-name>/index.ts`. 25Search for all instances of `SKELETON: <instruction>` in the file and 26follow the instructions. 27 28Notes on naming: 29- Don't name the directory `XyzPlugin` just `Xyz`. 30- The `pluginId` and directory name must match. 31- Plugins should be prefixed with the reversed components of a domain 32 name you control. For example if `example.com` is your domain your 33 plugin should be named `com.example.Foo`. 34- Core plugins maintained by the Perfetto team should use 35 `dev.perfetto.Foo`. 36- Commands should have ids with the pattern `example.com#DoSomething` 37- Command's ids should be prefixed with the id of the plugin which 38 provides them. 39- Command names should have the form "Verb something something", and should be 40 in normal sentence case. I.e. don't capitalize the first letter of each word. 41 - Good: "Pin janky frame timeline tracks" 42 - Bad: "Tracks are Displayed if Janky" 43 44### Start the dev server 45```sh 46./ui/run-dev-server 47``` 48Now navigate to [localhost:10000](http://localhost:10000/) 49 50### Enable your plugin 51- Navigate to the plugins page: [localhost:10000/#!/plugins](http://localhost:10000/#!/plugins). 52- Ctrl-F for your plugin name and enable it. 53 54Later you can request for your plugin to be enabled by default. 55Follow the [default plugins](#default-plugins) section for this. 56 57### Upload your plugin for review 58- Update `ui/src/plugins/<your-plugin-name>/OWNERS` to include your email. 59- Follow the [Contributing](./getting-started#contributing) 60 instructions to upload your CL to the codereview tool. 61- Once uploaded add `stevegolton@google.com` as a reviewer for your CL. 62 63## Plugin extension points 64Plugins can extend a handful of specific places in the UI. The sections 65below show these extension points and give examples of how they can be 66used. 67 68### Commands 69Commands are user issuable shortcuts for actions in the UI. 70They can be accessed via the omnibox. 71 72Follow the [create a plugin](#create-a-plugin) to get an initial 73skeleton for your plugin. 74 75To add your first command, add a call to `ctx.registerCommand()` in either 76your `onActivate()` or `onTraceLoad()` hooks. The recommendation is to register 77commands in `onActivate()` by default unless they require something from 78`PluginContextTrace` which is not available on `PluginContext`. 79 80The tradeoff is that commands registered in `onTraceLoad()` are only available 81while a trace is loaded, whereas commands registered in `onActivate()` are 82available all the time the plugin is active. 83 84```typescript 85class MyPlugin implements Plugin { 86 onActivate(ctx: PluginContext): void { 87 ctx.registerCommand( 88 { 89 id: 'dev.perfetto.ExampleSimpleCommand#LogHelloPlugin', 90 name: 'Log "Hello, plugin!"', 91 callback: () => console.log('Hello, plugin!'), 92 }, 93 ); 94 } 95 96 onTraceLoad(ctx: PluginContextTrace): void { 97 ctx.registerCommand( 98 { 99 id: 'dev.perfetto.ExampleSimpleTraceCommand#LogHelloTrace', 100 name: 'Log "Hello, trace!"', 101 callback: () => console.log('Hello, trace!'), 102 }, 103 ); 104 } 105} 106``` 107 108Here `id` is a unique string which identifies this command. 109The `id` should be prefixed with the plugin id followed by a `#`. All command 110`id`s must be unique system-wide. 111`name` is a human readable name for the command, which is shown in the command 112palette. 113Finally `callback()` is the callback which actually performs the 114action. 115 116Commands are removed automatically when their context disappears. Commands 117registered with the `PluginContext` are removed when the plugin is deactivated, 118and commands registered with the `PluginContextTrace` are removed when the trace 119is unloaded. 120 121Examples: 122- [dev.perfetto.ExampleSimpleCommand](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts). 123- [dev.perfetto.CoreCommands](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.CoreCommands/index.ts). 124- [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts). 125 126#### Hotkeys 127 128A default hotkey may be provided when registering a command. 129 130```typescript 131ctx.registerCommand({ 132 id: 'dev.perfetto.ExampleSimpleCommand#LogHelloWorld', 133 name: 'Log "Hello, World!"', 134 callback: () => console.log('Hello, World!'), 135 defaultHotkey: 'Shift+H', 136}); 137``` 138 139Even though the hotkey is a string, it's format checked at compile time using 140typescript's [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html). 141 142See [hotkey.ts](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/hotkeys.ts) 143for more details on how the hotkey syntax works, and for the available keys and 144modifiers. 145 146### Tracks 147#### Defining Tracks 148Tracks describe how to render a track and how to respond to mouse interaction. 149However, the interface is a WIP and should be considered unstable. 150This documentation will be added to over the next few months after the design is 151finalised. 152 153#### Reusing Existing Tracks 154Creating tracks from scratch is difficult and the API is currently a WIP, so it 155is strongly recommended to use one of our existing base classes which do a lot 156of the heavy lifting for you. These base classes also provide a more stable 157layer between your track and the (currently unstable) track API. 158 159For example, if your track needs to show slices from a given a SQL expression (a 160very common pattern), extend the `NamedSliceTrack` abstract base class and 161implement `getSqlSource()`, which should return a query with the following 162columns: 163 164- `id: INTEGER`: A unique ID for the slice. 165- `ts: INTEGER`: The timestamp of the start of the slice. 166- `dur: INTEGER`: The duration of the slice. 167- `depth: INTEGER`: Integer value defining how deep the slice should be drawn in 168 the track, 0 being rendered at the top of the track, and increasing numbers 169 being drawn towards the bottom of the track. 170- `name: TEXT`: Text to be rendered on the slice and in the popup. 171 172For example, the following track describes a slice track that displays all 173slices that begin with the letter 'a'. 174```ts 175class MyTrack extends NamedSliceTrack { 176 getSqlSource(): string { 177 return ` 178 SELECT 179 id, 180 ts, 181 dur, 182 depth, 183 name 184 from slice 185 where name like 'a%' 186 `; 187 } 188} 189``` 190 191#### Registering Tracks 192Plugins may register tracks with Perfetto using 193`PluginContextTrace.registerTrack()`, usually in their `onTraceLoad` function. 194 195```ts 196class MyPlugin implements Plugin { 197 onTraceLoad(ctx: PluginContextTrace): void { 198 ctx.registerTrack({ 199 uri: 'dev.MyPlugin#ExampleTrack', 200 displayName: 'My Example Track', 201 trackFactory: ({trackKey}) => { 202 return new MyTrack({engine: ctx.engine, trackKey}); 203 }, 204 }); 205 } 206} 207``` 208 209#### Default Tracks 210The "default" tracks are a list of tracks that are added to the timeline when a 211fresh trace is loaded (i.e. **not** when loading a trace from a permalink). 212This list is copied into the timeline after the trace has finished loading, at 213which point control is handed over to the user, allowing them add, remove and 214reorder tracks as they please. 215Thus it only makes sense to add default tracks in your plugin's `onTraceLoad` 216function, as adding a default track later will have no effect. 217 218```ts 219class MyPlugin implements Plugin { 220 onTraceLoad(ctx: PluginContextTrace): void { 221 ctx.registerTrack({ 222 // ... as above ... 223 }); 224 225 ctx.addDefaultTrack({ 226 uri: 'dev.MyPlugin#ExampleTrack', 227 displayName: 'My Example Track', 228 sortKey: PrimaryTrackSortKey.ORDINARY_TRACK, 229 }); 230 } 231} 232``` 233 234Registering and adding a default track is such a common pattern that there is a 235shortcut for doing both in one go: `PluginContextTrace.registerStaticTrack()`, 236which saves having to repeat the URI and display name. 237 238```ts 239class MyPlugin implements Plugin { 240 onTraceLoad(ctx: PluginContextTrace): void { 241 ctx.registerStaticTrack({ 242 uri: 'dev.MyPlugin#ExampleTrack', 243 displayName: 'My Example Track', 244 trackFactory: ({trackKey}) => { 245 return new MyTrack({engine: ctx.engine, trackKey}); 246 }, 247 sortKey: PrimaryTrackSortKey.COUNTER_TRACK, 248 }); 249 } 250} 251``` 252 253#### Adding Tracks Directly 254Sometimes plugins might want to add a track to the timeline immediately, usually 255as a result of a command or on some other user action such as a button click. 256We can do this using `PluginContext.timeline.addTrack()`. 257 258```ts 259class MyPlugin implements Plugin { 260 onTraceLoad(ctx: PluginContextTrace): void { 261 ctx.registerTrack({ 262 // ... as above ... 263 }); 264 265 // Register a command that directly adds a new track to the timeline 266 ctx.registerCommand({ 267 id: 'dev.MyPlugin#AddMyTrack', 268 name: 'Add my track', 269 callback: () => { 270 ctx.timeline.addTrack( 271 'dev.MyPlugin#ExampleTrack', 272 'My Example Track' 273 ); 274 }, 275 }); 276 } 277} 278``` 279 280### Tabs 281Tabs are a useful way to display contextual information about the trace, the 282current selection, or to show the results of an operation. 283 284To register a tab from a plugin, use the `PluginContextTrace.registerTab` 285method. 286 287```ts 288import m from 'mithril'; 289import {Tab, Plugin, PluginContext, PluginContextTrace} from '../../public'; 290 291class MyTab implements Tab { 292 render(): m.Children { 293 return m('div', 'Hello from my tab'); 294 } 295 296 getTitle(): string { 297 return 'My Tab'; 298 } 299} 300 301class MyPlugin implements Plugin { 302 onActivate(_: PluginContext): void {} 303 async onTraceLoad(ctx: PluginContextTrace): Promise<void> { 304 ctx.registerTab({ 305 uri: 'dev.MyPlugin#MyTab', 306 content: new MyTab(), 307 }); 308 } 309} 310``` 311 312You'll need to pass in a tab-like object, something that implements the `Tab` 313interface. Tabs only need to define their title and a render function which 314specifies how to render the tab. 315 316Registered tabs don't appear immediately - we need to show it first. All 317registered tabs are displayed in the tab dropdown menu, and can be shown or 318hidden by clicking on the entries in the drop down menu. 319 320Tabs can also be hidden by clicking the little x in the top right of their 321handle. 322 323Alternatively, tabs may be shown or hidden programmatically using the tabs API. 324 325```ts 326ctx.tabs.showTab('dev.MyPlugin#MyTab'); 327ctx.tabs.hideTab('dev.MyPlugin#MyTab'); 328``` 329 330Tabs have the following properties: 331- Each tab has a unique URI. 332- Only once instance of the tab may be open at a time. Calling showTab multiple 333 times with the same URI will only activate the tab, not add a new instance of 334 the tab to the tab bar. 335 336#### Ephemeral Tabs 337 338By default, tabs are registered as 'permanent' tabs. These tabs have the 339following additional properties: 340- They appear in the tab dropdown. 341- They remain once closed. The plugin controls the lifetime of the tab object. 342 343Ephemeral tabs, by contrast, have the following properties: 344- They do not appear in the tab dropdown. 345- When they are hidden, they will be automatically unregistered. 346 347Ephemeral tabs can be registered by setting the `isEphemeral` flag when 348registering the tab. 349 350```ts 351ctx.registerTab({ 352 isEphemeral: true, 353 uri: 'dev.MyPlugin#MyTab', 354 content: new MyEphemeralTab(), 355}); 356``` 357 358Ephemeral tabs are usually added as a result of some user action, such as 359running a command. Thus, it's common pattern to register a tab and show the tab 360simultaneously. 361 362Motivating example: 363```ts 364import m from 'mithril'; 365import {uuidv4} from '../../base/uuid'; 366import { 367 Plugin, 368 PluginContext, 369 PluginContextTrace, 370 PluginDescriptor, 371 Tab, 372} from '../../public'; 373 374class MyNameTab implements Tab { 375 constructor(private name: string) {} 376 render(): m.Children { 377 return m('h1', `Hello, ${this.name}!`); 378 } 379 getTitle(): string { 380 return 'My Name Tab'; 381 } 382} 383 384class MyPlugin implements Plugin { 385 onActivate(_: PluginContext): void {} 386 async onTraceLoad(ctx: PluginContextTrace): Promise<void> { 387 ctx.registerCommand({ 388 id: 'dev.MyPlugin#AddNewEphemeralTab', 389 name: 'Add new ephemeral tab', 390 callback: () => handleCommand(ctx), 391 }); 392 } 393} 394 395function handleCommand(ctx: PluginContextTrace): void { 396 const name = prompt('What is your name'); 397 if (name) { 398 const uri = 'dev.MyPlugin#MyName' + uuidv4(); 399 // This makes the tab available to perfetto 400 ctx.registerTab({ 401 isEphemeral: true, 402 uri, 403 content: new MyNameTab(name), 404 }); 405 406 // This opens the tab in the tab bar 407 ctx.tabs.showTab(uri); 408 } 409} 410 411export const plugin: PluginDescriptor = { 412 pluginId: 'dev.MyPlugin', 413 plugin: MyPlugin, 414}; 415``` 416 417### Details Panels & The Current Selection Tab 418The "Current Selection" tab is a special tab that cannot be hidden. It remains 419permanently in the left-most tab position in the tab bar. Its purpose is to 420display details about the current selection. 421 422Plugins may register interest in providing content for this tab using the 423`PluginContentTrace.registerDetailsPanel()` method. 424 425For example: 426 427```ts 428class MyPlugin implements Plugin { 429 onActivate(_: PluginContext): void {} 430 async onTraceLoad(ctx: PluginContextTrace): Promise<void> { 431 ctx.registerDetailsPanel({ 432 render(selection: Selection) { 433 if (canHandleSelection(selection)) { 434 return m('div', 'Details for selection'); 435 } else { 436 return undefined; 437 } 438 } 439 }); 440 } 441} 442``` 443 444This function takes an object that implements the `DetailsPanel` interface, 445which only requires a render function to be implemented that takes the current 446selection object and returns either mithril vnodes or a falsy value. 447 448Every render cycle, render is called on all registered details panels, and the 449first registered panel to return a truthy value will be used. 450 451Currently the winning details panel takes complete control over this tab. Also, 452the order that these panels are called in is not defined, so if we have multiple 453details panels competing for the same selection, the one that actually shows up 454is undefined. This is a limitation of the current approach and will be updated 455to a more democratic contribution model in the future. 456 457### Metric Visualisations 458TBD 459 460Examples: 461- [dev.perfetto.AndroidBinderViz](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts). 462 463### State 464NOTE: It is important to consider version skew when using persistent state. 465 466Plugins can persist information into permalinks. This allows plugins 467to gracefully handle permalinking and is an opt-in - not automatic - 468mechanism. 469 470Persistent plugin state works using a `Store<T>` where `T` is some JSON 471serializable object. 472`Store` is implemented [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/store.ts). 473`Store` allows for reading and writing `T`. 474Reading: 475```typescript 476interface Foo { 477 bar: string; 478} 479 480const store: Store<Foo> = getFooStoreSomehow(); 481 482// store.state is immutable and must not be edited. 483const foo = store.state.foo; 484const bar = foo.bar; 485 486console.log(bar); 487``` 488 489Writing: 490```typescript 491interface Foo { 492 bar: string; 493} 494 495const store: Store<Foo> = getFooStoreSomehow(); 496 497store.edit((draft) => { 498 draft.foo.bar = 'Hello, world!'; 499}); 500 501console.log(store.state.foo.bar); 502// > Hello, world! 503``` 504 505First define an interface for your specific plugin state. 506```typescript 507interface MyState { 508 favouriteSlices: MySliceInfo[]; 509} 510``` 511 512To access permalink state, call `mountStore()` on your `PluginContextTrace` 513object, passing in a migration function. 514```typescript 515class MyPlugin implements Plugin { 516 async onTraceLoad(ctx: PluginContextTrace): Promise<void> { 517 const store = ctx.mountStore(migrate); 518 } 519} 520 521function migrate(initialState: unknown): MyState { 522 // ... 523} 524``` 525 526When it comes to migration, there are two cases to consider: 527- Loading a new trace 528- Loading from a permalink 529 530In case of a new trace, your migration function is called with `undefined`. In 531this case you should return a default version of `MyState`: 532```typescript 533const DEFAULT = {favouriteSlices: []}; 534 535function migrate(initialState: unknown): MyState { 536 if (initialState === undefined) { 537 // Return default version of MyState. 538 return DEFAULT; 539 } else { 540 // Migrate old version here. 541 } 542} 543``` 544 545In the permalink case, your migration function is called with the state of the 546plugin store at the time the permalink was generated. This may be from an older 547or newer version of the plugin. 548 549**Plugins must not make assumptions about the contents of `initialState`!** 550 551In this case you need to carefully validate the state object. This could be 552achieved in several ways, none of which are particularly straight forward. State 553migration is difficult! 554 555One brute force way would be to use a version number. 556 557```typescript 558interface MyState { 559 version: number; 560 favouriteSlices: MySliceInfo[]; 561} 562 563const VERSION = 3; 564const DEFAULT = {favouriteSlices: []}; 565 566function migrate(initialState: unknown): MyState { 567 if (initialState && (initialState as {version: any}).version === VERSION) { 568 // Version number checks out, assume the structure is correct. 569 return initialState as State; 570 } else { 571 // Null, undefined, or bad version number - return default value. 572 return DEFAULT; 573 } 574} 575``` 576 577You'll need to remember to update your version number when making changes! 578Migration should be unit-tested to ensure compatibility. 579 580Examples: 581- [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts). 582 583## Guide to the plugin API 584The 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). 585 586## Default plugins 587Some plugins are enabled by default. 588These plugins are held to a higher quality than non-default plugins since changes to those plugins effect all users of the UI. 589The 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). 590 591## Misc notes 592- Plugins must be licensed under 593 [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) 594 the same as all other code in the repository. 595- Plugins are the responsibility of the OWNERS of that plugin to 596 maintain, not the responsibility of the Perfetto team. All 597 efforts will be made to keep the plugin API stable and existing 598 plugins working however plugins that remain unmaintained for long 599 periods of time will be disabled and ultimately deleted. 600 601