Skip to main content
zerotal

DevTools

@zerotal/devtools records a trace of every request — SQL queries, N+1 warnings, console logs, mail previews, cache operations, queued jobs, and whatever else your installed packages contribute — and streams them to a floating panel in your browser. No browser extension needed: in development the panel is injected for you, and it connects over Server-Sent Events.

DevtoolsProvider is gated to the web environment and is a no-op when APP_ENV=production or APP_ENV=prod.

Getting Started

# in your project root
bun add @zerotal/devtools

Register the provider

Add DevtoolsProvider to the providers array in bootstrap/providers.ts:

// bootstrap/providers.ts
import { DatabaseProvider } from "@zerotal/orm";
import { DevtoolsProvider } from "@zerotal/devtools";

const providers = [
  // …your other providers
  DatabaseProvider,
  DevtoolsProvider,
];

export default providers;

The provider declares static environments = ["web"], so it only activates for web requests. Within that, registering it switches on the following hooks (in lifecycle order, all of them additionally short-circuited in production):

  • onBooting — builds the trace store from your config, binds devtools.trace so other packages can contribute, and registers DevtoolsInjectionMiddleware via app.useOnce(), so you never add it to .use([…]) manually. The middleware serves the panel's API endpoints (see Endpoints).
  • onBooted — subscribes to FrameworkEvents for tracing, patches console.* to capture logs per request, opens the SSE bridge, then prints the inspector banner.
  • onStopping — unsubscribes, restores the original console.* methods, drops the declared channels, and closes the trace store.

Note — Nothing happens at import time. The trace store opens its database on first use rather than in a constructor, so importing this package in an app that runs in production writes nothing and starts no timer. N+1 detection itself is owned by the ORM provider (and env-gated there); DevTools only consumes the NPlusOneDetected event and surfaces it.

Start the client panel

In your frontend entry (resources/js/app.js), import and start the client:

// resources/js/app.js
import { DevTools } from "@zerotal/devtools/client";

DevTools.start();

This connects to the SSE stream and mounts the floating panel at the bottom of the page. Press Alt+D (or Cmd+D on Mac) to toggle it open.

Drag the strip along the panel's top edge to resize it; the height is kept. The button cycling ◐ ● ○ in the bar switches the theme between following your system, dark, and light — for when the panel and the page you are debugging disagree.

Keyboard

The panel's shortcuts fire only while the panel has focus — click it once. It is an overlay on your application, and binding j globally would navigate the trace list every time you typed into one of your own forms. Alt+D is the exception, because it is how you reach a panel that does not have focus yet.

KeyDoes
Alt+DToggle the panel (works anywhere)
j / Select the next request
k / Select the previous request
19Jump to the nth tab
/Open All and focus the filter
EscLeave the filter, or close the panel

j and k step through the filtered list, so narrowing first and stepping after works the way you would expect.

DevTools.start() accepts three optional fields:

// resources/js/app.js
DevTools.start({ endpoint: "/__zerotal/devtools", mode: "floating" }); // defaults
FieldRequiredDefaultDescription
endpointno"/__zerotal/devtools"Base path the client uses for the SSE stream and API routes.
modeno"floating""floating" pins a collapsible bar; "standalone" fills the window.
mountnodocument.bodyElement to mount into.

"standalone" is what the inspector dashboard at /__zerotal/devtools uses — same renderers, same tabs, mounted full-window instead of docked.

The panel

Two sections, switched in the tab strip.

Requests is the trace stream — what the app just did. Nine built-in tabs, each focused on a different concern for the current request, plus a tab for every channel an installed package declares. New traces stream in live; click any row in the All tab to pin an older request and inspect it across every tab.

App is the framework map — what the app is: its routes, config, container, providers, events, and the commands and scheduled tasks that have run. Two sections rather than fifteen tabs in one scrolling strip, because they answer different questions.

The floating panel and the standalone dashboard at /__zerotal/devtools are the same panel — the dashboard is it mounted full-window. A tab added by any package therefore appears in both.

Queries tab

