Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/concepts/ui-frontend-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Frontend Main API

This document provides an overview of the `Frontend` API in Perfetto UI, as defined in [`ui/src/frontend/frontend.ts`](../../ui/src/frontend/frontend.ts).
This API is the main application entry-point for the Perfetto UI and may be used by host (embedding) applications to customize various aspects of Perfetto UI's behavior, especially for advanced integration scenarios.

## Overview

The `Frontend` class provides for the main start-up sequence of the Perfetto UI.
The various steps in this sequence are factored out into protected API methods that a host application can extend or override to control or redefined specific Perfetto UI behaviors.
The primary intended use case is in applications that take care of such concerns as acquiring and managing Perfetto traces but wish to reuse/embed the Perfetto UI for presentation of those traces to the user.

When a host application creates and starts the `Frontend`, it must be sure to do this *before* the main [Perfetto UI module](../../ui/src/frontend/index.ts) is loaded or, better, ensure that that main module is not loaded at all as it would not be needed.

Host applications can specialize the `Frontend` to customize the following aspects of the Perfetto UI app:

- **Custom Error Handling**: Suppress Perfetto UI's internal error handling, deferring to whatever the host application does in that regard.
- **UI Rendering Control**: Prevent the start-up of the Perfetto UI app from rendering its main interface, allowing the host application can selectively instantiate components.
- **Content Security Policy Strategy**: Customization of how the CSP is installed in the application, with optional filtering of the rules.
- **Storage Customization**: Prefix cache storage keys for coexistence with other applications' caches and for certain application frameworks' restrictions.
- **Routing Hooks**: Intercept or override URL navigation and route handling.
- **Custom PostMessage Handling**: Process unrecognized messages posted to the window.
- **Preloaded Trace Handling**: Indicate how Perfetto should deal with traces pre-loaded in the trace processor backend.
- **App State Restoration**: Delegate Perfetto app state persistence to the host application.

### Example

Suppose you have an application integrating Perfetto:

```typescript
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you share your Frontend implementation? I'd be interested to see what parts of the code you override and what you keep. In essence, it'd be interesting to see what your embeddability requirements are so I can get a better understanding of your challenges.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My application is not open-source so I cannot post code here, but perhaps there are other ways. I shall follow that up.

At any rate, we do customize every one of the extension points currently defined in the Frontend class (and previously in the EmbedderContext) as otherwise they wouldn't be defined. And for the most part the customizations address three peculiarities of our application as compared to the SPA deployment of Perfetto UI:

  • our application is a developer workbench, deployable either as an Electron application (primary target) or as a browser application accessing shared developer workspaces on a server. Interactions with the browser window — especially dealing with location, navigation, and messaging — are different to how an SPA like Perfetto UI would operate and they are the responsibility of the framework on which our application is built
  • our application loads the data from trace files into a number of different views for different analytical purposes, one of which is the Perfetto UI's timeline viewer. Consequently, we load the trace into the trace processor (shell or WASM) a priori, so none of Perfetto UI's trace opening or closing flows apply, including the dialog that detects a running trace processor shell
  • our application loads any number of traces without requiring the user close any of them, especially to enable side-by-side comparison. As the framework on which this application is implemented operates within a single browser window, we do not rely on browser windows or tabs to isolate the timeline viewers from one another

// perfetto-setup.ts
import type {AppImpl} from 'perfetto/ui/dist/core/app_impl';
import {Frontend} from 'perfetto/ui/dist/frontend/frontend';

export class MyFrontend extends Frontend {
override protected installErrorHandlers(): void {
// I do my own error handling
}

override protected mountMainUI(): Disposable {
// I instantiate the UI at my own place in the DOM
return {
[Symbol.dispose]: () => {},
};
}

override protected createApp(args: AppInitArgs): AppImpl {
return super.createApp({
...args,
// Don't get initial route args from the window location
initialRouteArgs: {},
});
}

// ... other overrides

override start(): Promise<void> {
// Set a custom cache prefix for my application
setCachePrefix('https:/');
return super.start();
}
}
```

Then, in your application entrypoint:

```typescript
// index.ts
import {MyFrontend} from './perfetto-setup';

// ...

await new MyFrontend().start(); // Initialize the Perfetto UI
```

This ensures correct initialization of Perfetto's frontend with your application's customizations.
1 change: 1 addition & 0 deletions src/trace_processor/rpc/httpd.cc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const char* kDefaultAllowedCORSOrigins[] = {
"https://ui.perfetto.dev",
"http://localhost:10000",
"http://127.0.0.1:10000",
"file://",
};

