The debug panel is a docked overlay that aggregates host-side activity
from three independent event sources into one time-aligned inspector.
It's gated as EXPERIMENTAL because the SDK hooks it consumes aren't
a stable public surface yet.
The panel ships in every build. In dev environments
(VITE_APP_DEBUG=true) it auto-mounts collapsed; in staging/prod it
stays off until enabled via the Settings panel's "Open in debug mode"
button or ?debug=true. Its heavy chunk is dynamically imported, so
users who never see the panel pay zero download cost.
- What you see
- Enabling and disabling
- The three event sources
- Views
- Filters
- Detail pane
- Design concepts
- What's intentionally NOT instrumented
- Adding a new hook
- Known limitations
A resizable, dockable panel at the bottom of the viewport. It mounts
visible whenever debug mode is on; the panel's × button exits debug
mode entirely (see Enabling and disabling).
The left pane is your choice of List or Timeline; the right
pane is the detail inspector for the selected event. A draggable
splitter between them lets you rebalance the panes.
Hover any element in the Timeline for a zero-delay tooltip with the decoded method + summary. Click any row or box to pin it in the detail pane.
The panel ships in every build. Default behavior depends on the build
flag VITE_APP_DEBUG:
- Dev environments (
VITE_APP_DEBUG=true:bun run preview:debuglocally,paseoli.devanddotli.devin CI via theAPP_DEBUGGitHub Environment secret): the panel auto-mounts collapsed (header-only) so it's a one-click expand away without covering content unsolicited. - Staging / production (
VITE_APP_DEBUGunset): the panel is off until the user explicitly opts in.
When the panel isn't mounted, the bus stays in a null-stub state and
every emitDotliDebugEvent(...) call site scattered through main.ts,
bridge.ts, and container.ts is a cheap early-return. The panel's
UI chunk (panel-*.js) is a dynamic import — it isn't fetched until
the panel mounts.
Two ways to explicitly turn the panel on (mounts expanded):
- Settings button: click Open in debug mode at the bottom of
the host Settings menu (below "Share diagnostic"). The button
reloads the current tab with
?debug=trueappended. - URL param: visit any page with
?debug=true. The flag is persisted tosessionStorageand stripped from the URL (so it doesn't leak into the sandbox iframe's strict param validator).
Explicit opt-ins win over the build-time default — once you've toggled within a tab, the persisted choice survives reloads until the tab closes or you toggle the other way.
To turn it off:
- Click Exit debug mode in the host Settings menu — same row as "Open in debug mode" (the button toggles based on current state).
- Click the
×button on the panel header. Both paths setsessionStorage["dotli:truapi-debug"]to"0"and reload, which fully exits debug mode and discards the in-memory event store. - Visit any page with
?debug=off. Also persisted + URL-stripped. - Close the tab — the choice is sessionStorage-scoped, so a fresh tab starts clean regardless.
Every event observed by the panel comes from one of two buses. Each bus is independent; the panel merges their streams into one time- aligned store.
The postMessage protocol between the dotli host and product iframes,
captured from the Rust-backed TrUAPI bridge. Every frame's tag and payload
come from describeWireFrame (packages/ui/src/debug-wire-describe.ts),
which resolves the wire discriminant against @parity/truapi/wire-table:
remote_chain_*— chainHead, chainSpec, and transaction methods (one-to-one wrap of the new Substrate JSON-RPC spec). These keep the pre-port legacy tag names on purpose, the panel's swimlane and annotation logic keys on them. Their SCALE payloads are decoded through a small codec registry into the shapeschain-decode.tsexpects (genesis hash, follow/operation ids, block hashes, events). A malformed frame degrades to{ wireId, bytes }rather than breaking the tap.<export>_<role>— everything else in the generated wire table, named from the lowercased export plus its lifecycle role (e.g.system_handshake_request). These aren't payload-decoded. The detail pane shows the raw SCALE bytes as{ wireId, bytes }.signing_*/session_*/entropy_*/local_storage_*— redacted regardless of decode registration. The detail pane shows{ redacted: true, byteLength }only. Decoded contents, and even the raw bytes, never leave the tap.wire_<id>— fallback for discriminants absent from the wire table, with raw bytes.
Adding payload decode for a new family is one linkage row in
debug-wire-describe.ts, nothing else in the panel changes.
Dotli-internal host-side orchestration, captured by
onDotliDebugEvent from @dotli/truapi-debug/dotli-debug-bus.
boot:*—started,protocol_warmup_started,topbar_ready,url_parsed,cid_cache_checked,landing_page_shown,ready,failed.resolve:*—started,phase,storage_read,completed,failed.render:*—iframe_begin,iframe_ready.bridge:*—setup_begin,setup_ready,iframe_load,first_inbound,first_outbound.failover:*—chain_backend(user-triggered backend switch).
Both views operate on the same filtered slice of the event store. Clicking an event in one view pins the same event in the detail pane regardless of which view is active.
A chronological row per event. Each row shows:
- Wall-clock timestamp (HH:MM:SS.mmm).
- Direction arrow (▶ outgoing, ◀ incoming) for TrUAPI; a layer badge
(
boot,resolve,bridge, …) for system events. - Product id (for TrUAPI) or a flow-id badge (for system) — both tinted by a stable hash of the id, so rows belonging to the same request / flow share a colour.
- Method tag or
layer.eventidentifier, color-coded by category (request / response / receive / system). - Inline summary — either decoded chain annotations (block hash, opId tail, outcome) or the system-event one-liner.
- Delta from the group's first event (e.g. RTT on a response, time- since-start on a subscription receive).
Under heavy traffic the list renders incrementally — new rows are appended without rebuilding existing DOM, so clicks on older rows stay responsive while events stream in.
A 2-D layout with time on the Y axis (top = earlier, newest at the bottom). Events are grouped into vertical swimlanes and lane- packed within each swimlane: a new box occupies the leftmost lane that isn't already taken at its Y range.
Each swimlane has its own horizontal scroll, so a chain with many concurrent operations can grow wide without pushing the whole view. Vertical scroll is shared across all swimlanes, so events at the same Y in different swimlanes occurred at the same moment.
The filter bar at the top of the panel applies to both views:
- TrUAPI / System checkboxes — coarse kind toggle. Unchecking hides events of that kind from both views, and the affected swimlanes disappear when they have nothing to show.
- Direction chips (both / out / in) — TrUAPI-only; system events are shown regardless.
- Product chips — one per distinct
productIdseen; TrUAPI-only. - Tag filter — case-insensitive substring match. Matches against
the TrUAPI method tag for TrUAPI events, and against
layer:eventfor system events.
Pause / Clear buttons are in the panel header:
- Pause — stops ingesting new events. Already-stored events stay visible; resume to start ingesting again.
- Clear — drops the ring buffer. The selected-event detail resets to "(select an event to inspect)".
Click any row, box, or pill to pin it. The detail pane shows:
- Core metadata: time, direction, product, TrUAPI method tag, requestId, flow/group size.
- Sibling pills (List view only) — every other event sharing the
same
requestId, with delta times. Click to jump. - Summary — a human-readable one-liner describing what the chain-protocol message means (decoded from the JSON-RPC shape).
- Chain annotations — the correlation keys that are buried
inside the payload:
genesisHash,followSubscriptionId,operationId,blockHash, event tag, outcome. - Full payload JSON, pretty-printed with Uint8Array values rendered as hex.
- Core metadata: time, source, layer, event, flowId.
- Summary — the short sentence from
system-summary.ts. - "What is this?" — a collapsible
<details>block with a long-form paragraph explaining what the subsystem is doing at this point, what triggered the event, and what it unblocks downstream. One entry per event variant (seepackages/truapi-debug/src/system-explanations.ts). - Full payload JSON.
When a box is clicked in Timeline view, the detail pane stacks every member of the box's group chronologically. Each member has its own Summary, "What is this?", and payload JSON. This is useful for long flows (for example, a chainHead.body that streamed N result events through its follow subscription): you see the whole sequence in one glance.
Every event in the store has a correlation key, used for grouping:
- TrUAPI events key on
requestId— the same id that the transport uses to correlate requests with responses or to stream receives on a subscription. - System events key on
flowId— a uuid generated by the emit site at the start of a flow. Every event in the same flow shares the flowId. Host-originated session actions use their per-actionmessageIdas the flowId so each action is its own mini-flow.
eventsInGroup(key) and firstInGroup(key) query the store
uniformly regardless of the key's origin.
In the Timeline:
- A box represents a flow that has (or will have) a clear beginning and end. Examples: a TrUAPI request → response pair, a chainHead.body operation from request → terminal event on its follow subscription.
- A pill represents a point-in-time event with no natural end —
failover:chain_backendand single-event system flows. - Pending boxes have a dashed bottom edge. They grow downward as time advances; once the terminal event lands, the box snaps to its final height.
The mapping from event to box is encoded in timeline-layout.ts:
segmentForGroup for TrUAPI, systemSegmentForGroup for system
events. Subscriptions that aren't request-response-shaped (e.g.
chainHead.follow) are drawn as rails — thin vertical lines in
their own left-edge column — not boxes.
- Chain swimlanes — one per distinct
genesisHashobserved among TrUAPI chain messages. The swimlane only appears once aremote_chain_head_follow_starthas been observed for that chain (otherwise it'd clutter the view with one-off queries). The header shows the chain's friendly name when known (Paseo, Paseo Asset Hub, Paseo Bulletin) or a short hex hash otherwise. - System swimlane — everything from the system event buses. Always present if any system event is visible.
- Other swimlane — TrUAPI traffic that isn't chain-related (host API calls for signing, storage, accounts, statement store, etc.).
Swimlane order: chain swimlanes first (alphabetical by genesis), then System, then Other.
Boot events fire in main() within the first few milliseconds of
page load. The panel itself is a dynamic import that might take
tens of hundreds of ms to resolve, during which the bus has no
listener.
To avoid dropping those early events, the dotli bus supports an
opt-in buffer. main.ts calls enableDotliDebugBuffering() as
soon as it knows the panel will be loaded, before the first emit.
Events emitted during the panel's load window are retained in a
bounded ring (capacity 512); the first subscriber triggers a
one-shot replay. After replay, buffering switches off and
subsequent no-listener emits go back to being silent no-ops.
This makes the captured boot sequence consistent regardless of browser cache warmth.
The TrUAPI hook does not need the same mechanism in practice: traffic cannot start before a product iframe is rendered, long after the panel is up.
Both the list and the timeline renderers avoid innerHTML = on
steady-state updates. Every logical element (row, box, rail, tick,
label, connector) carries a data-key derived from its stable id
(event seq, segment anchor, etc.). On each render we walk the new
layout and either update an existing element's attributes in place
or create it if it didn't exist; elements whose key is absent from
the new layout are removed.
This keeps hover state stable and means clicks don't get dropped
between pointerdown and click when events are streaming in — the
earlier bug class where <details> expansion and timeline-box
selection were flaky under heavy traffic.
The detail pane is rebuilt only on user-initiated events (clicks,
tab swap, filter change, clear). Incoming events don't touch it,
so the <details> expand-collapse interaction is always stable.
The design rule: hook places where independent decision-making logic lives. Skip layers that just translate or forward messages from a layer already being observed.
host-substrate-chain-connection(WS provider, BranchedProvider, metadata cache). Pure translation ofremote_chain_*TrUAPI calls into JSON-RPC over WebSocket. The TrUAPI hook already shows the semantic shape; the JSON-RPC layer is just the wire format.- statement-store transport (encrypt/decrypt, RPC adapter, subscription multiplexer). This remains opaque chain traffic at the host boundary.
- smoldot internals (raw log callback stream). Dependency chatter, not host logic. The resolver's decision outcomes are captured instead.
- Raw iframe postMessage. The TrUAPI hook already captures the transport envelope and payload bytes at the bridge boundary. Add method-payload decoding there if the raw bytes become insufficient.
- handoff-service file chunking. Transfer transport, not semantic protocol.
If any of these become useful to observe in practice, they can be added without restructuring anything: each hook is an independent bus and the panel subscribes to all of them.
- Pick the right bus. If the event originates in dotli host orchestration, use the dotli bus. If it originates in TrUAPI traffic use the dotli bus; TrUAPI wire traffic uses the TrUAPI event shape.
- Add an event type. Extend the relevant discriminated union with
a new variant carrying
layer,event,flowId,timestamp, and a typedpayload. - Add a summary. Extend
system-summary.ts(one-liner) andsystem-explanations.ts(long-form "What is this?"). - Add a flow terminator if needed. If the new event closes a
multi-step flow, add its event name (or a shared terminal suffix) to
SYSTEM_TERMINATOR_SUFFIXES, or extendisSystemFlowTerminator()intimeline-layout.ts, so pending-vs-complete is computed right. - Emit. Call the matching debug-bus emitter at the decision point.
Check
hasDotliDebugListeners()first if payload construction is expensive.
No UI changes required — the filters, swimlanes, and detail pane pick up new variants automatically.
- Ring buffer is fixed at 2000 events. Large enough for most
interactive debugging, but a long-running session that generates
lots of
NewBlock/BestBlockChangedticks will evict older events. A future iteration could expose capacity as a setting or add a "pause when full" mode. - No time-based Y scale yet. Timeline Y is event-index-based
(each event = fixed
ROW_HEIGHTpixels), not wall-clock-based. Works well for dense bursts but doesn't preserve idle-period relative sizing. A proper time scale is a follow-up. - Timeline keyboard navigation is list-only right now. ↑/↓ step through the list; there's no equivalent in Timeline view yet.
- No export / import. Captured events are tab-local and discarded on reload.