The default tab — shows an overview of the request followed by every SQL query executed:

  • Route pattern + Controller@action
  • The error, when the request threw — message and status, above everything else, because on a failed request that is the answer
  • Duration, query count, total DB time, heap memory, and the authenticated user
  • N+1 warnings — flagged when the same query shape repeats during a request, each with the eager-load that removes it and the call that suppresses it
  • Each query: SQL, bindings, duration bar, row count, and the line of your code that ran it — see Editor links

Timeline tab

Everything the request did, on one waterfall: queries, cache operations, mail, jobs, log lines, and channel entries, each placed by its offset from the request start and sized by its duration. Every entry already carried that offset — the waterfall is what makes "what was waiting on what" legible instead of a column of numbers you have to order in your head.

Above it, what the browser measured for this page load: time to first byte, parse, load, and first contentful paint. Server duration reported as though it were the user's experience is a panel's most misleading number — a 12ms response the browser then spends 900ms painting is a slow page. Kept visibly separate from the waterfall, because these describe the page and not this request.

Logs tab

Every console.log, .debug, .info, .warn, and .error call captured during the request, with an offset timestamp and level colour-coding.

Request tab

Both halves of the exchange: the status line, query string parameters, request headers, response headers, and the names of the keys in the session.

Headers are an allowlist rather than a denylist, because a trace is persisted — a header nobody thought to deny is a header on disk for a day. devtools.headers opens up the ones you are actually debugging; cookie and authorization are never recorded whatever you ask for, because they are the request.

Session keys only, never values. "Is the CSRF token there, did the flash survive the redirect, is the user id set" are all answered by the keys, and the values are this request's real state.

Exception tab

For a request that threw: the error's type, its message, and the full stack with every frame a link into your editor.

Framework frames are kept and dimmed rather than dropped. You read a stack trace to find out how you got somewhere, and a trace with the middle removed does not tell you that — but in a forty-frame trace the six you wrote should be the ones that stand out.

Mail tab

Every mail notification sent or queued during the request — notification class name, recipients, subject, send time, and queued/sent status. Each one carries a Preview, collapsed by default, that renders the actual email in a fully sandboxed frame: no scripts, no same-origin access, no navigation. That is not optional hardening — the panel lives on your app's own origin, so rendering a template's markup inline would make any user input inside a mail a self-XSS.

Cache tab

Every cache operation performed during the request — has, hit, miss, write, forget, and flush — with the key, operation type, TTL, and offset timestamp.

Jobs tab

Every job dispatched (or processed synchronously) during the request — class name, queue, status (dispatched / completed / failed), duration, and any error message.

Channel tabs

One tab per channel an installed package declares — Inertia from @zerotal/inertia, Auth from @zerotal/auth, Flow from @zerotal/flow, and any your own packages add. Each is rendered from the channel's own descriptor, in whichever presentation it asked for, so the tab exists without DevTools shipping code for it. See Contributing a tab.

All tab

The full request history for the current session, with a filter box and a row of facet chips. Click any row to pin that request's trace in all other tabs. A request that threw is marked in red and carries its error message inline, so you can find the one that broke without opening each in turn.

Text narrows rather than widens: posts 500 finds failing requests to /posts, matching method, path, status code, and the matched route's pattern, controller, and action.

Facets compose with it and with each other. Method chips list only the verbs actually recorded — an app that only ever GETs gets one chip, not five. Then 2xx/3xx/4xx/5xx, and three toggles:

ChipKeeps
errorsRequests that threw, plus any 4xx or 5xx — a rendered 404 counts.
slowOver 300ms, the same line the duration colouring already draws.
n+1Requests carrying an N+1 warning.

Picking two chips in one row means either; picking chips in two rows means both. POST with 5xx is failing writes, not writes-or-failures.

Above 200 rows the list renders only what the viewport can reach, so a large capacity is a list you can scroll rather than thousands of nodes.

Requests a channel says belong together are folded into one entry under the oldest of them, with a +N toggle to open the rest. One thing you did is often several requests — a page visit and the deferred props that arrive after it — and listing them as unrelated siblings is how the request you are reading gets pushed off the top. Which requests correlate is the channel's to declare; see traceGroup.

The panel remembers where you were — whether it was open, which tab you were on, and what you had filtered to — across a reload. On a page you are reloading because you are debugging it, that is the wrong moment to lose your place.

The App section

