@zerotal/arch 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,1013 @@
1
+ ---
2
+ title: DevTools
3
+ description: See per-request SQL, logs, mail, cache, and jobs in a live in-browser debug panel during development.
4
+ ---
5
+
6
+ # DevTools
7
+
8
+ `@zerotal/devtools` records a trace of every request — SQL queries, N+1
9
+ warnings, console logs, mail previews, cache operations, queued jobs, and
10
+ whatever else your installed packages contribute — and streams them to a floating
11
+ panel in your browser. No browser extension needed: in development the panel is
12
+ injected for you, and it connects over Server-Sent Events.
13
+
14
+ `DevtoolsProvider` is gated to the `web` environment and is a no-op when
15
+ `APP_ENV=production` or `APP_ENV=prod`.
16
+
17
+ ## Getting Started
18
+
19
+ ```bash
20
+ # in your project root
21
+ bun add @zerotal/devtools
22
+ ```
23
+
24
+ ## Register the provider
25
+
26
+ Add `DevtoolsProvider` to the providers array in `bootstrap/providers.ts`:
27
+
28
+ ```typescript
29
+ // bootstrap/providers.ts
30
+ import { DatabaseProvider } from "@zerotal/orm";
31
+ import { DevtoolsProvider } from "@zerotal/devtools";
32
+
33
+ const providers = [
34
+ // …your other providers
35
+ DatabaseProvider,
36
+ DevtoolsProvider,
37
+ ];
38
+
39
+ export default providers;
40
+ ```
41
+
42
+ The provider declares `static environments = ["web"]`, so it only activates for
43
+ web requests. Within that, registering it switches on the following hooks (in
44
+ lifecycle order, all of them additionally short-circuited in production):
45
+
46
+ - `onBooting` — builds the trace store from your [config](#configuration), binds
47
+ `devtools.trace` so other packages can contribute, and registers
48
+ `DevtoolsInjectionMiddleware` via `app.useOnce()`, so you never add it to
49
+ `.use([…])` manually. The middleware serves the panel's API endpoints (see
50
+ [Endpoints](#endpoints)).
51
+ - `onBooted` — subscribes to [`FrameworkEvents`](/docs/events) for tracing,
52
+ patches `console.*` to capture logs per request, opens the SSE bridge, then
53
+ prints the inspector banner.
54
+ - `onStopping` — unsubscribes, restores the original `console.*` methods, drops
55
+ the declared channels, and closes the trace store.
56
+
57
+ > **Note** — Nothing happens at import time. The trace store opens its database
58
+ > on first use rather than in a constructor, so importing this package in an app
59
+ > that runs in production writes nothing and starts no timer. N+1 detection
60
+ > itself is owned by the [ORM](/docs/orm/index) provider (and env-gated there);
61
+ > DevTools only consumes the `NPlusOneDetected` event and surfaces it.
62
+
63
+ ## Start the client panel
64
+
65
+ In your frontend entry (`resources/js/app.js`), import and start the client:
66
+
67
+ ```typescript
68
+ // resources/js/app.js
69
+ import { DevTools } from "@zerotal/devtools/client";
70
+
71
+ DevTools.start();
72
+ ```
73
+
74
+ This connects to the SSE stream and mounts the floating panel at the bottom of
75
+ the page. Press `Alt+D` (or `Cmd+D` on Mac) to toggle it open.
76
+
77
+ Drag the strip along the panel's top edge to resize it; the height is kept. The
78
+ button cycling `◐ ● ○` in the bar switches the theme between following your
79
+ system, dark, and light — for when the panel and the page you are debugging
80
+ disagree.
81
+
82
+ ### Keyboard
83
+
84
+ The panel's shortcuts fire **only while the panel has focus** — click it once.
85
+ It is an overlay on your application, and binding `j` globally would navigate the
86
+ trace list every time you typed into one of your own forms. `Alt+D` is the
87
+ exception, because it is how you reach a panel that does not have focus yet.
88
+
89
+ | Key | Does |
90
+ | --------- | ------------------------------------ |
91
+ | `Alt+D` | Toggle the panel (works anywhere) |
92
+ | `j` / `↓` | Select the next request |
93
+ | `k` / `↑` | Select the previous request |
94
+ | `1`–`9` | Jump to the nth tab |
95
+ | `/` | Open **All** and focus the filter |
96
+ | `Esc` | Leave the filter, or close the panel |
97
+
98
+ `j` and `k` step through the _filtered_ list, so narrowing first and stepping
99
+ after works the way you would expect.
100
+
101
+ `DevTools.start()` accepts three optional fields:
102
+
103
+ ```typescript
104
+ // resources/js/app.js
105
+ DevTools.start({ endpoint: "/__zerotal/devtools", mode: "floating" }); // defaults
106
+ ```
107
+
108
+ | Field | Required | Default | Description |
109
+ | ---------- | -------- | ----------------------- | --------------------------------------------------------------------- |
110
+ | `endpoint` | no | `"/__zerotal/devtools"` | Base path the client uses for the SSE stream and API routes. |
111
+ | `mode` | no | `"floating"` | `"floating"` pins a collapsible bar; `"standalone"` fills the window. |
112
+ | `mount` | no | `document.body` | Element to mount into. |
113
+
114
+ `"standalone"` is what the inspector dashboard at `/__zerotal/devtools` uses —
115
+ same renderers, same tabs, mounted full-window instead of docked.
116
+
117
+ ## The panel
118
+
119
+ Two sections, switched in the tab strip.
120
+
121
+ **Requests** is the trace stream — what the app just did. Nine built-in tabs, each
122
+ focused on a different concern for the current request, plus a tab for every
123
+ [channel](#contributing-a-tab) an installed package declares. New traces stream in
124
+ live; click any row in the **All** tab to pin an older request and inspect it
125
+ across every tab.
126
+
127
+ **[App](#the-app-section)** is the framework map — what the app _is_: its routes,
128
+ config, container, providers, events, and the commands and scheduled tasks that
129
+ have run. Two sections rather than fifteen tabs in one scrolling strip, because
130
+ they answer different questions.
131
+
132
+ The floating panel and the standalone dashboard at `/__zerotal/devtools` are the
133
+ same panel — the dashboard is it mounted full-window. A tab added by any package
134
+ therefore appears in both.
135
+
136
+ ### Queries tab
137
+
138
+ The default tab — shows an overview of the request followed by every SQL query
139
+ executed:
140
+
141
+ - Route pattern + `Controller@action`
142
+ - **The error**, when the request threw — message and status, above everything
143
+ else, because on a failed request that is the answer
144
+ - Duration, query count, total DB time, heap memory, and the authenticated user
145
+ - **N+1 warnings** — flagged when the same query shape repeats during a request,
146
+ each with the eager-load that removes it and the call that suppresses it
147
+ - Each query: SQL, bindings, duration bar, row count, and **the line of your code
148
+ that ran it** — see [Editor links](#editor-links)
149
+
150
+ ### Timeline tab
151
+
152
+ Everything the request did, on one waterfall: queries, cache operations, mail,
153
+ jobs, log lines, and channel entries, each placed by its offset from the request
154
+ start and sized by its duration. Every entry already carried that offset — the
155
+ waterfall is what makes "what was waiting on what" legible instead of a column of
156
+ numbers you have to order in your head.
157
+
158
+ Above it, **what the browser measured** for this page load: time to first byte,
159
+ parse, load, and first contentful paint. Server duration reported as though it
160
+ were the user's experience is a panel's most misleading number — a 12ms response
161
+ the browser then spends 900ms painting is a slow page. Kept visibly separate from
162
+ the waterfall, because these describe the page and not this request.
163
+
164
+ ### Logs tab
165
+
166
+ Every `console.log`, `.debug`, `.info`, `.warn`, and `.error` call captured
167
+ during the request, with an offset timestamp and level colour-coding.
168
+
169
+ ### Request tab
170
+
171
+ Both halves of the exchange: the status line, query string parameters, request
172
+ headers, response headers, and the **names** of the keys in the session.
173
+
174
+ Headers are an allowlist rather than a denylist, because a trace is persisted —
175
+ a header nobody thought to deny is a header on disk for a day. `devtools.headers`
176
+ opens up the ones you are actually debugging; `cookie` and `authorization` are
177
+ never recorded whatever you ask for, because they _are_ the request.
178
+
179
+ Session **keys only**, never values. "Is the CSRF token there, did the flash
180
+ survive the redirect, is the user id set" are all answered by the keys, and the
181
+ values are this request's real state.
182
+
183
+ ### Exception tab
184
+
185
+ For a request that threw: the error's type, its message, and the full stack with
186
+ every frame a [link into your editor](#editor-links).
187
+
188
+ Framework frames are kept and dimmed rather than dropped. You read a stack trace
189
+ to find out how you got somewhere, and a trace with the middle removed does not
190
+ tell you that — but in a forty-frame trace the six you wrote should be the ones
191
+ that stand out.
192
+
193
+ ### Mail tab
194
+
195
+ Every mail notification sent or queued during the request — notification class
196
+ name, recipients, subject, send time, and queued/sent status. Each one carries a
197
+ **Preview**, collapsed by default, that renders the actual email in a fully
198
+ sandboxed frame: no scripts, no same-origin access, no navigation. That is not
199
+ optional hardening — the panel lives on your app's own origin, so rendering a
200
+ template's markup inline would make any user input inside a mail a self-XSS.
201
+
202
+ ### Cache tab
203
+
204
+ Every cache operation performed during the request — `has`, `hit`, `miss`,
205
+ `write`, `forget`, and `flush` — with the key, operation type, TTL, and offset
206
+ timestamp.
207
+
208
+ ### Jobs tab
209
+
210
+ Every job dispatched (or processed synchronously) during the request — class
211
+ name, queue, status (`dispatched` / `completed` / `failed`), duration, and any
212
+ error message.
213
+
214
+ ### Channel tabs
215
+
216
+ One tab per channel an installed package declares — **Inertia** from
217
+ [`@zerotal/inertia`](/docs/inertia/devtools), **Auth** from
218
+ [`@zerotal/auth`](/docs/authentication), **Flow** from
219
+ [`@zerotal/flow`](/docs/flow), and any your own packages add. Each is rendered
220
+ from the channel's own descriptor, in whichever
221
+ [presentation](#choosing-a-presentation) it asked for, so the tab exists without
222
+ DevTools shipping code for it. See [Contributing a tab](#contributing-a-tab).
223
+
224
+ ### All tab
225
+
226
+ The full request history for the current session, with a filter box and a row of
227
+ facet chips. Click any row to pin that request's trace in all other tabs. A
228
+ request that threw is marked in red and carries its error message inline, so you
229
+ can find the one that broke without opening each in turn.
230
+
231
+ **Text** narrows rather than widens: `posts 500` finds failing requests to
232
+ `/posts`, matching method, path, status code, and the matched route's pattern,
233
+ controller, and action.
234
+
235
+ **Facets** compose with it and with each other. Method chips list only the verbs
236
+ actually recorded — an app that only ever GETs gets one chip, not five. Then
237
+ `2xx`/`3xx`/`4xx`/`5xx`, and three toggles:
238
+
239
+ | Chip | Keeps |
240
+ | -------- | --------------------------------------------------------------------- |
241
+ | `errors` | Requests that threw, plus any `4xx` or `5xx` — a rendered 404 counts. |
242
+ | `slow` | Over 300ms, the same line the duration colouring already draws. |
243
+ | `n+1` | Requests carrying an N+1 warning. |
244
+
245
+ Picking two chips in one row means either; picking chips in two rows means both.
246
+ `POST` with `5xx` is failing writes, not writes-or-failures.
247
+
248
+ Above 200 rows the list renders only what the viewport can reach, so a large
249
+ `capacity` is a list you can scroll rather than thousands of nodes.
250
+
251
+ Requests a channel says belong together are folded into one entry under the
252
+ oldest of them, with a `+N` toggle to open the rest. One thing you did is often
253
+ several requests — a page visit and the deferred props that arrive after it —
254
+ and listing them as unrelated siblings is how the request you are reading gets
255
+ pushed off the top. Which requests correlate is the channel's to declare; see
256
+ [`traceGroup`](#choosing-a-presentation).
257
+
258
+ The panel remembers where you were — whether it was open, which tab you were on,
259
+ and what you had filtered to — across a reload. On a page you are reloading
260
+ _because_ you are debugging it, that is the wrong moment to lose your place.
261
+
262
+ ## The App section
263
+
264
+ Everything above reads the trace stream. These six read the framework's own
265
+ registries — which existed all along and were CLI-only or invisible, so "is that
266
+ route even registered", "who bound `cache`", and "does anything actually listen
267
+ to `OrderPlaced`" were questions you answered by reading source.
268
+
269
+ | Tab | Shows |
270
+ | ------------- | -------------------------------------------------------------------------------- |
271
+ | **Routes** | Method, path, name, handler, middleware. GETs are clickable. |
272
+ | **Config** | The resolved tree, flattened to dotted paths, secrets masked. |
273
+ | **Container** | Every binding, its kind, and which provider bound it. |
274
+ | **Providers** | Boot order — which decides who wins a contested binding — and per-provider cost. |
275
+ | **Events** | Application listeners and framework subscribers, in one list. |
276
+ | **Commands** | Console commands and scheduled tasks, with outcome and duration. |
277
+
278
+ All six share one read of one map, taken when you first open the section and
279
+ cached after — six requests for it would be six answers that can disagree. The
280
+ `↻` button re-reads it, for the case where a provider registered a route late.
281
+
282
+ > **Note** — The Config tab masks a bare `key` as well as everything the
283
+ > [redaction rules](#redaction) already cover, because `app.key` is your
284
+ > application's encryption key. `dsn` too. Config is the one place secrets are
285
+ > supposed to live, so it gets the benefit of the doubt in the other direction.
286
+
287
+ **Commands** is the answer to the thing the rest of the panel structurally cannot
288
+ show: a scheduled task that fails at 03:00 has no request to hang off, so until
289
+ now it left no trace in the tool whose job is to show you what your app did. The
290
+ feed keeps the last 200 entries for the life of the process.
291
+
292
+ ## How traces are captured
293
+
294
+ DevTools never polls or wraps your code. On boot it subscribes to
295
+ [`FrameworkEvents`](/docs/events) and buffers each event against the active
296
+ request context until the request finishes:
297
+
298
+ ```text
299
+ QueryExecuted ────┐
300
+ NPlusOneDetected ─┤
301
+ MessageSent ──────┤ per-request buffer (WeakMap keyed by HttpContext)
302
+ CacheQueried ─────┤ │
303
+ JobRan ───────────┤ │ RequestHandled / RequestFailed
304
+ channel entries ──┤ │
305
+ console.* ────────┘ ▼
306
+ RequestTrace → traceStore().push()
307
+
308
+ ▼ SSE
309
+ browser panel
310
+ ```
311
+
312
+ Buffers are keyed by the `HttpContext` in a `WeakMap`, so they are garbage
313
+ collected with the request and capture events even from phases that run before
314
+ the middleware (such as auth loading the user). Internal framework paths
315
+ (`/__zerotal/`, `/__flow/`, `/__dev/`) are skipped so the panel only shows your
316
+ own traffic.
317
+
318
+ DevTools imports no feature package. Each one owns its own bridge — it resolves
319
+ `devtools.trace` from the container, and does nothing when devtools is not
320
+ installed — so adding or removing a package changes nothing here.
321
+
322
+ ## Configuration
323
+
324
+ Publish `config/devtools.ts` when you want to change the defaults:
325
+
326
+ ```typescript
327
+ // config/devtools.ts
328
+ import { DevtoolsConfig } from "@zerotal/devtools";
329
+
330
+ export default DevtoolsConfig({
331
+ capacity: 250,
332
+ redact: { allow: ["email", "slug"] },
333
+ });
334
+ ```
335
+
336
+ | Option | Type | Default | Purpose |
337
+ | --------------- | ------------------------ | -------------------------- | ------------------------------------------------------------ |
338
+ | `enabled` | `boolean \| null` | `null` | `null` follows the dev-surface gate; `true`/`false` decides. |
339
+ | `gate` | `DevtoolsGate \| null` | `null` | Who may read it outside development. See below. |
340
+ | `capacity` | `number` | `100` | Traces kept in memory and reloaded on start. |
341
+ | `dbPath` | `string \| null` | `.zerotal/devtools.sqlite` | History file. `null` keeps traces in memory only. |
342
+ | `pruneHours` | `number` | `24` | How long a persisted trace survives. |
343
+ | `redact` | `RedactionOptions` | `{ enabled: true }` | Whether sensitive values are masked. See below. |
344
+ | `editor` | `EditorName \| null` | `"vscode"` | Which editor `file:line` links open. |
345
+ | `editorPathMap` | `Record<string, string>` | `{}` | Rewrite captured paths for editing on another machine. |
346
+ | `captureSource` | `boolean` | `true` | Capture the call site of each query and log line. |
347
+ | `headers` | `string[]` | `[]` | Extra request headers to record. `["*"]` for all. |
348
+
349
+ `ZT_DEVTOOLS_DB` and `ZT_DEVTOOLS_PRUNE_HOURS` still set `dbPath` and
350
+ `pruneHours` when no config file is present.
351
+
352
+ ### Editor links
353
+
354
+ Every location the panel shows is a link that opens it: a query's call site, a
355
+ log line's, a stack frame. Going from "this query is slow" to the line that ran
356
+ it is the most frequent move in a debugging session, and without this it is two
357
+ manual searches.
358
+
359
+ ```typescript
360
+ // config/devtools.ts
361
+ export default DevtoolsConfig({
362
+ editor: "cursor", // vscode | vscode-insiders | cursor | windsurf | zed | webstorm
363
+ });
364
+ ```
365
+
366
+ Set `editor: null` to render locations as plain text instead.
367
+
368
+ **Editing on a different machine.** The process recording a trace is often not
369
+ the one with your editor on it — a container reports `/app/src/Foo.ts` for a file
370
+ that lives at `~/project/src/Foo.ts`. Map it home:
371
+
372
+ ```typescript
373
+ export default DevtoolsConfig({
374
+ editorPathMap: { "/app": "/Users/you/project" },
375
+ });
376
+ ```
377
+
378
+ Longest prefix wins, so a specific mapping can sit inside a general one.
379
+
380
+ **What it costs.** One stack walk per recorded query and log line, filtered to
381
+ application frames. Measured at roughly **two microseconds, flat from stack depth
382
+ 5 to 80** — the engine builds the trace lazily, so depth barely registers. A
383
+ request running forty queries pays about 0.08ms. It is on by default and only
384
+ ever runs while the inspector itself is running; `captureSource: false` turns it
385
+ off.
386
+
387
+ A query with no application frame above it — one from a seeder, or from inside a
388
+ package — shows no location rather than pointing at a file you did not write.
389
+
390
+ ### Redaction
391
+
392
+ A trace does not stay on screen: it streams to the browser **and** is written to
393
+ `.zerotal/devtools.sqlite`, where it sits for a day. What it carries is the
394
+ request's real values — the password on a registration, a reset token, every
395
+ customer email a listing selects by. So they are masked by default:
396
+
397
+ ```text
398
+ SELECT * FROM users WHERE email = ‹redacted› AND id = 42
399
+ ```
400
+
401
+ Masking happens where a value enters the trace, not where the panel draws it.
402
+ Redacting in a renderer would protect nothing: by then the unredacted copy is
403
+ already on disk. Four things are covered:
404
+
405
+ | What | Matched on |
406
+ | --------------------------------- | ------------------------------------ |
407
+ | **Query bindings** | the column each one sets or compares |
408
+ | **Channel entries** | each field name, at every depth |
409
+ | **Objects passed to `console.*`** | each field name, at every depth |
410
+ | **Cache keys** | each segment of the key |
411
+
412
+ One rule decides all four: a name is sensitive when it contains `password`,
413
+ `token`, `secret`, `session`, `api_key`, … — matched as substrings, so
414
+ `password` covers `password_hash`. `id` and the timestamp columns are always
415
+ shown, so a trace stays readable.
416
+
417
+ Two deliberate choices about the edges. A binding that cannot be attributed to a
418
+ column is masked — guessing the other way is what writes a password to disk. And
419
+ a cache key keeps its name and loses only what follows it, so `password_reset:9f2c`
420
+ records as `password_reset:‹redacted›` and the Cache tab stays legible.
421
+
422
+ Redaction reads _names_, never contents: a bare string is never inspected for
423
+ things that look like secrets. `console.log(user)` is masked field by field;
424
+ `console.log("token is abc123")` is recorded as written.
425
+
426
+ Open individual names back up, close extra ones, or turn it off entirely:
427
+
428
+ ```typescript
429
+ // config/devtools.ts
430
+ export default DevtoolsConfig({
431
+ redact: {
432
+ allow: ["email"], // show these in full
433
+ deny: ["nickname"], // mask these too
434
+ // enabled: false, // mask nothing — only when nothing sensitive is in reach
435
+ },
436
+ });
437
+ ```
438
+
439
+ #### Applying the same rule yourself
440
+
441
+ The masking functions are exported, so a package contributing its own
442
+ [channel](#contributing-a-tab) — or anything else that writes to the trace — can
443
+ hold the line the panel holds, using the app's own `allow` and `deny`:
444
+
445
+ | Function | Signature | Use for |
446
+ | ------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
447
+ | `redactValue(value, options?)` | `(value: unknown, o?: RedactionOptions) => unknown` | Anything with named fields. Walks deeply, replaces cycles, caps depth. |
448
+ | `redactCacheKey(key, options?)` | `(key: string, o?: RedactionOptions) => string` | A key that welds a name to a value. Keeps the name, masks what follows a sensitive part. |
449
+ | `redactBindings(sql, b, o?)` | `(sql: string, bindings: unknown[], o?: RedactionOptions) => unknown[]` | Query bindings, attributed to their columns via the SQL. |
450
+ | `isSensitiveName(name, o?)` | `(name: string, o?: RedactionOptions) => boolean` | The predicate itself, when you need to make the decision rather than apply it. |
451
+
452
+ `redactValue` returns a bare scalar unchanged — there is no name to judge it by —
453
+ so pass the object, not the field.
454
+
455
+ All of them run one walk, `redactGraph` from `@zerotal/core/security`, which is
456
+ also what the [Inertia recorder](/docs/inertia/devtools) uses. Reach for it
457
+ directly when you are recording values somewhere else and need the same three
458
+ problems solved — cycles, a depth bound, and values like `Date` or `File` that
459
+ read better flat than walked — but want your own markers:
460
+
461
+ ```typescript
462
+ import { redactGraph } from "@zerotal/core/security";
463
+
464
+ const safe = redactGraph(payload, {
465
+ sensitive: (key) => /password|token/i.test(key),
466
+ mask: "[hidden]",
467
+ circular: "[cycle]",
468
+ tooDeep: "[deep]",
469
+ maxDepth: 8,
470
+ });
471
+ ```
472
+
473
+ Those five fields, plus an optional `flatten` for values you would rather render
474
+ than walk, are `RedactGraphOptions`.
475
+
476
+ It is a traversal, not a policy: the predicate and the markers are yours, because
477
+ a debug panel's `‹redacted›` is a display choice while an adapter implementing a
478
+ published protocol has its markers specified for it.
479
+
480
+ ## Endpoints
481
+
482
+ `DevtoolsInjectionMiddleware` serves these paths directly (it short-circuits the
483
+ request before it reaches your routes). They exist only when the middleware is
484
+ registered — which the provider skips entirely in production:
485
+
486
+ | Path | Method | Description |
487
+ | ---------------------------------- | ------ | -------------------------------------------------- |
488
+ | `/__zerotal/devtools` | `GET` | Standalone inspector dashboard (opens in new tab) |
489
+ | `/__zerotal/devtools/client.js` | `GET` | The injected floating-panel bundle |
490
+ | `/__zerotal/devtools/dashboard.js` | `GET` | The same panel, mounted full-window |
491
+ | `/__zerotal/devtools/sse` | `GET` | Server-sent events stream — `EventSource` endpoint |
492
+ | `/__zerotal/devtools/api/traces` | `GET` | Recent request traces (JSON) |
493
+ | `/__zerotal/devtools/api/channels` | `GET` | Declared trace channels (JSON) |
494
+ | `/__zerotal/devtools/api/map` | `GET` | The framework map — routes, config, container, … |
495
+ | `/__zerotal/devtools/api/clear` | `POST` | Clear all stored traces |
496
+
497
+ > **Danger** — These endpoints expose request headers, SQL with its bindings,
498
+ > session key names, stack traces, and rendered mail. They are all behind one
499
+ > gate: a development process always passes, and anywhere else the absence of a
500
+ > `gate` is a refusal. See [Running it outside development](#running-it-outside-development).
501
+
502
+ ## Reading traces programmatically
503
+
504
+ The same in-memory store that feeds the panel is reachable as `traceStore()`, so
505
+ you can read traces or react to new ones for custom metrics:
506
+
507
+ ```typescript
508
+ // in a script or provider
509
+ import { traceStore } from "@zerotal/devtools";
510
+
511
+ // All traces stored in memory (up to `capacity`, most recent first)
512
+ const traces = traceStore().all();
513
+
514
+ // Find slow requests
515
+ const slow = traces.filter((t) => t.durationMs > 500);
516
+
517
+ // Find requests with N+1 warnings
518
+ const nplus = traces.filter((t) => t.warnings.length > 0);
519
+
520
+ // Find requests that threw
521
+ const failed = traces.filter((t) => t.exception !== null);
522
+
523
+ // Subscribe to new traces (e.g. for custom metrics)
524
+ const unsub = traceStore().subscribe((trace) => {
525
+ if (trace === null) return; // 'clear' event
526
+ console.log(
527
+ `[trace] ${trace.method} ${trace.path} → ${trace.statusCode} (${trace.durationMs}ms)`,
528
+ );
529
+ });
530
+
531
+ // Unsubscribe when done
532
+ unsub();
533
+
534
+ // Clear all stored traces (memory + SQLite)
535
+ traceStore().clear();
536
+ ```
537
+
538
+ Traces are persisted to `.zerotal/devtools.sqlite` and loaded on restart. Two
539
+ environment variables tune persistence:
540
+
541
+ ```ini
542
+ # .env
543
+ ZT_DEVTOOLS_DB=.data/devtools.sqlite # default: .zerotal/devtools.sqlite
544
+ ZT_DEVTOOLS_PRUNE_HOURS=48 # default: 24
545
+ ```
546
+
547
+ > **Note** — If `bun:sqlite` is unavailable, the store degrades silently to
548
+ > memory-only: traces still appear in the panel but are not persisted across
549
+ > restarts.
550
+
551
+ ## Running it outside development
552
+
553
+ By default the inspector follows the same gate as the stack-trace error page: on
554
+ under `zt dev`, off in a deployed process, with nothing to configure. That is the
555
+ right default and it was, until recently, the only option — so the way people ran
556
+ the inspector on a shared staging box was to lie about `APP_ENV`.
557
+
558
+ There is now a supported way:
559
+
560
+ ```typescript
561
+ // config/devtools.ts
562
+ export default DevtoolsConfig({
563
+ enabled: true, // explicit; `null` follows the dev-surface gate
564
+ gate: (request) => request.headers.get("X-Debug-Key") === Bun.env["DEBUG_KEY"],
565
+ });
566
+ ```
567
+
568
+ Four rules, and they are the point of it:
569
+
570
+ - **A development process always passes.** A gate that can lock you out of your
571
+ own laptop is a gate that gets switched off, and then nothing is gated.
572
+ - **Anywhere else, no gate is a refusal.** An app that turned the inspector on
573
+ outside development without saying who may read it has not made a decision this
574
+ code should make for it. A gate that _throws_ is also a refusal — failing open
575
+ there would turn a typo in an authorization check into an open inspector.
576
+ - **One gate answers for everything.** The stream, the trace JSON, the dashboard,
577
+ and the panel bundle expose the same request data. Two gates that can disagree
578
+ is how a dev-only surface ends up serving request headers in production.
579
+ - **Refusals are 404, not 403.** Outside development the honest answer to an
580
+ unauthenticated stranger is that there is nothing here.
581
+
582
+ > **Danger** — Traces contain request headers, SQL with its bindings, session key
583
+ > names, stack traces, and rendered mail. Gate accordingly, and prefer
584
+ > `enabled: false` to a weak gate.
585
+
586
+ Auto-injection of the panel script is development-only. On a gated environment
587
+ the tag would go into every visitor's HTML and then 404 in their console, so
588
+ there the way in is the dashboard at `/__zerotal/devtools`.
589
+
590
+ To switch it off entirely without removing the provider:
591
+
592
+ ```typescript
593
+ // config/devtools.ts
594
+ export default DevtoolsConfig({ enabled: false });
595
+ ```
596
+
597
+ Or omit the provider:
598
+
599
+ ```typescript
600
+ // bootstrap/providers.ts
601
+ import type { ServiceProvider } from "zerotal";
602
+ import { DatabaseProvider } from "@zerotal/orm";
603
+ import { DevtoolsProvider } from "@zerotal/devtools";
604
+
605
+ const providers: ServiceProvider[] = [DatabaseProvider];
606
+ if (Bun.env.APP_ENV !== "production") providers.push(DevtoolsProvider);
607
+
608
+ export default providers;
609
+ ```
610
+
611
+ ## Testing
612
+
613
+ Set your suite up once as described in [Testing](/docs/testing). Devtools is
614
+ development-only tooling, so the tests worth writing are about it staying out of
615
+ the way — not about the panel itself.
616
+
617
+ **Assert it never reaches production.** This is the one that matters: the
618
+ injection middleware appends a script tag to every HTML response, and a
619
+ misconfigured deploy that ships it exposes request traces to your users:
620
+
621
+ ```typescript
622
+ // tests/devtools/injection.test.ts
623
+ import { test } from "bun:test";
624
+ import { createApp } from "../helpers.ts";
625
+
626
+ test("the devtools script is not injected outside development", async () => {
627
+ const app = await createApp(); // helpers boot with env: "test"
628
+
629
+ const res = await app.get("/");
630
+
631
+ res.assertDontSee("__devtools");
632
+ await app.close();
633
+ });
634
+ ```
635
+
636
+ **Assert it does not touch non-HTML responses.** A middleware that appends markup
637
+ to a JSON body or a file download corrupts it, and the failure shows up as a
638
+ parse error somewhere unrelated:
639
+
640
+ ```typescript
641
+ // tests/devtools/injection.test.ts
642
+ test("JSON responses are left alone", async () => {
643
+ const res = await app.get("/api/posts", { Accept: "application/json" });
644
+
645
+ res.assertHeader("Content-Type", "application/json");
646
+ res.assertDontSee("<script");
647
+ });
648
+ ```
649
+
650
+ **The trace store is an ordinary object**, so a panel plugin you write tests
651
+ without a browser:
652
+
653
+ ```typescript
654
+ // tests/devtools/plugin.test.ts
655
+ import { traceStore } from "@zerotal/devtools";
656
+
657
+ traceStore().clear();
658
+ await app.get("/posts");
659
+
660
+ expect(traceStore().all()).not.toHaveLength(0);
661
+ ```
662
+
663
+ > **Note** — If your suite boots with `env: "test"` (as the scaffolded
664
+ > `tests/helpers.ts` does), devtools is inactive and these assertions pass
665
+ > trivially. That is the point — they fail only when someone widens the
666
+ > environment check, which is exactly when you want to hear about it.
667
+
668
+ ## References
669
+
670
+ ### `TraceStore`
671
+
672
+ The in-memory ring of recent traces that backs the panel, reached through
673
+ `traceStore()`.
674
+
675
+ It is a function rather than an exported instance because the store opens a
676
+ SQLite file: constructing it at module scope meant importing this package wrote
677
+ a database into the working directory of every process that did so, production
678
+ included. `traceStore()` builds it on first call, and `DevtoolsProvider`
679
+ installs one configured from your `config/devtools.ts`.
680
+
681
+ | Method | Signature | Description |
682
+ | --------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
683
+ | `all()` | `all(): RequestTrace[]` | Return all stored traces, most recent first. |
684
+ | `push(trace)` | `push(trace: RequestTrace): void` | Add a trace, persist it, and notify subscribers. Called internally by the tracer. |
685
+ | `clear()` | `clear(): void` | Empty the in-memory store and delete all rows from the SQLite DB. |
686
+ | `subscribe(fn)` | `subscribe(fn: (t: RequestTrace \| null) => void): () => void` | Register a callback for every new trace; returns an unsubscribe fn. `fn` gets `null` on `clear`. |
687
+ | `dispose()` | `dispose(): void` | Flush pending writes, stop the timers, and close the database. |
688
+ | `capacity` | `readonly capacity: number` | How many traces this store keeps. Sent to the panel so it trims to the same depth. |
689
+
690
+ ### `TraceSink`
691
+
692
+ Bound in the container as `devtools.trace`. Resolve it with `tryMake` and guard
693
+ the result — it is absent when devtools is not installed or the app is in
694
+ production.
695
+
696
+ | Method | Signature | Description |
697
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------ |
698
+ | `channel(descriptor)` | `(d: TraceChannelDescriptor) => void` | Declare a channel so its entries get a tab. |
699
+ | `record(ctx, channel, entry)` | `(ctx: object, channel: string, entry: object) => void` | Record one entry; `offsetMs` is stamped for you. |
700
+ | `bufferQuery` / `bufferWarning` / `bufferMail` / `bufferCache` / `bufferJob` | `(ctx, entry) => void` | The five signals with bespoke panels. |
701
+
702
+ ### `TraceChannelDescriptor`
703
+
704
+ ```typescript
705
+ // from @zerotal/devtools
706
+ interface TraceChannelDescriptor {
707
+ id: string; // unique — also the key under RequestTrace.channels
708
+ label: string; // tab label
709
+ badge?: string; // entry field shown as the row's leading chip
710
+ title?: string; // entry field shown as the row's main text
711
+ meta?: string[]; // entry fields shown as dim metadata
712
+ warn?: string; // entry field whose truthiness marks the row
713
+ order?: number; // position among channel tabs (default 100)
714
+
715
+ // Presentation — see below
716
+ render?: "rows" | "tree" | "table" | "kv" | "grouped";
717
+ treeField?: string;
718
+ treeBadge?: string;
719
+ groupBy?: string;
720
+ flags?: string[];
721
+ traceGroup?: string;
722
+ }
723
+ ```
724
+
725
+ #### Choosing a presentation
726
+
727
+ A flat list of rows is the right shape for an audit feed and the wrong one for a
728
+ prop map or a route table. `render` picks a different one — still declared as
729
+ data, so the panel ships no code for your package either way.
730
+
731
+ | `render` | Shape | Also reads |
732
+ | ----------- | --------------------------------------------------------- | ------------------------ |
733
+ | `"rows"` | One block per entry: badge, title, meta. **The default.** | — |
734
+ | `"tree"` | A map of dotted paths, drawn as branches and leaves. | `treeField`, `treeBadge` |
735
+ | `"table"` | One row per entry, `meta` as columns. For many entries. | — |
736
+ | `"kv"` | Every field of every entry. For few entries, many fields. | — |
737
+ | `"grouped"` | Rows collected under a shared value. | `groupBy` |
738
+
739
+ Two hints apply to any presentation:
740
+
741
+ - **`flags`** — fields rendered as a bare chip when truthy. A flag is named by
742
+ its _field_, so `{ shared: true }` reads as **shared** rather than
743
+ `shared: true`, which is how a row ends up saying nothing at a glance. Under
744
+ `"tree"` they apply per node.
745
+ - **`traceGroup`** — the field whose value correlates whole _traces_ on the All
746
+ tab. One user action can be several requests; traces sharing a value here fold
747
+ into one expandable entry under the oldest of them, instead of scattering down
748
+ the list and pushing what you were reading off the top.
749
+
750
+ `"tree"` takes a **flat map of dotted paths**, not a nested object —
751
+ `{ "user.name": {…}, "user.email": {…} }` becomes one `user` branch with two
752
+ leaves. Each node's own fields become its `treeBadge` chip, its `flags`, and a
753
+ dim attribute line, so you describe what a node _is_ without the panel knowing
754
+ what any of it means:
755
+
756
+ ```typescript
757
+ trace.channel({
758
+ id: "widgets",
759
+ label: "Widgets",
760
+ render: "tree",
761
+ treeField: "nodes", // the entry field holding the path map
762
+ treeBadge: "kind", // each node's leading chip
763
+ flags: ["cached", "stale"], // each node's boolean chips
764
+ });
765
+
766
+ trace.record(ctx, "widgets", {
767
+ nodes: {
768
+ "sidebar.filters": { kind: "list", cached: true },
769
+ "sidebar.tags": { kind: "list", source: "api" },
770
+ },
771
+ });
772
+ ```
773
+
774
+ Badge chips are accented by hashing their own text, so repeated values keep a
775
+ consistent colour and stay tellable apart without the panel holding a list of
776
+ every value any package might use.
777
+
778
+ ### `RequestTrace`
779
+
780
+ The shape pushed to the store and streamed to the panel:
781
+
782
+ ```typescript
783
+ // from @zerotal/devtools
784
+ interface RequestTrace {
785
+ id: string;
786
+ requestId: string;
787
+ method: string;
788
+ path: string;
789
+ statusCode: number;
790
+ startMs: number;
791
+ durationMs: number;
792
+ queries: QuerySpan[];
793
+ warnings: NPlusOneWarning[];
794
+ memory: number; // heap in use as the request finished, in bytes
795
+ queryParams: Record<string, string>;
796
+ headers: Record<string, string>; // allowlisted; never auth/cookie
797
+ responseHeaders: Record<string, string>;
798
+ session: string[]; // key names only, never values
799
+ route: RouteInfo | null;
800
+ auth: AuthInfo | null;
801
+ /** The error that ended the request, or null when it completed normally. */
802
+ exception: ExceptionInfo | null;
803
+ logs: LogEntry[];
804
+ mail: MailEntry[];
805
+ cache: CacheEntry[];
806
+ jobs: JobEntry[];
807
+ /** Entries recorded on open channels, keyed by channel id. */
808
+ channels: Record<string, TraceChannelEntry[]>;
809
+ }
810
+
811
+ interface QuerySpan {
812
+ sql: string;
813
+ bindings: unknown[]; // masked unless the column is allow-listed
814
+ startMs: number;
815
+ durationMs: number;
816
+ rowCount: number;
817
+ source?: SourceLocation; // the app line that ran it, when one was found
818
+ }
819
+
820
+ interface NPlusOneWarning {
821
+ sql: string;
822
+ count: number;
823
+ }
824
+
825
+ interface RouteInfo {
826
+ pattern: string;
827
+ controller: string;
828
+ action: string;
829
+ }
830
+
831
+ interface AuthInfo {
832
+ id: unknown;
833
+ name?: unknown;
834
+ email?: unknown;
835
+ }
836
+
837
+ interface LogEntry {
838
+ level: "log" | "debug" | "info" | "warn" | "error";
839
+ args: string[];
840
+ offsetMs: number;
841
+ source?: SourceLocation;
842
+ }
843
+
844
+ interface ExceptionInfo {
845
+ message: string;
846
+ status: number; // the status the rendered error response used
847
+ type?: string; // the error's class name
848
+ frames?: SourceLocation[]; // innermost first, framework frames kept
849
+ }
850
+
851
+ interface SourceLocation {
852
+ file: string;
853
+ line: number;
854
+ column?: number;
855
+ function?: string;
856
+ }
857
+
858
+ interface MailEntry {
859
+ className: string;
860
+ to: string[];
861
+ subject: string;
862
+ html: string;
863
+ durationMs: number;
864
+ queued: boolean;
865
+ offsetMs: number;
866
+ }
867
+
868
+ interface CacheEntry {
869
+ op: "has" | "hit" | "miss" | "write" | "forget" | "flush";
870
+ key: string;
871
+ ttl?: number;
872
+ durationMs: number;
873
+ offsetMs: number;
874
+ }
875
+
876
+ interface JobEntry {
877
+ className: string;
878
+ queue: string;
879
+ status: "dispatched" | "completed" | "failed";
880
+ durationMs: number;
881
+ error?: string;
882
+ offsetMs: number;
883
+ }
884
+ ```
885
+
886
+ ## Contributing a tab
887
+
888
+ The panel is a **unified dev tool**: any package can add its own tab, and there
889
+ are two ways in depending on where the data lives.
890
+
891
+ - **The data is per-request, and the server has it** — a query, a dispatch, an
892
+ authorization decision. Declare a **channel** and record against the request
893
+ context. DevTools renders the rows for you, and they show up in the Timeline
894
+ waterfall too. This is the usual case.
895
+ - **The data only exists in the browser** — a client-side store, a WebSocket
896
+ frame log. Register a **panel plugin** and render it yourself. This is how
897
+ `@zerotal/flow` contributes its
898
+ [time-travel Timeline](/docs/flow/performance#time-travel-devtools).
899
+
900
+ ### Channels — server-side data
901
+
902
+ Resolve `devtools.trace` from the container, declare how your entries should
903
+ read, then record one per event. Guard the lookup: it is absent when devtools is
904
+ not installed or the app is in production, and your package must not care.
905
+
906
+ ```typescript
907
+ // your-package/src/observability.ts
908
+ import { FrameworkEvents, RequestContext } from "zerotal";
909
+ import type { Application } from "zerotal";
910
+
911
+ interface DevtoolsSink {
912
+ channel(descriptor: {
913
+ id: string;
914
+ label: string;
915
+ badge?: string;
916
+ title?: string;
917
+ meta?: string[];
918
+ warn?: string;
919
+ order?: number;
920
+ }): void;
921
+ record(ctx: object, channel: string, entry: Record<string, unknown>): void;
922
+ }
923
+
924
+ export function installWidgetObservability(app: Application): () => void {
925
+ const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
926
+ if (!trace) return () => {};
927
+
928
+ trace.channel({
929
+ id: "widgets",
930
+ label: "Widgets",
931
+ badge: "op", // leading chip on each row
932
+ title: "name", // the row's main text
933
+ meta: ["durationMs"], // dim metadata beneath it
934
+ warn: "failed", // a truthy value here marks the row and the tab badge
935
+ order: 40, // position among channel tabs
936
+ });
937
+
938
+ return FrameworkEvents.on(WidgetRendered, (e) => {
939
+ const ctx = RequestContext.tryGet();
940
+ if (ctx) trace.record(ctx, "widgets", { op: "render", name: e.name, durationMs: e.ms });
941
+ });
942
+ }
943
+ ```
944
+
945
+ The descriptor crosses the wire to the browser, so it names _fields_ rather than
946
+ carrying formatter functions — that is what lets DevTools render a tab for a
947
+ package it has never heard of. `offsetMs` is stamped for you, and an entry with a
948
+ `durationMs` gets a bar in the Timeline.
949
+
950
+ Declaring is idempotent and order-independent: re-declaring an id replaces it,
951
+ and entries recorded before a channel is declared still appear once it is.
952
+
953
+ ### Panel plugins — browser-side data
954
+
955
+ From your package's **browser** code, register a panel on the global registry the
956
+ panel exposes:
957
+
958
+ ```ts
959
+ window.__zerotalDevtools?.register({
960
+ id: "my-panel", // unique — the tab is addressed internally as `plugin:my-panel`
961
+ title: "My Panel", // tab label
962
+ badge: () => items.length || undefined, // optional badge (falsy hides it)
963
+ render: (el) => {
964
+ el.innerHTML = `<p class="empty">Nothing yet</p>`; // render into the shared content area
965
+ },
966
+ });
967
+
968
+ // Push a live update — refresh the badge, and re-render if the tab is open:
969
+ window.__zerotalDevtools?.refresh("my-panel");
970
+ ```
971
+
972
+ Notes:
973
+
974
+ - **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.
975
+ - **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).
976
+ - **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.
977
+ - **Event handling.** `el` (the content area) is persistent across renders; set `el.onclick` with a delegated handler (assignment replaces, so it won't stack).
978
+
979
+ The `DevtoolsPanelPlugin` type is exported from `@zerotal/devtools` for TypeScript consumers.
980
+
981
+ ### Testing what you contribute
982
+
983
+ The panel is markup, and markup is awkward to assert on. Everything in it that is
984
+ _logic_ is exported from `@zerotal/devtools/client`, so you can check how your
985
+ channel's rows will filter, fold, and nest without a browser:
986
+
987
+ | Export | Answers |
988
+ | ---------------------------------- | ------------------------------------------------------------ |
989
+ | `matchesFilter(trace, query)` | Would this trace survive the filter box? |
990
+ | `matchesFacets(trace, facets)` | Would it survive the facet chips? |
991
+ | `traceMatches(trace, query, f)` | Both at once — what the All tab actually asks. |
992
+ | `methodsPresent(traces)` | Which method chips are worth offering. |
993
+ | `noFacets()` / `facetsActive(f)` | An empty `Facets` set, and whether one narrows anything. |
994
+ | `SLOW_MS` | Where the `slow` chip draws its line, so a test can agree. |
995
+ | `buildPathTree(paths)` | What tree does my `"tree"` channel's dotted path map become? |
996
+ | `traceGroupKey(trace, channels)` | Which channel field correlates this trace, if any? |
997
+ | `foldTraceRows(matches, ch, open)` | The rows the All tab draws, with correlated requests folded. |
998
+
999
+ `PathTreeNode` is `{ children: Map<string, PathTreeNode>; attrs: Record<string, unknown> | null }`.
1000
+ A branch that nothing was recorded against has `attrs: null`, which is
1001
+ meaningfully different from `{}` — a node can be both a branch and a leaf.
1002
+
1003
+ `foldTraceRows` returns a flat `TraceRow[]`, each row carrying its index into the
1004
+ unfiltered list plus whether it heads a group (`groupKey`, `groupSize`) or is a
1005
+ folded follow-up (`child`).
1006
+
1007
+ ## Next steps
1008
+
1009
+ - [Logger](/docs/logger) — structured logging that surfaces in the Logs tab.
1010
+ - [Query builder](/docs/query-builder) — the queries DevTools traces and flags for N+1.
1011
+ - [Events](/docs/events) — the `FrameworkEvents` that DevTools subscribes to.
1012
+ - [Telemetry](/docs/telemetry) — production-grade metrics once you move past the dev panel.
1013
+ - [Testing](/docs/testing/index) — assert on requests without the floating panel.