@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/devtools.md
ADDED
|
@@ -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.
|