Everything above reads the trace stream. These six read the framework's own registries — which existed all along and were CLI-only or invisible, so "is that route even registered", "who bound cache", and "does anything actually listen to OrderPlaced" were questions you answered by reading source.

TabShows
RoutesMethod, path, name, handler, middleware. GETs are clickable.
ConfigThe resolved tree, flattened to dotted paths, secrets masked.
ContainerEvery binding, its kind, and which provider bound it.
ProvidersBoot order — which decides who wins a contested binding — and per-provider cost.
EventsApplication listeners and framework subscribers, in one list.
CommandsConsole commands and scheduled tasks, with outcome and duration.

All six share one read of one map, taken when you first open the section and cached after — six requests for it would be six answers that can disagree. The button re-reads it, for the case where a provider registered a route late.

Note — The Config tab masks a bare key as well as everything the redaction rules already cover, because app.key is your application's encryption key. dsn too. Config is the one place secrets are supposed to live, so it gets the benefit of the doubt in the other direction.

Commands is the answer to the thing the rest of the panel structurally cannot show: a scheduled task that fails at 03:00 has no request to hang off, so until now it left no trace in the tool whose job is to show you what your app did. The feed keeps the last 200 entries for the life of the process.

How traces are captured

DevTools never polls or wraps your code. On boot it subscribes to FrameworkEvents and buffers each event against the active request context until the request finishes:

QueryExecuted ────┐
NPlusOneDetected ─┤
MessageSent ──────┤   per-request buffer (WeakMap keyed by HttpContext)
CacheQueried ─────┤        │
JobRan ───────────┤        │ RequestHandled / RequestFailed
channel entries ──┤        │
console.* ────────┘        ▼
                     RequestTrace → traceStore().push()
                               │
                               ▼  SSE
                         browser panel

Buffers are keyed by the HttpContext in a WeakMap, so they are garbage collected with the request and capture events even from phases that run before the middleware (such as auth loading the user). Internal framework paths (/__zerotal/, /__flow/, /__dev/) are skipped so the panel only shows your own traffic.

DevTools imports no feature package. Each one owns its own bridge — it resolves devtools.trace from the container, and does nothing when devtools is not installed — so adding or removing a package changes nothing here.

Configuration

Publish config/devtools.ts when you want to change the defaults:

// config/devtools.ts
import { DevtoolsConfig } from "@zerotal/devtools";

export default DevtoolsConfig({
  capacity: 250,
  redact: { allow: ["email", "slug"] },
});
OptionTypeDefaultPurpose
enabledboolean | nullnullnull follows the dev-surface gate; true/false decides.
gateDevtoolsGate | nullnullWho may read it outside development. See below.
capacitynumber100Traces kept in memory and reloaded on start.
dbPathstring | null.zerotal/devtools.sqliteHistory file. null keeps traces in memory only.
pruneHoursnumber24How long a persisted trace survives.
redactRedactionOptions{ enabled: true }Whether sensitive values are masked. See below.
editorEditorName | null"vscode"Which editor file:line links open.
editorPathMapRecord<string, string>{}Rewrite captured paths for editing on another machine.
captureSourcebooleantrueCapture the call site of each query and log line.
headersstring[][]Extra request headers to record. ["*"] for all.

ZT_DEVTOOLS_DB and ZT_DEVTOOLS_PRUNE_HOURS still set dbPath and pruneHours when no config file is present.

Every location the panel shows is a link that opens it: a query's call site, a log line's, a stack frame. Going from "this query is slow" to the line that ran it is the most frequent move in a debugging session, and without this it is two manual searches.

// config/devtools.ts
export default DevtoolsConfig({
  editor: "cursor", // vscode | vscode-insiders | cursor | windsurf | zed | webstorm
});

Set editor: null to render locations as plain text instead.

Editing on a different machine. The process recording a trace is often not the one with your editor on it — a container reports /app/src/Foo.ts for a file that lives at ~/project/src/Foo.ts. Map it home:

export default DevtoolsConfig({
  editorPathMap: { "/app": "/Users/you/project" },
});

Longest prefix wins, so a specific mapping can sit inside a general one.