class Httpd : public base::HttpRequestHandler {
Expand Down
24 changes: 20 additions & 4 deletions ui/src/base/http_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@

import {assertTrue} from './logging';

let servingRoot: string | undefined;

/**
* Set the root URL prefix for serving assets.
* Useful for embedding applications that cannot rely on the document script.
* If not set, it will be derived when needed from the document script.
*/
export function setServingRoot(root: string): void {
servingRoot = root;
}

export function fetchWithTimeout(
input: RequestInfo,
init: RequestInit,
Expand Down Expand Up @@ -74,17 +85,22 @@ export function fetchWithProgress(
* @returns the directory where the app is served from, e.g. 'v46.0-a2082649b'
*/
export function getServingRoot() {
if (servingRoot !== undefined) {
return servingRoot;
}

// Works out the root directory where the content should be served from
// e.g. `http://origin/v1.2.3/`.
const script = document.currentScript as HTMLScriptElement;

if (script === null) {
// Can be null in tests.
assertTrue(typeof jest !== 'undefined');
return '';
servingRoot = '';
} else {
servingRoot = script.src;
servingRoot = servingRoot.substring(0, servingRoot.lastIndexOf('/') + 1);
}

let root = script.src;
root = root.substring(0, root.lastIndexOf('/') + 1);
return root;
return servingRoot;
}
15 changes: 9 additions & 6 deletions ui/src/core/app_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,15 @@ export class AppImpl implements App {
};
}

openTraceFromFile(file: File): void {
this.openTrace({type: 'FILE', file});
openTraceFromFile(file: File, serializedAppState?: SerializedAppState): void {
this.openTrace({type: 'FILE', file, serializedAppState});
}

openTraceFromMultipleFiles(files: ReadonlyArray<File>): void {
this.openTrace({type: 'MULTIPLE_FILES', files});
openTraceFromMultipleFiles(
files: ReadonlyArray<File>,
serializedAppState?: SerializedAppState,
): void {
this.openTrace({type: 'MULTIPLE_FILES', files, serializedAppState});
}

openTraceFromUrl(url: string, serializedAppState?: SerializedAppState) {
Expand All @@ -341,8 +344,8 @@ export class AppImpl implements App {
this.openTrace({...args, type: 'ARRAY_BUFFER', serializedAppState});
}

openTraceFromHttpRpc(): void {
this.openTrace({type: 'HTTP_RPC'});
openTraceFromHttpRpc(serializedAppState?: SerializedAppState): void {
this.openTrace({type: 'HTTP_RPC', serializedAppState});
}

private async openTrace(src: TraceSource) {
Expand Down
17 changes: 15 additions & 2 deletions ui/src/core/cache_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ const TRACE_CACHE_SIZE = 10;

let LAZY_CACHE: Cache | undefined = undefined;

let cachePrefix = '';

/**
* Set a prefix to the keys used to cache data in the local store.
* Useful for embedding applications to ensure uniqueness of keys.
* By default, there is no prefix.
*/
export function setCachePrefix(prefix: string): void {
cachePrefix = prefix;
}

async function getCache(): Promise<Cache | undefined> {
if (self.caches === undefined) {
// The browser doesn't support cache storage or the page is opened from
Expand Down Expand Up @@ -129,7 +140,7 @@ export async function cacheTrace(
],
]);
await deleteStaleEntries();
const key = `/_${TRACE_CACHE_NAME}/${traceUuid}`;
const key = `${cachePrefix}/_${TRACE_CACHE_NAME}/${traceUuid}`;
await cachePut(key, new Response(trace, {headers}));

// Verify the file was actually cached, large files can silently fail.
Expand All @@ -151,7 +162,9 @@ export async function tryGetTrace(
traceUuid: string,
): Promise<TraceArrayBufferSource | undefined> {
await deleteStaleEntries();
const response = await cacheMatch(`/_${TRACE_CACHE_NAME}/${traceUuid}`);
const response = await cacheMatch(
`${cachePrefix}/_${TRACE_CACHE_NAME}/${traceUuid}`,
);

if (!response) return undefined;
return {
Expand Down
4 changes: 2 additions & 2 deletions ui/src/core/page_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class PageManagerImpl {

// Called by index.ts upon the main frame redraw callback.
renderPageForCurrentRoute(): m.Children {
const route = Router.parseFragment(location.hash);
const route = Router.currentRoute;
this.previousPages.set(route.page, {
page: route.page,
subpage: route.subpage,
Expand All @@ -63,7 +63,7 @@ export class PageManagerImpl {

// Will return undefined if either: (1) the route does not exist; (2) the
// route exists, it requires a trace, but there is no trace loaded.
private renderPageForRoute(page: string, subpage: string) {
renderPageForRoute(page: string, subpage: string): m.Children {
const handler = this.registry.tryGet(page);
if (handler === undefined) {
return undefined;
Expand Down
19 changes: 18 additions & 1 deletion ui/src/core/raf_scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export class RafScheduler implements Raf {
// These happen at the beginning of any animation frame. Used by Animation.
private animationCallbacks = new Set<AnimationCallback>();

// These happen during any animaton frame, after the (optional) DOM redraw.
// These also happen during any animation frame, after the (optional) DOM redraw,
// but unlike the postRedrawCallbacks, they are not automatically removed.
private domRedrawCallbacks = new Set<RedrawCallback>();

// These happen during any animation frame, after the (optional) DOM redraw
// and and DOM redraw call-backs.
private canvasRedrawCallbacks = new Set<RedrawCallback>();

// These happen at the end of full (DOM) animation frames.
Expand Down Expand Up @@ -83,6 +88,16 @@ export class RafScheduler implements Raf {
this.animationCallbacks.delete(cb);
}

addDomRedrawCallback(cb: RedrawCallback): Disposable {
const domRedrawCallbacks = this.domRedrawCallbacks;
domRedrawCallbacks.add(cb);
return {
[Symbol.dispose]() {
domRedrawCallbacks.delete(cb);
},
};
}

addCanvasRedrawCallback(cb: RedrawCallback): Disposable {
this.canvasRedrawCallbacks.add(cb);
const canvasRedrawCallbacks = this.canvasRedrawCallbacks;
Expand Down Expand Up @@ -122,6 +137,8 @@ export class RafScheduler implements Raf {
this.syncDomRedrawMountEntry(element, component);
}

this.domRedrawCallbacks.forEach((cb) => cb());

if (this.recordPerfStats) {
this.perfStats.domRedraw.addValue(performance.now() - redrawStart);
}
Expand Down
55 changes: 47 additions & 8 deletions ui/src/core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import m from 'mithril';
import {assertTrue} from '../base/logging';
import {assertFalse, assertTrue} from '../base/logging';
import {RouteArgs, ROUTE_SCHEMA} from '../public/route_schema';

export const ROUTE_PREFIX = '#!';
Expand Down Expand Up @@ -72,14 +72,32 @@ export interface Route {
export class Router {
private readonly recentChanges: number[] = [];

// frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here.
// This event is decoupled for testing and to avoid circular deps.
onRouteChanged: (route: Route) => void = () => {};
private running = false;

constructor() {
private static instance: Router | undefined;

// The onRouteChanged event is decoupled for testing and to avoid circular deps.
// frontend/Frontend.ts calls maybeOpenTraceFromRoute() + redraw here.
constructor(protected readonly onRouteChanged: (route: Route) => void) {
assertFalse(
Router.instance !== undefined,
'The router has already been created.',
);
Router.instance = this;
}

start(): Disposable {
assertFalse(this.running, 'Router already running');
this.running = true;

const oldOnHashChange = window.onhashchange;
window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e);
const route = Router.parseUrl(window.location.href);
this.onRouteChanged(route);
return {
[Symbol.dispose]: () => {
window.onhashchange = oldOnHashChange;
this.running = false;
},
};
}

private onHashChange(e: HashChangeEvent) {
Expand All @@ -88,6 +106,14 @@ export class Router {
const oldRoute = Router.parseUrl(e.oldURL);
const newRoute = Router.parseUrl(e.newURL);

this.handleRouteChange(oldRoute, newRoute, e.newURL);
}

protected handleRouteChange(
oldRoute: Route,
newRoute: Route,
newURL: string,
): void {
if (
newRoute.args.local_cache_key === undefined &&
oldRoute.args.local_cache_key
Expand Down Expand Up @@ -116,7 +142,7 @@ export class Router {
normalizedFragment += `#${newRoute.fragment}`;
}

if (!e.newURL.endsWith(normalizedFragment)) {
if (!newURL.endsWith(normalizedFragment)) {
location.replace(normalizedFragment);
return;
}
Expand All @@ -125,10 +151,23 @@ export class Router {
}

static navigate(newHash: string) {
this.instance?.navigateHash(newHash);
}

navigateHash(newHash: string): void {
assertTrue(newHash.startsWith(ROUTE_PREFIX));
window.location.hash = newHash;
}

/** Get the current route. */
get route(): Route {
return Router.parseFragment(location.hash);
}

static get currentRoute(): Route {
return Router.instance?.route ?? this.parseFragment(location.hash);
}

// Breaks down a fragment into a Route object.
// Sample input:
// '#!/record/gpu?local_cache_key=abcd-1234#myfragment'
Expand Down
Loading
Loading