RFC-0009: Embeddable UI #3604
Replies: 4 comments 3 replies
-
|
@cdamus Apologies for how long it took me to get this together, but I would really value your feedback on this :) |
Beta Was this translation helpful? Give feedback.
-
|
A glaring omission from this doc is any mention of the omnibox, and practically how modals will work. The concern is mostly around how to trigger an omnibox prompt or modal in the correct UI instance if we can now have multiple instances. Currently for modals we just call the global It seems that some state currently in the app or globals actually needs to be per UI instance, rather than one global one for all instances. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks a lot for this. this is... A LOT. My main reaction after getting to the bottom of this proposal is that IMHO this should be split really into two different discussions:
The same applies here. We want to split out things like analytics, default plugins, error reporting, to give flexibility to embedders. I find a bit difficult to reason atomically about both 1+2 (although at some point they will not be completely disjoint)
Ok but we need to handle backward compat somehow.
Do we actually need this? This feels a further mainteinance burden TBH with little value for ourselves. I think we should stop at "the codebase is in the right shape... now other people can make a good use of it" I'd be more in favour of splitting things in folders in a way that clearly decouples the "common embeddable part" vs "our App (with our postmessage etc)" I would NOT split bundles for ourselves, as that seems a recipe for further debugging headaches, which would not benefit us at all. In other words, I am in favour of the architectural cleanup, but I am not a fan of the "splitting into library and figuring out redist" part. I think we should limit our support to "you can define a site.ts / installtion.ts (name TBD) and you can configure hooks when forking the repo"
+1. >1 AppImpl has too many edge cases and, more importantly, increased cognitive load that we'd have to keep in mind for a use case we never use (which means we'd break it regularly)
Strong +1, just I'd probably do something at the registry level (e.g. introduce the notion of "parent registry" or similar) rather than doing on a case-by-case basis? (unless practicalities prove otherwise)? There is also something else to say, probably more for the longer term to avoid scope creep: I'd probably switch to a model where the intersection of AppImpl and TraceImpl is empty. So rather than saying "there is an omnibox manager, which works both for app and trace, etc etc" I'd say:
We should prob invert this dependency and have loadTrace to take a listener interface, and wire it up on the other end
Well pragmatically we need to rely on reset.js or similar.
+1 although the css styling might be tricky. we should audit all cases of
I think this is going to be more complicated...perhaps the trickiest part of all this proposal. If you just stub out router.navigate() various things will break as plugins depend on that.
The problem is that in order for this to work you need to pass down the TraceImpl object into the m(Anchor) no? Maybe there is some alternative approach where we can intercept navigation attempts and re-route them to the specific TraceImpl?
Yes this needs to stay global. AnimationFrame scheduling is necessarily global. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks @stevegolton this proposal largely aligns with the current implementation in my fork of the Perfetto repository of support for embedding and multiple open traces. I have some specific comments to offer in-line, below.
Yes, I think one of the principal architectural points currently is the small number of global singletons that various far-flung bits of code reach out to. I'm thinking especially of the AppImpl, the Router and its static methods, and the HttpRpcEngine (really only the static TCP port to connect to). The trace converter and other webworkers don't seem like they would likely figure into an embedding application (they don't in mine, though its does use the WASM trace processor), the HttpRpcEngine is easily dealt with, the Router could be injected into components that need it, and as discussed in this document the AppImpl can remain a singleton if it is separated from the trace(s). At least, that has not presented any difficulties for my embedding application. I suppose even the Router could be published by the AppImpl, then its API could be non-static.
A separate embedder build would be very helpful. That can also then help embedders to integrate the CSS stylesheet in the most efficient way for them. This raises the question of some other assets such as the
I don't imagine that it would make sense for many applications to have different configurations for multiple AppImpls, for example different plug-ins activated or different feature flags enabled. It certainly doesn't make sense for mine. So this isn't much of a "con" ;-)
In theory the AppImpl could hold the complete set of loaded traces (make them available for perusal) and could track an "active" trace as the one that the user is currently engaged with. This is what the fork of Perfetto UI in my application does. In general, I would anticipate that in any embedding application there will be a singular "input focus" and whatever trace was last implicated by the input focus could be the "active" trace. And an application for which this doesn't make sense could simply never "activate" a trace in the AppImpl. But then that would have consequences for any component or plug-in that expects the AppImpl to be able to provide a trace for it to act on. But it's already the case that the AppImpl may not have a trace if none has yet been loaded in the session. Moreover, UI components requiring a trace have already been refactored to inject the trace into them (that was merged in August 2025)
As mentioned above, there could be the half-way position of maintaining an "active" trace, but I can understand that this may be deprecated. If for no other reason than it may become too easy to rely on it as the "one true trace" as in olden times.
I don't understand what is the advantage of parameterizing a Command with a trace provided by the caller versus requiring the Command to be registered in the context (CommandManager) of the Trace and not of the AppImpl. Commands are contributed by plug-ins, which instances are already scoped to a trace. So would it not be feasible to require them to register commands that operate on a trace in the context of the trace and not the AppImpl? I half expect that plug-ins are doing this already.
This raises an interesting question. Perfetto UI has long provided a mechanism by which to suppress the sidebar (the "embedded mode"). Would it be necessary/useful to provide some API by which plug-ins can learn that extension points they would contribute to, such as the sidebar, are not in play, so they would simply not offer those contributions? Or would do something else? Would it make sense to allow embedder applications to replace the omnibox altogether? For example, my application is implemented on a framework that itself provides a command palette after the VS Code fashion, with commands covering a much wider scope (obviously) than a loaded Perfetto trace. This function of the omnibox is duplicative in my application, and even the other use cases such of ad hoc SQL queries and text input would be straight-forward to implement in my application's palette ... if I could hook into Perfetto UI's omnibox manager to delegate the UI.
Ideally any "progress activity" incurred by a trace could be reported via a pluggable interface to the host application, to manifest it in its own UI (status bar, progress toast, etc.). Some kind of ProgressMonitor interface, very loosely sketched: > // Initialize a common, global app - this function must be called first.
> AppImpl.initialize({
progressMonitor: {
onActivityStarted(activity: { id: unknown; title: string; description?: string; totalWork?: number ) { /* */ },
onActivityProgress(progress: { activityId: unknown; task?: string; work?: number ) { /* */ },
onActivityDone(status: { activityId: unknown; success?: true; error?: any} ) { /* */ },
},
// ... other embedder hooks ...
});
If the
This consequence has not been an issue for my application at all, if it helps to know that. It makes perfect sense that there should be only a single RafScheduler to manage the browser's single series of animation frames. However, in an application that presents multiple open traces (such as mine) it is natural to expect that these might be implemented in some sort of tabbed/paged interface within the browser such that some open timelines are actually hidden from view. These, then, would not need to redraw until they are next shown. Ideally the RafScheduler could know that some mounts are dormant in this way and skip them in the redraw, keeping their redraw requests deferred until those mounts are shown.
All good points. Even if the outcome of this process is not a formal API and long-term commitment to compatibility with the embedded scenario, it can bring about architectural changes that will make it feasible for forks to maintain for themselves the integration hooks that make it work. As it stands, a fork must make some rather deep changes in code that sees continual churn, making on-going synchronization from upstream Perfetto repository difficult. Of course, the best case is a Perfetto UI that I can just drop into my app, configure, and go :-) Having seen for myself, in a project that has been embedding Perfetto UI since about March 2023, how much the architecture has evolved and crystallized since then already to bring us much closer a fully functional integration, I am hopeful that there is not so much further to go! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
📄 RFC Doc: 0009-embeddable-ui.md
Embeddable UI
Authors: @stevegolton
Status: Draft
Problem
This doc outlines the process of making the UI embeddable - i.e. make it
possible to instantiate one or more instances of the UI within another web-based
application and control the instances from the host application, without the use
of iframes or other isolation techniques.
Perfetto currently expects to be the only thing in the web page and thus
directly accesses things like the DOM, the URL, and postmessage channels. What's
more, it is entirely written to be self-contained, and doesn't offer any
interface through which it can be configured programmatically.
Compelling Example
A web app that embeds two instances of the perfetto UI, loads two traces from
some arbitrary source and injects them into each instance. Traces are managed
entirely from the host application. The two instances should look and feel like
the host application as much as possible.
Requirements
app, avoiding collisions and conflicts with the host app also other instances
of the Perfetto UI.
router logic).
restrictions?
customize the UI's behavior and appearance.
load).
look more like their own app.
Design
The design outlined in this doc revolves around the idea of splitting Perfetto
into two parts:
bindings. This is the reusable core which can be used in the standalone and
embedded contexts. This idea is that this library will be configurable,
conservative, and avoid touching the DOM or too many browser APIs, and won't
have any import time.
browser and mounts the root mithril node, etc. This is the UI that will be
hosted at ui.perfetto.dev.
This separation actually somewhat exists right now with
frontend/index.ts+common.scssbeing the standalone part, and the rest of the code being thelibrary. The separation however is not perfect. The core of the app definitely
directly accesses a lot of DOM and browser APIs but the general outline is
already there, we just need to lean into this architecture a little more.
Building and Importing
How would an embedder actually import and use the generated bundle?
If we imported the current frontend bundle directly, we would end up importing
all of the
frontend/index.tscode as well which would immediately run onstartup, which is not what we want. Ideally we should have two builds - one for
the library that doesn't run anything and simply exports the classes and
functions required by the embedding application.
We can configure rollup to build us two bundles - a library and a standalone
webapp:
Then in the embedder or the standalone we can use the bundle like so:
This will allow us to ship the library to 3rd parties and package the standalone
version for us. It's crucial that the library doesn't have any side effects at
the root level (e.g. when importing).
Shared app vs separate apps
If we can have multiple instances of Perfetto in a single page, this raises the
question of whether we have one
appor many?Shared 'App' (Preferred)
Pros:
globals in place.
global state to store app related entities, such as settings, in their
onActivatefunction.we can access the global instance
AppImpl.instance.Cons:
this is stored in the trace object).
app.trace,because one app can now be used with many traces. We would need to invert the
dependencies, and also separate the shared registries such as commands,
sidebar entries, and pages so that the trace object has its own set of
registries.
Separate Apps
Pros:
Cons:
settings references registered in onActivate for instance to global storage so
that it can be used inside onTraceLoad().
components.
Multi-trace Tenancy
Given that we have one app and many traces, we must remove any state from any
given trace from the app.
Trace-scoped Entities
Right now, when commands et al are registered with a trace, they are actually
added to the app's registry and removed when the trace is disposed. This won't
work if we can have multiple traces loaded simultaneously with a shared single
app instance.
Instead we should:
searched first, followed by the app's registry.
entity is added to the trace, it first checks for the existence of that entity
in the app and throws if there exists a duplicate.
Example implementation:
This decoupling also makes it much easier to dispose of a trace, as all state
related to a trace is contained within the trace object itself. We can simply
drop all references to a given trace object when we're done with it, we don't
need an explicit dispose step (with the exception of stopping the web worker
perhaps).
App-scoped Entities
Right now, the app stores a reference to the currently loaded trace. This is
useful for app-registered entities (e.g. commands) as they can simply call
app.traceto look up which trace they're working on.Only one trace can be loaded at a time within the current environment so that
makes perfect sense. However, if we're now saying that the embedder takes
control of the trace, and the app has no concept of which traces are available,
what are the legitimate uses of the trace. Which trace do app-registered
entities work with?
In order to do this, we must now inject the trace into the command's callback
via some attribute. This trace object must originate from the calling context.
E.g.
Trace Management
Right now, plugins et al can simply open a trace by calling openTrace on the
app, however in this new model we are taking the stance that the currently
loaded trace is controlled by the embedder and injected into the UI. However, it
can still be useful to allow plugins to be able to ask the embedder to open a
trace from within a plugin etc.
E.g. how do we manage this:
The most logical solution here is to defer this to the embedder.
In our embedded scenario:
The embedder in this scenario would simply avoid adding any plugins that open
traces to avoid this error from ever occurring.
Trace loading statuses
When loading a trace
load_trace.tscurrently calls into the app to set theomnibox text when the trace is loading in order to provide feedback to the user
about the trace loading process - now this state needs to be stored in the
individual trace object that's doing the loading. This way, the UiMain object
that's looking at the trace object can just glean the status from that instead.
WASM and HTTP-RPC Engines
When we call
app.openTraceFromFile(), this will kick off a new independentbackground thread and WASM instance per trace, rather than attempting to keep
the same background worker instance.
Cons: Could produce memory spikes when switching traces.
CSS
CSS has been largely fixed by the introduction of the pf-* prefix to avoid
collisions with classes in the parent page. We still do use some generic
selectors in common.scss such as h1, h2, h3 and similar, but in the standalone
example these are very reasonable. We could either include them in the
standalone build and omit for the embedded, or we could move them behind
specific style classes.
See: #2436
Within the embedded application, Perfetto's bundled CSS may be imported
dynamically or just added to the HTML header.
There still exists a lot of element styling - CSS rules targeting element types
directly e.g. h1, html, body, in
common.scss. These styles belong squarely inthe domain of the standalone UI so we should bundle them separately (in the same
vein as we will bundle the JS bundles separately) and only include these
dedicated styles in the standalone build.
Theming
Similarly to the above, theming is supported via standalone CSS variables.
In the standalone application, we inject this in using the
ThemeProvidermithril component. This injects the CSS variables depending on the theme
setting.
Within an embedded application, this ThemeProvider can be omitted assuming the
variables are injected via some other method.
Hotkey Capturing
Currently hotkeys are bound to the document. We currently use a
HotkeyContextmithril component which wraps most of the UI, and provides a focusable wrapper
which, when clicked on, becomes the focused element on the page and can capture
all following keyboard events. This is used in the current standalone UI like
so:
However, this is largely disabled in the current UI as it causes focus loss
issues. It doesn't work well with programmatic
element.blur()as used in theomnibox for example. When this function is called, the user agent moves the
focus back to the root, not the nearest focusable ancestor. We would need to
implement something to handle this for us - possibly using a custom event.
We would have to scour the codebase for all .blur() calls and instead dispatch
one of these delegatefocus events. Any embedder won't have any idea
what this even is and won't capture it, so it's unlikely to cause any issues
with the embedder.
This mechanism can actually even be used quite effectively to define hotkeys for
specific subpages - e.g. the explore page - by wrapping the page in a
HotkeyContextand providing a custom set of hotkeys/callbacks for that page.Error Handling
We configure error handlers in
frontend/index.tscurrently. This can remainas-is and these will not be configured in the embedded build. It's up to the
embedder to register root level error handlers to catch Perfetto crashes by
registering handlers for uncaught errors that propagate to the window:
Reporting errors from workers
Currently logging.reportError is called directly when errors occur inside
traceconv worker and/or the service worker. TODO: Needs more work to understand
exactly how these should behave. Ideally they should invoke an error that gets
thrown on the window's context so that it can propagate up and get handled at
the root level as above.
Modals
showModal()uses a global to store the current modal. This causes problemswhen we have multiple UI instances in the same page - that one modal triggered
from one UI will show up in both UIs. We could fix this by storing the modal
state at the root of the UIMain instead, seeing as it's squarely a property of
the UI.
Plugins
The embedder should be in control of which plugins are
initiated. We can make this an option when initializing Perfetto.
The standalone app will naturally import all of them and enable those only in
the default list.
Because of the global app state, we don't have to separate out the plugin
enabled/disabled settings (which are currently stored in the
perfettoFlagsetting in localstorage). This does mean however, that if a plugin is enabled in
one instance, it'll also be enabled in another instance and vice versa. I think
this isn't the end of the world and is an understandable tradeoff.
It makes sense that the embedder should omit plugins that don't make any sense
in the embedded case - e.g. the example traces or the 'Open trace from file'
button. We will have to go through the plugins we have and perhaps separate them
out into smaller reusable chunks so that the embedder may pick and choose the
ones they need vs the ones that make no sense for the app.
Storage
In order to avoid collisions with local and cache storage keys between Perfetto
and the embedder, we should make it so that the key can be prefixed.
Routing
It makes sense that the embedder should have complete control over the route of
the UI so that it can keep the UI focused on a given page and change it
programmatically.
Anchors
Anchors that change the hash route are now just never going to work as they will
change the embedder's route. This could be quite complex. We could try and leave
the anchors in place and override them in embedders, though that goes against
the rest of the ethos in this doc of doing nothing by default.
We should probably introduce a new API on router so anchors can now link to new
pages and change the route like this:
We could even wrap this up behind the
Anchorwidget, and grey out the widgetif the embedder has not injected the correct link.
The href attribute is still supported for external links.
RAF Scheduler
While it would be nice to isolate refreshes to only the UI instance that needs
it - the most practical solution to dealing with the raf would be to just keep
it as a global object. Indeed the 'm' (mithril object) is global anyway, so we'd
have the same problem if we were to use m.mount() rather than raf.mount().
The ramifications of this mean that if one UI instance triggers an update, then
all UI instances will refresh, but that's a reasonable tradeoff for the
complexity that attempting to separate them would involve.
Analytics
Analytics would be configured by the standalone app, we can inject an analytics
handler into Perfetto at configuration time.
By default
logEvent()will just be a no-op.Other
The following functionality will be entirely handled by the standalone app or the
embedder. Perfetto doesn't need to get involved at all.
API Surface
The
AppImpl.initialize()function is the primary entry point for both standalone andembedded applications. It initializes the global app instance and configures all
the hooks that allow Perfetto to interact with its hosting environment.
Complete TypeScript Interface
Risks
essentially making sure that all new code doesn't assume that the standalone
application is the only deployment context. This will introduce an additional
review burden.
addressed in this document.
that is difficult to change in the future. Is this something we want to agree
to and maintain going forward? There will be some additional overhead to test
for regressions?
💬 Discussion Guidelines:
Beta Was this translation helpful? Give feedback.
All reactions