What it costs. One stack walk per recorded query and log line, filtered to application frames. Measured at roughly two microseconds, flat from stack depth 5 to 80 — the engine builds the trace lazily, so depth barely registers. A request running forty queries pays about 0.08ms. It is on by default and only ever runs while the inspector itself is running; captureSource: false turns it off.

A query with no application frame above it — one from a seeder, or from inside a package — shows no location rather than pointing at a file you did not write.

Redaction

A trace does not stay on screen: it streams to the browser and is written to .zerotal/devtools.sqlite, where it sits for a day. What it carries is the request's real values — the password on a registration, a reset token, every customer email a listing selects by. So they are masked by default:

SELECT * FROM users WHERE email = ‹redacted› AND id = 42

Masking happens where a value enters the trace, not where the panel draws it. Redacting in a renderer would protect nothing: by then the unredacted copy is already on disk. Four things are covered:

WhatMatched on
Query bindingsthe column each one sets or compares
Channel entrieseach field name, at every depth
Objects passed to console.*each field name, at every depth
Cache keyseach segment of the key

One rule decides all four: a name is sensitive when it contains password, token, secret, session, api_key, … — matched as substrings, so password covers password_hash. id and the timestamp columns are always shown, so a trace stays readable.

Two deliberate choices about the edges. A binding that cannot be attributed to a column is masked — guessing the other way is what writes a password to disk. And a cache key keeps its name and loses only what follows it, so password_reset:9f2c records as password_reset:‹redacted› and the Cache tab stays legible.

Redaction reads names, never contents: a bare string is never inspected for things that look like secrets. console.log(user) is masked field by field; console.log("token is abc123") is recorded as written.

Open individual names back up, close extra ones, or turn it off entirely:

// config/devtools.ts
export default DevtoolsConfig({
  redact: {
    allow: ["email"], // show these in full
    deny: ["nickname"], // mask these too
    // enabled: false,   // mask nothing — only when nothing sensitive is in reach
  },
});

Applying the same rule yourself

The masking functions are exported, so a package contributing its own channel — or anything else that writes to the trace — can hold the line the panel holds, using the app's own allow and deny:

FunctionSignatureUse for
redactValue(value, options?)(value: unknown, o?: RedactionOptions) => unknownAnything with named fields. Walks deeply, replaces cycles, caps depth.
redactCacheKey(key, options?)(key: string, o?: RedactionOptions) => stringA key that welds a name to a value. Keeps the name, masks what follows a sensitive part.
redactBindings(sql, b, o?)(sql: string, bindings: unknown[], o?: RedactionOptions) => unknown[]Query bindings, attributed to their columns via the SQL.
isSensitiveName(name, o?)(name: string, o?: RedactionOptions) => booleanThe predicate itself, when you need to make the decision rather than apply it.

redactValue returns a bare scalar unchanged — there is no name to judge it by — so pass the object, not the field.

All of them run one walk, redactGraph from @zerotal/core/security, which is also what the Inertia recorder uses. Reach for it directly when you are recording values somewhere else and need the same three problems solved — cycles, a depth bound, and values like Date or File that read better flat than walked — but want your own markers:

import { redactGraph } from "@zerotal/core/security";

const safe = redactGraph(payload, {
  sensitive: (key) => /password|token/i.test(key),
  mask: "[hidden]",
  circular: "[cycle]",
  tooDeep: "[deep]",
  maxDepth: 8,
});

Those five fields, plus an optional flatten for values you would rather render than walk, are RedactGraphOptions.

It is a traversal, not a policy: the predicate and the markers are yours, because a debug panel's ‹redacted› is a display choice while an adapter implementing a published protocol has its markers specified for it.

Endpoints

DevtoolsInjectionMiddleware serves these paths directly (it short-circuits the request before it reaches your routes). They exist only when the middleware is registered — which the provider skips entirely in production:

PathMethodDescription
/__zerotal/devtoolsGETStandalone inspector dashboard (opens in new tab)
/__zerotal/devtools/client.jsGETThe injected floating-panel bundle
/__zerotal/devtools/dashboard.jsGETThe same panel, mounted full-window
/__zerotal/devtools/sseGETServer-sent events stream — EventSource endpoint
/__zerotal/devtools/api/tracesGETRecent request traces (JSON)
/__zerotal/devtools/api/channelsGETDeclared trace channels (JSON)
/__zerotal/devtools/api/mapGETThe framework map — routes, config, container, …
/__zerotal/devtools/api/clearPOSTClear all stored traces

Danger — These endpoints expose request headers, SQL with its bindings, session key names, stack traces, and rendered mail. They are all behind one gate: a development process always passes, and anywhere else the absence of a gate is a refusal. See Running it outside development.

Reading traces programmatically

The same in-memory store that feeds the panel is reachable as traceStore(), so you can read traces or react to new ones for custom metrics:

// in a script or provider
import { traceStore } from "@zerotal/devtools";

// All traces stored in memory (up to `capacity`, most recent first)
const traces = traceStore().all();

// Find slow requests
const slow = traces.filter((t) => t.durationMs > 500);

// Find requests with N+1 warnings
const nplus = traces.filter((t) => t.warnings.length > 0);

// Find requests that threw
const failed = traces.filter((t) => t.exception !== null);

// Subscribe to new traces (e.g. for custom metrics)
const unsub = traceStore().subscribe((trace) => {
  if (trace === null) return; // 'clear' event
  console.log(
    `[trace] ${trace.method} ${trace.path} → ${trace.statusCode} (${trace.durationMs}ms)`,
  );
});

// Unsubscribe when done
unsub();

// Clear all stored traces (memory + SQLite)
traceStore().clear();

Traces are persisted to .zerotal/devtools.sqlite and loaded on restart. Two environment variables tune persistence:

# .env
ZT_DEVTOOLS_DB=.data/devtools.sqlite   # default: .zerotal/devtools.sqlite
ZT_DEVTOOLS_PRUNE_HOURS=48             # default: 24

Note — If bun:sqlite is unavailable, the store degrades silently to memory-only: traces still appear in the panel but are not persisted across restarts.

Running it outside development

By default the inspector follows the same gate as the stack-trace error page: on under zt dev, off in a deployed process, with nothing to configure. That is the right default and it was, until recently, the only option — so the way people ran the inspector on a shared staging box was to lie about APP_ENV.

There is now a supported way:

// config/devtools.ts
export default DevtoolsConfig({
  enabled: true, // explicit; `null` follows the dev-surface gate
  gate: (request) => request.headers.get("X-Debug-Key") === Bun.env["DEBUG_KEY"],
});

Four rules, and they are the point of it:

  • A development process always passes. A gate that can lock you out of your own laptop is a gate that gets switched off, and then nothing is gated.
  • Anywhere else, no gate is a refusal. An app that turned the inspector on outside development without saying who may read it has not made a decision this code should make for it. A gate that throws is also a refusal — failing open there would turn a typo in an authorization check into an open inspector.
  • One gate answers for everything. The stream, the trace JSON, the dashboard, and the panel bundle expose the same request data. Two gates that can disagree is how a dev-only surface ends up serving request headers in production.
  • Refusals are 404, not 403. Outside development the honest answer to an unauthenticated stranger is that there is nothing here.

Danger — Traces contain request headers, SQL with its bindings, session key names, stack traces, and rendered mail. Gate accordingly, and prefer enabled: false to a weak gate.

Auto-injection of the panel script is development-only. On a gated environment the tag would go into every visitor's HTML and then 404 in their console, so there the way in is the dashboard at /__zerotal/devtools.

To switch it off entirely without removing the provider:

// config/devtools.ts
export default DevtoolsConfig({ enabled: false });

Or omit the provider:

// bootstrap/providers.ts
import type { ServiceProvider } from "zerotal";
import { DatabaseProvider } from "@zerotal/orm";
import { DevtoolsProvider } from "@zerotal/devtools";

const providers: ServiceProvider[] = [DatabaseProvider];
if (Bun.env.APP_ENV !== "production") providers.push(DevtoolsProvider);

export default providers;

Testing

Set your suite up once as described in Testing. Devtools is development-only tooling, so the tests worth writing are about it staying out of the way — not about the panel itself.

Assert it never reaches production. This is the one that matters: the injection middleware appends a script tag to every HTML response, and a misconfigured deploy that ships it exposes request traces to your users:

// tests/devtools/injection.test.ts
import { test } from "bun:test";
import { createApp } from "../helpers.ts";

test("the devtools script is not injected outside development", async () => {
  const app = await createApp(); // helpers boot with env: "test"

  const res = await app.get("/");

  res.assertDontSee("__devtools");
  await app.close();
});

Assert it does not touch non-HTML responses. A middleware that appends markup to a JSON body or a file download corrupts it, and the failure shows up as a parse error somewhere unrelated:

// tests/devtools/injection.test.ts
test("JSON responses are left alone", async () => {
  const res = await app.get("/api/posts", { Accept: "application/json" });

  res.assertHeader("Content-Type", "application/json");
  res.assertDontSee("<script");
});

The trace store is an ordinary object, so a panel plugin you write tests without a browser:

// tests/devtools/plugin.test.ts
import { traceStore } from "@zerotal/devtools";

traceStore().clear();
await app.get("/posts");

expect(traceStore().all()).not.toHaveLength(0);

Note — If your suite boots with env: "test" (as the scaffolded tests/helpers.ts does), devtools is inactive and these assertions pass trivially. That is the point — they fail only when someone widens the environment check, which is exactly when you want to hear about it.

References

TraceStore

The in-memory ring of recent traces that backs the panel, reached through traceStore().

It is a function rather than an exported instance because the store opens a SQLite file: constructing it at module scope meant importing this package wrote a database into the working directory of every process that did so, production included. traceStore() builds it on first call, and DevtoolsProvider installs one configured from your config/devtools.ts.

MethodSignatureDescription
all()all(): RequestTrace[]Return all stored traces, most recent first.
push(trace)push(trace: RequestTrace): voidAdd a trace, persist it, and notify subscribers. Called internally by the tracer.
clear()clear(): voidEmpty the in-memory store and delete all rows from the SQLite DB.
subscribe(fn)subscribe(fn: (t: RequestTrace | null) => void): () => voidRegister a callback for every new trace; returns an unsubscribe fn. fn gets null on clear.
dispose()dispose(): voidFlush pending writes, stop the timers, and close the database.
capacityreadonly capacity: numberHow many traces this store keeps. Sent to the panel so it trims to the same depth.

TraceSink

Bound in the container as devtools.trace. Resolve it with tryMake and guard the result — it is absent when devtools is not installed or the app is in production.

MethodSignatureDescription
channel(descriptor)(d: TraceChannelDescriptor) => voidDeclare a channel so its entries get a tab.
record(ctx, channel, entry)(ctx: object, channel: string, entry: object) => voidRecord one entry; offsetMs is stamped for you.
bufferQuery / bufferWarning / bufferMail / bufferCache / bufferJob(ctx, entry) => voidThe five signals with bespoke panels.

TraceChannelDescriptor

// from @zerotal/devtools
interface TraceChannelDescriptor {
  id: string; // unique — also the key under RequestTrace.channels
  label: string; // tab label
  badge?: string; // entry field shown as the row's leading chip
  title?: string; // entry field shown as the row's main text
  meta?: string[]; // entry fields shown as dim metadata
  warn?: string; // entry field whose truthiness marks the row
  order?: number; // position among channel tabs (default 100)

  // Presentation — see below
  render?: "rows" | "tree" | "table" | "kv" | "grouped";
  treeField?: string;
  treeBadge?: string;
  groupBy?: string;
  flags?: string[];
  traceGroup?: string;
}

Choosing a presentation

A flat list of rows is the right shape for an audit feed and the wrong one for a prop map or a route table. render picks a different one — still declared as data, so the panel ships no code for your package either way.

renderShapeAlso reads
"rows"One block per entry: badge, title, meta. The default.
"tree"A map of dotted paths, drawn as branches and leaves.treeField, treeBadge
"table"One row per entry, meta as columns. For many entries.
"kv"Every field of every entry. For few entries, many fields.
"grouped"Rows collected under a shared value.groupBy

Two hints apply to any presentation:

  • flags — fields rendered as a bare chip when truthy. A flag is named by its field, so { shared: true } reads as shared rather than shared: true, which is how a row ends up saying nothing at a glance. Under "tree" they apply per node.
  • traceGroup — the field whose value correlates whole traces on the All tab. One user action can be several requests; traces sharing a value here fold into one expandable entry under the oldest of them, instead of scattering down the list and pushing what you were reading off the top.

"tree" takes a flat map of dotted paths, not a nested object — { "user.name": {…}, "user.email": {…} } becomes one user branch with two leaves. Each node's own fields become its treeBadge chip, its flags, and a dim attribute line, so you describe what a node is without the panel knowing what any of it means:

trace.channel({
  id: "widgets",
  label: "Widgets",
  render: "tree",
  treeField: "nodes", // the entry field holding the path map
  treeBadge: "kind", // each node's leading chip
  flags: ["cached", "stale"], // each node's boolean chips
});

trace.record(ctx, "widgets", {
  nodes: {
    "sidebar.filters": { kind: "list", cached: true },
    "sidebar.tags": { kind: "list", source: "api" },
  },
});

Badge chips are accented by hashing their own text, so repeated values keep a consistent colour and stay tellable apart without the panel holding a list of every value any package might use.

RequestTrace

The shape pushed to the store and streamed to the panel:

// from @zerotal/devtools
interface RequestTrace {
  id: string;
  requestId: string;
  method: string;
  path: string;
  statusCode: number;
  startMs: number;
  durationMs: number;
  queries: QuerySpan[];
  warnings: NPlusOneWarning[];
  memory: number; // heap in use as the request finished, in bytes
  queryParams: Record<string, string>;
  headers: Record<string, string>; // allowlisted; never auth/cookie
  responseHeaders: Record<string, string>;
  session: string[]; // key names only, never values
  route: RouteInfo | null;
  auth: AuthInfo | null;
  /** The error that ended the request, or null when it completed normally. */
  exception: ExceptionInfo | null;
  logs: LogEntry[];
  mail: MailEntry[];
  cache: CacheEntry[];
  jobs: JobEntry[];
  /** Entries recorded on open channels, keyed by channel id. */
  channels: Record<string, TraceChannelEntry[]>;
}

interface QuerySpan {
  sql: string;
  bindings: unknown[]; // masked unless the column is allow-listed
  startMs: number;
  durationMs: number;
  rowCount: number;
  source?: SourceLocation; // the app line that ran it, when one was found
}

interface NPlusOneWarning {
  sql: string;
  count: number;
}

interface RouteInfo {
  pattern: string;
  controller: string;
  action: string;
}

interface AuthInfo {
  id: unknown;
  name?: unknown;
  email?: unknown;
}

interface LogEntry {
  level: "log" | "debug" | "info" | "warn" | "error";
  args: string[];
  offsetMs: number;
  source?: SourceLocation;
}

interface ExceptionInfo {
  message: string;
  status: number; // the status the rendered error response used
  type?: string; // the error's class name
  frames?: SourceLocation[]; // innermost first, framework frames kept
}

interface SourceLocation {
  file: string;
  line: number;
  column?: number;
  function?: string;
}

interface MailEntry {
  className: string;
  to: string[];
  subject: string;
  html: string;
  durationMs: number;
  queued: boolean;
  offsetMs: number;
}

interface CacheEntry {
  op: "has" | "hit" | "miss" | "write" | "forget" | "flush";
  key: string;
  ttl?: number;
  durationMs: number;
  offsetMs: number;
}

interface JobEntry {
  className: string;
  queue: string;
  status: "dispatched" | "completed" | "failed";
  durationMs: number;
  error?: string;
  offsetMs: number;
}

Contributing a tab

The panel is a unified dev tool: any package can add its own tab, and there are two ways in depending on where the data lives.

  • The data is per-request, and the server has it — a query, a dispatch, an authorization decision. Declare a channel and record against the request context. DevTools renders the rows for you, and they show up in the Timeline waterfall too. This is the usual case.
  • The data only exists in the browser — a client-side store, a WebSocket frame log. Register a panel plugin and render it yourself. This is how @zerotal/flow contributes its time-travel Timeline.

Channels — server-side data

Resolve devtools.trace from the container, declare how your entries should read, then record one per event. Guard the lookup: it is absent when devtools is not installed or the app is in production, and your package must not care.

// your-package/src/observability.ts
import { FrameworkEvents, RequestContext } from "zerotal";
import type { Application } from "zerotal";

interface DevtoolsSink {
  channel(descriptor: {
    id: string;
    label: string;
    badge?: string;
    title?: string;
    meta?: string[];
    warn?: string;
    order?: number;
  }): void;
  record(ctx: object, channel: string, entry: Record<string, unknown>): void;
}

export function installWidgetObservability(app: Application): () => void {
  const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
  if (!trace) return () => {};

  trace.channel({
    id: "widgets",
    label: "Widgets",
    badge: "op", // leading chip on each row
    title: "name", // the row's main text
    meta: ["durationMs"], // dim metadata beneath it
    warn: "failed", // a truthy value here marks the row and the tab badge
    order: 40, // position among channel tabs
  });

  return FrameworkEvents.on(WidgetRendered, (e) => {
    const ctx = RequestContext.tryGet();
    if (ctx) trace.record(ctx, "widgets", { op: "render", name: e.name, durationMs: e.ms });
  });
}

The descriptor crosses the wire to the browser, so it names fields rather than carrying formatter functions — that is what lets DevTools render a tab for a package it has never heard of. offsetMs is stamped for you, and an entry with a durationMs gets a bar in the Timeline.

Declaring is idempotent and order-independent: re-declaring an id replaces it, and entries recorded before a channel is declared still appear once it is.

Panel plugins — browser-side data

From your package's browser code, register a panel on the global registry the panel exposes:

window.__zerotalDevtools?.register({
  id: "my-panel", // unique — the tab is addressed internally as `plugin:my-panel`
  title: "My Panel", // tab label
  badge: () => items.length || undefined, // optional badge (falsy hides it)
  render: (el) => {
    el.innerHTML = `<p class="empty">Nothing yet</p>`; // render into the shared content area
  },
});

// Push a live update — refresh the badge, and re-render if the tab is open:
window.__zerotalDevtools?.refresh("my-panel");

Notes:

  • Order-independent. The registry is created by whichever runs first (the panel or an extension), so you can register before or after the panel mounts — a late registration (e.g. after a WebSocket connects) adds the tab live.
  • Optional-peer friendly. Guard with ?. — if @zerotal/devtools isn't on the page, window.__zerotalDevtools is undefined and your register call is simply skipped (fall back to your own UI if you have one).
  • Themed for free. render(el) writes into the panel's Shadow DOM content area, so the devtools CSS classes (empty, dim, sec, stitle, qrow, ibtn, …) and CSS variables (--purple, --muted, --card, …) are available — your tab matches the panel without shipping styles.
  • Event handling. el (the content area) is persistent across renders; set el.onclick with a delegated handler (assignment replaces, so it won't stack).

The DevtoolsPanelPlugin type is exported from @zerotal/devtools for TypeScript consumers.

Testing what you contribute

The panel is markup, and markup is awkward to assert on. Everything in it that is logic is exported from @zerotal/devtools/client, so you can check how your channel's rows will filter, fold, and nest without a browser:

ExportAnswers
matchesFilter(trace, query)Would this trace survive the filter box?
matchesFacets(trace, facets)Would it survive the facet chips?
traceMatches(trace, query, f)Both at once — what the All tab actually asks.
methodsPresent(traces)Which method chips are worth offering.
noFacets() / facetsActive(f)An empty Facets set, and whether one narrows anything.
SLOW_MSWhere the slow chip draws its line, so a test can agree.
buildPathTree(paths)What tree does my "tree" channel's dotted path map become?
traceGroupKey(trace, channels)Which channel field correlates this trace, if any?
foldTraceRows(matches, ch, open)The rows the All tab draws, with correlated requests folded.

PathTreeNode is { children: Map<string, PathTreeNode>; attrs: Record<string, unknown> | null }. A branch that nothing was recorded against has attrs: null, which is meaningfully different from {} — a node can be both a branch and a leaf.

foldTraceRows returns a flat TraceRow[], each row carrying its index into the unfiltered list plus whether it heads a group (groupKey, groupSize) or is a folded follow-up (child).

Next steps

  • Logger — structured logging that surfaces in the Logs tab.
  • Query builder — the queries DevTools traces and flags for N+1.
  • Events — the FrameworkEvents that DevTools subscribes to.
  • Telemetry — production-grade metrics once you move past the dev panel.
  • Testing — assert on requests without the floating panel.