@atolljs/devtools 0.1.7 → 0.1.8

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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
package/README.md CHANGED
@@ -1,57 +1,19 @@
1
1
  # @atolljs/devtools
2
2
 
3
3
  Devtools transport + analytics dashboard for AtollJS instrumentation events:
4
- pool lifecycle, task timings, worker crashes/respawns, shared-memory field
5
- writes, and island op traffic — streamed from the app to a standalone server
6
- and rendered by a bundled dashboard.
7
-
8
- The dashboard is a multi-view analytics site (dependency-free static app in
9
- `app/`):
10
-
11
- - **Dashboard** — sub-tabs: *Overview* (the **app map** — one world-space
12
- canvas holding every live session as an atoll cluster: main thread at
13
- the center, pool sockets on the rim, workers on the mid orbit, islands
14
- on the outer orbit rendered as framework marks — React ⚛, Vue V,
15
- Svelte S, Angular A, Solid, vanilla JS, per the `framework` tag the
16
- mounter declares. Wheel to zoom, drag to pan, double-click to refit;
17
- zoom tiers apply clustering — sessions collapse to labeled discs, mid
18
- zoom collapses islands into a green pool ring; click a node to inspect,
19
- click a session disc/center to drill in) plus KPI cards, per-second
20
- task-throughput chart, and an "attention needed" feed. *Pools*,
21
- *Workers*, *Islands*: the entity tables — pools (size, memory, worker
22
- count, tasks, failures), workers (tasks, failures, respawns, errors,
23
- heap, hosted islands, last task, live/down), islands (app, framework,
24
- pid, host `poolN#0` cross-link, round-trips, op batches, replay ms,
25
- events, live/unmounted). *Worker* / *Island*: the inspector sub-tabs,
26
- appearing on row or map-node click — worker inspector (own waterfall
27
- lane, live heap chart, that worker's fetches, task history, error
28
- log), island inspector (framework badge, op-traffic chart, per-call
29
- aggregates, event-channel breakdown, round-trip table). Island and
30
- worker inspectors are cross-linked — `island:mount` carries the
31
- client's `poolId`.
32
- - **Tasks** — a waterfall/flame view: one swimlane per pool worker slot,
33
- queued→dispatched→settled segments colored by outcome, hover tooltips;
34
- per-task aggregates (n, failures, wait/run avg, run p95/max) with
35
- click-to-drill filtering of the waterfall and the per-call table.
36
- - **Network** — two sub-tabs. *Traffic*: rolling 60s charts (tasks/s,
37
- avg run ms, errors/s, in-flight calls, island ops/round-trips/events
38
- per second, fetches/s) plus a **fetch log** of real `fetch()` traffic
39
- from main thread and workers. *Request* (click a fetch row): request/
40
- response headers, capped body previews, sizes (transfer/encoded/
41
- decoded), and a Resource-Timing stage breakdown (stalled, dns, tcp,
42
- tls, request+waiting, download). Cross-origin responses without
43
- `Timing-Allow-Origin` show total time only.
44
- - **Memory** — two sub-tabs. *Shared-memory writes*: field-writes/s and
45
- top-fields/s charts plus the per-field table (writes, writes/sec,
46
- version, last writer `poolId#slot` when worker-forwarded, rate
47
- sparkline). *JS heap*: per-execution-context heap via
48
- `performance.measureUserAgentSpecificMemory` — worker heaps identified
49
- by worker script URL.
50
- - **Log** — the raw event stream.
4
+ pool lifecycle, task timings and message cost, worker crashes/respawns,
5
+ shared-memory field writes and live values, island op traffic, props and
6
+ DOM, main-thread jank, reactivity, and SDK logs. Events stream from the app
7
+ to a dashboard (same-origin BroadcastChannel, or the standalone server), and
8
+ the dashboard can send commands back: kill a worker, inject faults, edit
9
+ island props, set shared-memory watchpoints.
10
+
11
+ The dashboard is a dependency-free static app in `app/`. Its views are
12
+ described in the [Views reference](#views-reference) below.
51
13
 
52
14
  Sessions are pages/processes; a disconnected session is retained (bounded)
53
15
  as an **ended** session so its history stays attributed for post-mortem
54
- analysis — its rows are grayed and excluded from live KPIs. Hover an ended
16
+ analysis: its rows are grayed and excluded from live KPIs. Hover an ended
55
17
  session in the sidebar and click **×** to dismiss it: the server drops it
56
18
  from the session list and purges its replayed batches, so it stays gone for
57
19
  current and future viewers. Live sessions can't be dismissed.
@@ -60,10 +22,13 @@ current and future viewers. Live sessions can't be dismissed.
60
22
 
61
23
  | Import | Purpose |
62
24
  |---|---|
63
- | `@atolljs/devtools` | `connectDevtools({ transport?, url?, session?, flushMs?, bufferCap?, replayBatches?, network?, memory? })` — installs the core event sink and batches events to dashboards. Default `transport: 'auto'`: **BroadcastChannel** in a browser window (pure client — no backend; the dashboard is served on the app's own origin at `/__atoll/` by the vite plugin, and `mountDevtoolsOverlay()` embeds it in a floating flyout — draggable, resizable, `position` option), WebSocket to the aggregate server everywhere else (`url` or `transport: 'websocket'` opts in — Node uses this path). `network` (default on) wraps `fetch` to emit `net:fetch` per request; `memory` (default on) samples JS heap into `runtime:memory`. |
64
- | `@atolljs/devtools/node` | Node entry — `initDevtools`/`connectDevtools` without the browser-only pieces (WebSocket-only, env-gated, no overlay). Use this import in Node apps. |
65
- | `@atolljs/devtools/server` | `createDevtoolsServer({ port?, host?, appDir?, replayBatches? })` — Node http + dependency-free WebSocket server on `127.0.0.1` (default port 4780). |
66
- | `atoll-devtools` bin | Boots the server and prints the dashboard URL. `atoll devtools` resolves it through the CLI too. |
25
+ | `@atolljs/devtools` | `connectDevtools({ transport?, url?, session?, flushMs?, bufferCap?, replayBatches?, network?, memory?, jank? })`: installs the core event sink and batches events to dashboards. Default `transport: 'auto'`: **BroadcastChannel** in a browser window (pure client, no backend; the dashboard is served on the app's own origin at `/__atoll/` by the vite plugin in dev, and emitted into `vite build` output whenever the app bundles devtools), WebSocket to the aggregate server everywhere else (`url` or `transport: 'websocket'` opts in; Node uses this path). `session` is `{ name?, hint?, framework? }`. `network` (default on) wraps `fetch` to emit `net:fetch` per request; `memory` (default on) samples JS heap into `runtime:memory`; `jank` (default on) installs the main-thread jank probe (`runtime:longframe`, `runtime:frames`). |
26
+ | `@atolljs/devtools` | `initDevtools({ enabled?, overlay?, ...connectOptions })`: the URL-gated one-liner (`?__atoll_devtools`), connects and mounts the overlay. |
27
+ | `@atolljs/devtools` | `mountDevtoolsOverlay({ src?, startOpen?, width?, height?, position?, persist?, hotkey? })`: the floating flyout (draggable, resizable). `persist` (default `true`, or a localStorage key, default `'atoll-devtools:overlay'`) remembers position, size and open state; `hotkey` (default `'Alt+Shift+D'`, `false` disables) toggles it. |
28
+ | `@atolljs/devtools/node` | Node entry: `initDevtools`/`connectDevtools` without the browser-only pieces (WebSocket-only, env-gated, no overlay). Use this import in Node apps. |
29
+ | `@atolljs/devtools/server` | `createDevtoolsServer({ port?, host?, appDir?, replayBatches?, closedTtlMs?, sweepMs? })`: Node http + dependency-free WebSocket server on `127.0.0.1` (default port 4780). |
30
+ | `@atolljs/devtools/otel` | `exportOtel({ endpoint?, headers?, serviceName?, resource?, signals?, flushIntervalMs?, maxBatch?, maxQueue?, maxSeries?, onError?, network?, memory?, jank? })`: dependency-free OTLP/HTTP JSON exporter (traces, metrics, logs) to any OpenTelemetry backend; returns `{ flush(), close() }`. Install before pools spawn. See [OpenTelemetry export](#opentelemetry-export). |
31
+ | `atoll-devtools` bin | Boots the server and prints the dashboard URL. `atoll devtools` resolves it through the CLI too. `--otlp <url>` also forwards every session to an OTLP endpoint. |
67
32
 
68
33
  ## Usage
69
34
 
@@ -71,18 +36,19 @@ current and future viewers. Live sessions can't be dismissed.
71
36
  npx atoll-devtools # or: atoll devtools [--port 4780]
72
37
  ```
73
38
 
74
- The quickest wire-up in an existing app is the scaffolder —
39
+ The quickest wire-up in an existing app is the scaffolder:
75
40
  `atoll add devtools` writes the right init module for your host (browser,
76
41
  Node, or the Angular aggregate-server variant) and installs the package.
77
42
  Or by hand:
78
43
 
79
44
  ```ts
80
- // in the app — one line. No-op unless the URL carries ?__atoll_devtools:
45
+ // in the app, one line. No-op unless the URL carries ?__atoll_devtools:
81
46
  // then the BroadcastChannel sink installs and the overlay flyout mounts.
82
47
  // Call before pools spawn: worker-side events forward only if the sink
83
48
  // existed at INIT time.
84
49
  import { initDevtools } from '@atolljs/devtools';
85
- initDevtools({ session: { name: 'my-app' } });
50
+ // `framework` (optional) puts the shell's mark on the map's main-thread hub.
51
+ initDevtools({ session: { name: 'my-app', framework: 'react' } });
86
52
 
87
53
  // my-app/?__atoll_devtools → devtools on
88
54
  // my-app/ → zero cost, no sink installed
@@ -93,8 +59,8 @@ initDevtools({ session: { name: 'my-app' } });
93
59
 
94
60
  ## Node apps (Express, NestJS, …)
95
61
 
96
- Node takes the WebSocket path automatically — no `window`, so no
97
- BroadcastChannel — pointed at the standalone server, which is also the
62
+ Node takes the WebSocket path automatically (no `window`, so no
63
+ BroadcastChannel), pointed at the standalone server, which is also the
98
64
  dashboard:
99
65
 
100
66
  ```bash
@@ -102,14 +68,14 @@ npx atoll-devtools # http://127.0.0.1:4780
102
68
  ```
103
69
 
104
70
  ```ts
105
- // src/devtools.ts — a dedicated module, imported FIRST from the entry.
71
+ // src/devtools.ts: a dedicated module, imported FIRST from the entry.
106
72
  // No-op unless ATOLL_DEVTOOLS is set in the environment.
107
73
  import { initDevtools } from '@atolljs/devtools/node';
108
74
  initDevtools({ session: { name: 'my-api' } });
109
75
  ```
110
76
 
111
77
  ```ts
112
- // main.ts — the import order matters: ESM evaluates imports before the
78
+ // main.ts: the import order matters. ESM evaluates imports before the
113
79
  // body, and pools usually spawn inside a sibling module or NestFactory.
114
80
  import './devtools';
115
81
  ```
@@ -118,31 +84,399 @@ import './devtools';
118
84
  ATOLL_DEVTOOLS=1 npm run start
119
85
  ```
120
86
 
121
- - Works on any Node — the client uses the global `WebSocket` on ≥ 22 and
87
+ - Works on any Node: the client uses the global `WebSocket` on ≥ 22 and
122
88
  falls back to a bundled dependency-free client (`src/nodeWs.ts`) below it.
123
- - Inbound HTTP is not instrumented — `net:fetch` covers the `fetch()` calls
89
+ - Inbound HTTP is not instrumented: `net:fetch` covers the `fetch()` calls
124
90
  your app *makes*, on the main thread and inside workers.
125
- - `runtime:memory` comes from `process.memoryUsage()` in Node — per-worker
91
+ - `runtime:memory` comes from `process.memoryUsage()` in Node: per-worker
126
92
  heap columns fill for `worker_threads` pools.
127
- - No overlay, no `/__atoll/` — Node sessions appear on the aggregate
128
- dashboard as `runtime: 'node'` alongside any browser apps pointed at the
129
- same server.
93
+ - No overlay, no `/__atoll/`, no jank probe (it is browser main thread
94
+ only): Node sessions appear on the aggregate dashboard as
95
+ `runtime: 'node'` alongside any browser apps pointed at the same server.
130
96
  - See `examples/express` and `examples/nestjs` for the wiring.
131
97
 
98
+ ## Views reference
99
+
100
+ The nav runs Dashboard, Tasks, Performance, Network, Memory, Reactivity,
101
+ Audits, Log. Each view opens with a one-line intro (dismissible; "Show view
102
+ intros again" in the palette brings them back). Views scope to the session
103
+ selected in the sidebar, or every session when none is.
104
+
105
+ ### Dashboard
106
+
107
+ The **app map** sits above the sub-tabs: one world-space canvas holding
108
+ every live session as an atoll cluster. The main thread is at the center
109
+ (carrying the shell's framework mark when `session.framework` is set), pool
110
+ sockets (P) on the inner orbit, workers on the outer orbit. A worker
111
+ hosting one island is drawn *as* that island, as its framework mark
112
+ (React, Vue, Svelte, Angular, Solid, or vanilla JS, per the `framework` tag
113
+ the mounter declares or the renderer the worker reports). A worker shared by
114
+ several islands is a ring with a W badge and one node per instance inside. A
115
+ plain worker, or an untagged lone island, is a W. Nested sub-workers hang
116
+ off the instance (or worker) that spawned them on a violet link. Wheel to
117
+ zoom, drag to pan, double-click to refit and back out to every session;
118
+ click a node to inspect it, click a session disc to drill into that
119
+ session. Fed by `pool:init`, `worker:spawn`, `island:mount`.
120
+
121
+ - **Overview**: KPI cards (live pools, tasks settled, error rate, run p95
122
+ and max, field writes, islands, workers), the per-second **task
123
+ throughput** chart (last 60s, with latest, peak, average, total and
124
+ failures), and an **Attention needed** feed of worker respawns, worker
125
+ errors, and failed task settles.
126
+ - **Pools / Workers / Islands**: the entity tables. Pools: size, memory,
127
+ worker count, tasks, failures. Workers: tasks, failures, respawns, errors,
128
+ heap, hosted islands, last task, live/down. Islands: app, framework, pid,
129
+ host `poolId#slot` cross-link, round-trips, op batches, ops, replay ms,
130
+ events, live/unmounted. Click a row to open its inspector.
131
+ - **Worker** (inspector, appears on a worker click): KPIs, the worker's own
132
+ task lane (click a segment to drill Tasks to that task id), its last 150
133
+ tasks, a live heap chart, its last 50 fetches, and its error log. With a
134
+ live session that exposes the commands, the inspector also carries the
135
+ **Kill worker** button and the **Chaos** form (see
136
+ [Live controls](#live-controls)).
137
+ - **Island** (inspector, appears on an island click): KPIs (round-trips,
138
+ avg/max round-trip ms, ops applied, replay ms, emits, host heap, failed
139
+ calls), an op-traffic chart colored by call, per-call aggregates, the
140
+ event-channel breakdown, and the last 150 round-trips. Three tabs sit
141
+ above the charts:
142
+ - **Elements**: the island's real DOM as a collapsible tree, fetched with
143
+ `island.tree` (up to 4000 nodes per fetch; Refresh, or auto-refresh every
144
+ 2s). Hover a node to outline it in the app page (`island.highlight`,
145
+ with its tag and size); click it for its attributes and text. A nested
146
+ island (`parent~app@N`) shows the top-level island's tree, which holds
147
+ its replayed DOM.
148
+ - **Props**: the `island:props` history (mount plus every `updateProps`,
149
+ last 100), each entry diffed against the previous one by top-level key.
150
+ - **Events**: the `island:event` log (last 500) with payload previews and
151
+ a name filter.
152
+
153
+ Island controls (**Edit props**, **Push** / **Poll** mode) sit in the
154
+ inspector header.
155
+ - **Map** (flyout only): in the `?mini=1` layout the app map becomes its own
156
+ default sub-tab and fills the view.
157
+
158
+ ### Tasks
159
+
160
+ A waterfall of every pool task call: one swimlane per worker slot, queue
161
+ wait (gray) then run (colored by outcome), with hover tooltips. Below it,
162
+ per-task aggregates (calls, failures, wait avg, run avg, run p95, run max;
163
+ click a row to drill the waterfall and tables to that task) and the latest
164
+ 200 calls. Fed by `task:enqueue` / `task:dispatch` / `task:settle`.
165
+
166
+ ### Performance
167
+
168
+ Main-thread responsiveness and message cost, all on a rolling 60s window.
169
+
170
+ - **Main thread**: KPIs (long frames, total blocking time, fps now, min fps,
171
+ dropped frames, worst frame) and a timeline of long frames (bar height =
172
+ duration, red when blocking ≥ 100ms) overlaid with one fps line per
173
+ session. Fed by the [jank probe](#jank-probe-and-user-timing).
174
+ - **Worst long frames**: the longest retained frames with their top scripts
175
+ from Long Animation Frame attribution (the `longtask` fallback has none).
176
+ - **Message cost**: per task id, the estimated clone size of args posted
177
+ (avg, max) and results returned (avg, max); per island, the op-batch size
178
+ (avg, max, total). Fed by `task:dispatch.argBytes`,
179
+ `task:settle.resultBytes`, `island:ops.bytes`.
180
+
181
+ The nav badge counts long frames in the last 10s.
182
+
183
+ ### Network
184
+
185
+ - **Traffic**: rolling 60s charts (tasks/s, avg run ms, errors/s, in-flight
186
+ calls, island ops/s, island round-trips/s, island events/s, fetches/s) and
187
+ a **fetch log** of real `fetch()` traffic from the main thread and workers
188
+ (latest 150, attributed to `main` or `poolId#slot`).
189
+ - **Request** (click a fetch row): request/response headers, capped body
190
+ previews, sizes (transfer/encoded/decoded), and a Resource Timing stage
191
+ breakdown (stalled, dns, tcp, tls, request+waiting, download).
192
+ Cross-origin responses without `Timing-Allow-Origin` show total time only.
193
+
194
+ ### Memory
195
+
196
+ - **Values**: live shared-memory field values for one session, read with
197
+ `memory.read` about twice a second while the tab is open; changed cells
198
+ flash. **Snapshot** remembers the current values and a diff column
199
+ compares against them. **Watchpoints** and the watch-hit log sit below
200
+ (see [Watchpoints](#shared-memory-watchpoints)). Needs one live session:
201
+ select it in the sidebar when several are live.
202
+ - **Shared-memory writes**: field-writes/s and top-fields/s charts plus the
203
+ per-field table (writes, writes/sec, version, last writer `poolId#slot`
204
+ when worker-forwarded, rate sparkline). Fed by `memory:write`.
205
+ - **JS heap**: a per-session heap line and a per-execution-context table
206
+ from `performance.measureUserAgentSpecificMemory` (worker heaps identified
207
+ by worker script URL). Fed by `runtime:memory`.
208
+
209
+ ### Reactivity
210
+
211
+ The cross-thread dependency graph from `reactive:node` events, laned by
212
+ owner: a **shared memory** lane for field sources, then the main thread, then
213
+ one lane per worker slot. Edges run dependency to dependent. Node kinds map
214
+ onto the SDK's reactivity:
215
+
216
+ | Kind | What it is |
217
+ |---|---|
218
+ | source | a shared-memory field, id `mem:<path>`, the same node on every thread |
219
+ | bridge | a version-counter watcher (`Atomics.waitAsync`, or the 50ms poll fallback): the part that crosses threads |
220
+ | derived | a selector slice of a `watch` / `observe` |
221
+ | effect | a `watch` callback, or an `observe` observable's subscriber fan-out |
222
+
223
+ Click a node for its id, lane, owner, dependencies and dependents. Disposed
224
+ nodes fade (toggle **show disposed**). Nodes are emitted only for
225
+ subscriptions created while devtools is enabled; **Load snapshot** fetches
226
+ the main thread's live nodes (`reactive.nodes`) when the dashboard opened
227
+ after the creation events left the replay tail.
228
+
229
+ ### Audits
230
+
231
+ Live recommendations: pure rules re-run once a second over the selected
232
+ sessions' recent data, rendered as cards with a severity, the evidence, a
233
+ fix, a docs link, and entity chips that open the matching inspector. Filter
234
+ by severity; **mute rule** hides a rule's findings (persisted in
235
+ localStorage under `atoll.devtools.audits.muted`). The "What each rule
236
+ needs" table lists every rule's input events and whether that data has been
237
+ seen. The nav badge counts unmuted errors and warnings.
238
+
239
+ | Rule | Fires when | Severity |
240
+ |---|---|---|
241
+ | `coi-islands` | islands are mounted on a page reporting `crossOriginIsolated = false` | warn |
242
+ | `large-args` / `large-results` | a task's args / results p95 or average exceeds 64KB (3+ calls in 60s) | warn |
243
+ | `island-batch-bytes` | an island's op-batch p95 exceeds 128KB (3+ batches in 60s) | warn |
244
+ | `island-slow-replay` | an island's replay p95 exceeds 16ms (3+ batches in 60s) | warn |
245
+ | `pool-saturated` | queue-wait p95 over 50ms and over run p95 (20+ dispatches), or a backlog of 20+ that grew by 10+ in 10s | warn |
246
+ | `pool-underused` | a pool of 2+ workers below 5% busy over 30s+ of observation, no backlog | info |
247
+ | `pool-oversized` | `poolSize` exceeds the session's `hardwareConcurrency` | warn |
248
+ | `main-jank` | total blocking time of visible-page frames in 60s reaches 300ms (error at 1000ms) | warn / error |
249
+ | `background-work` | script time in hidden-page long frames reaches 1000ms in 60s | info |
250
+ | `low-fps` | average fps below 45 over 10s (3+ samples) | warn |
251
+ | `task-errors` | a task's error + crashed rate reaches 10% (error at 50%), 5+ settles | warn / error |
252
+ | `task-timeouts` | a task's timeout rate reaches 5%, 5+ settles | warn |
253
+ | `queue-full` | any `queue-full` settle in 60s | warn |
254
+ | `crash-loop` | one slot respawned 3+ times in 60s | error |
255
+ | `heap-pressure` | heap / limit reaches 80% (error at 95%), worker or main thread | warn / error |
256
+ | `devtools-late` | task events for a pool whose `pool:init` was never seen | warn |
257
+ | `memory-write-storm` | one field averages 1000+ writes/s over 5s | warn |
258
+ | `fetch-failing` | requests to a method + path failed (network error or status ≥ 400) in 60s | info |
259
+ | `fetch-slow` | p95 to response headers over 2000ms (3+ requests) | info |
260
+ | `island-untagged` | mounted islands with no framework tag and no reported renderer | info |
261
+
262
+ Thresholds live as exported constants in `app/panels/audit-rules.js`.
263
+ Late-joining dashboards: a pool whose `pool:init` already left the app's
264
+ replay tail looks like `devtools-late`, and replayed history is stamped on
265
+ arrival, so the whole tail counts as "the last few seconds" for rate rules
266
+ until it ages out.
267
+
268
+ ### Log
269
+
270
+ The raw event stream, one line per event (last 2000 lines). Filter by text,
271
+ by session (when several are live), and by group chips: one per event-type
272
+ prefix (`task`, `island`, `memory`, …) plus one per `log` level. Click a
273
+ chip to toggle it, shift- or alt-click to solo it. **pause** holds incoming
274
+ lines until resume; click a line to expand its full payload. Timestamps are
275
+ the dashboard's wall clock at receipt, with the event's per-thread elapsed
276
+ time alongside.
277
+
278
+ `log` lines are SDK log entries (`src/log.ts`) mirrored into the stream:
279
+ they follow the emitting thread's `setLogLevel` (default `info`), carry a
280
+ preview of the entry's data, and include worker logs once that worker's
281
+ devtools forwarding is on. A worker's logs from before its INIT handshake,
282
+ or from a worker spawned before devtools was enabled, never arrive.
283
+
284
+ ## Live controls
285
+
286
+ The dashboard sends commands to a live app over the same transport
287
+ (`control` frames; see [docs/devtools.md](https://github.com/jwhenry3/atolljs/blob/main/docs/devtools.md)).
288
+ A control is enabled only when the session is live, not a replayed
289
+ recording, and the app advertises the command; otherwise its tooltip says
290
+ why.
291
+
292
+ - **Kill worker** (worker inspector): `worker.kill` runs the runner's real
293
+ crash path with the message `killed from devtools`: in-flight calls reject
294
+ with `WorkerCrashedError` and the worker respawns when the runner allows
295
+ it. Asks for confirmation first.
296
+ - **Chaos** (worker inspector, applies to the whole runner): `pool.chaos`
297
+ with a delay before each call is posted (ms), a fail rate (calls rejected
298
+ with `chaos: injected failure`, settle outcome `error`, never reaching the
299
+ worker), and a timeout rate (calls failed through the real timeout path,
300
+ outcome `timeout`; the call still runs and its late reply is discarded).
301
+ Rates are percentages in the form; fail + timeout can't exceed 100%.
302
+ **Clear** removes it. The palette's "Chaos: clear all" clears every runner
303
+ in every live session.
304
+ - **Edit props** (island inspector): loads the island's current props as
305
+ JSON (`island.props`), and **Apply** (Ctrl+Enter) sends
306
+ `island.updateProps`. Callbacks show as `"[fn]"`; leaving a placeholder in
307
+ place keeps the original callback wired.
308
+ - **Push / Poll** (island inspector): `island.setMode` switches the
309
+ island's transport mode.
310
+
311
+ Pools and islands created inside a worker (sub-pools, nested islands) are
312
+ not controllable: their handles live in that worker, which has no
313
+ transport. A nested island's DOM still shows up in its top-level island's
314
+ Elements tree.
315
+
316
+ ## Shared-memory watchpoints
317
+
318
+ In **Memory › Values**, pick a field and a rule, then **Add watch**. Rules:
319
+ `change`, `> n`, `< n`, `>= n`, `<= n`, `== v`, `!= v`, where `v` is JSON
320
+ when it parses (`== "down"`, `== true`, `== 3`) and a bare string otherwise.
321
+ Numeric comparisons only match number/bigint fields; list fields support
322
+ `change` only. Each match emits a `memory:watch-hit` event (path, version,
323
+ value preview, rule), listed in the watch-hit log with per-watch hit
324
+ counts. Setting a watch takes the current value as the baseline (not a
325
+ hit). Writes on the main thread are checked synchronously; writes from
326
+ workers are checked when the main thread observes the version bump, so a
327
+ burst between two observations coalesces to the latest value.
328
+
329
+ ## Jank probe and User Timing
330
+
331
+ `connectDevtools({ jank })` (default on) installs a browser main-thread
332
+ probe:
333
+
334
+ - `runtime:longframe`: Long Animation Frame entries where supported
335
+ (`blockingMs` plus the five longest scripts), `longtask` entries
336
+ otherwise. Frames that report no blocking and under 50ms of script time
337
+ are skipped as throttled renders. While the page is hidden, frame length
338
+ is throttling rather than work, so only frames with at least 100ms of
339
+ script (or a `longtask` of 100ms or more) are reported, flagged
340
+ `hidden: true`. Performance draws them gray and counts them as
341
+ "background work"; they stay out of the foreground jank KPIs, and Audits
342
+ reports them under `background-work` instead of `main-jank`.
343
+ - `runtime:frames`: a requestAnimationFrame sampler, one event per second
344
+ with fps and the frames that took over about twice the display interval.
345
+ A gap over a second (hidden tab, debugger pause) restarts the window.
346
+
347
+ The flyout dashboard is a same-origin iframe sharing the app's main thread,
348
+ so its own rendering counts as app jank. Use the full-page dashboard (its
349
+ own tab) when measuring.
350
+
351
+ While devtools is enabled the SDK also writes User Timing measures, so the
352
+ browser's Performance panel shows atoll work next to the app's frames. In
353
+ Chrome they land in an `atoll` track group:
354
+
355
+ | Measure | Where | Track |
356
+ |---|---|---|
357
+ | `atoll task <taskId>` | main thread, dispatch to reply | the runner id (`pool-1`, `search-w`) |
358
+ | `atoll run <taskId>` | inside the worker, task execution | `tasks` |
359
+ | `atoll replay <instance>` | main thread, one island op-batch replay | `islands` |
360
+
361
+ Each measure is cleared from the timeline buffer right after it is
362
+ recorded (the Performance panel has already captured it), so
363
+ `performance.getEntriesByType('measure')` won't accumulate them.
364
+
365
+ ## Message cost
366
+
367
+ `task:dispatch.argBytes`, `task:settle.resultBytes`, and `island:ops.bytes`
368
+ are estimates from `estimateCloneBytes` (strings 2 bytes per char, numbers 8,
369
+ typed arrays and buffers by `byteLength`, plus small per-key overhead; the
370
+ walk stops after 5000 nodes), a "is this message big?" signal, not an exact
371
+ count. `resultBytes` is set only on `ok` settles; errored, timed-out,
372
+ aborted, and crashed calls carry none.
373
+
374
+ ## Recording and replay
375
+
376
+ Every batch the dashboard receives lands in a rolling capture (the last
377
+ ~50,000 events). The toolbar adds:
378
+
379
+ - **● Rec / ■ Stop**: an explicit recording (stops itself at 500,000
380
+ events).
381
+ - **Export**: downloads the recording when there is one, else the rolling
382
+ capture, as `atoll-devtools-<session-or-all>-<yyyymmdd-hhmmss>.json`.
383
+ With a session selected, only that session is exported.
384
+ - **Import**: opens a recording (or drop the file on the window). Replay
385
+ pauses the live transport, resets the dashboard, and re-ingests the file
386
+ through the normal ingest path, so every view rebuilds as it did live. A
387
+ banner offers Play/Pause, speed (1x, 4x, 16x, max), a scrubber (seeking
388
+ rebuilds up to that point), and **Back to live**. Replayed sessions are
389
+ named with a `⏺ ` prefix and are closed, so no control command can fire.
390
+
391
+ The file format is `{ format: 'atoll-devtools-recording', version: 1,
392
+ exportedAt, sessions, frames: [{ t, sessionId, events }] }`, with `t` the
393
+ dashboard's receive clock (replay only uses differences).
394
+
395
+ ## Keyboard, palette, deep links
396
+
397
+ | Key | Action |
398
+ |---|---|
399
+ | Ctrl+K / ⌘K | Command palette: views, subviews, islands, workers, pools, sessions, commands |
400
+ | `g` then a letter | Go to a view: `d` Dashboard, `t` Tasks, `p` Performance, `n` Network, `m` Memory, `r` Reactivity, `a` Audits, `l` Log |
401
+ | `[` / `]` | Previous / next sub-tab |
402
+ | `/` | Focus the current view's search or filter box |
403
+ | `s` | Show / hide the sessions sidebar (full page, aggregate mode) |
404
+ | `?` | Shortcuts and what each view is for |
405
+ | Esc | Close the palette, help, or a menu |
406
+ | Alt+Shift+D | Show / hide the flyout, from the app page or inside the flyout |
407
+
408
+ The address hash tracks where you are:
409
+ `#/<view>[/<sub>][?session=…&island=…&worker=…]`. Share it, reload it, or
410
+ use Back / Forward on the full page; a routed island or worker opens once it
411
+ appears (matching by key in a reloaded session too). Inside the flyout the
412
+ hash is updated with `replaceState` only, so devtools navigation never adds
413
+ entries to the host page's history. The last view is remembered per layout
414
+ (flyout and full page). The flyout's **full page ↗** link opens the same
415
+ route; the palette's "Copy link to this view" copies it. A first-run tip
416
+ points at the palette.
417
+
418
+ ## Chrome DevTools extension
419
+
420
+ The same dashboard also runs as an **atoll** panel inside Chrome DevTools,
421
+ for the inspected tab, with no flyout and no second tab:
422
+
423
+ ```bash
424
+ npm run build:extension # → packages/devtools-extension/dist
425
+ ```
426
+
427
+ Open chrome://extensions, enable Developer mode, choose "Load unpacked" and
428
+ pick `packages/devtools-extension/dist`. The app still needs devtools on
429
+ (`initDevtools()` with `?__atoll_devtools`, or `connectDevtools()`). Frames
430
+ are pushed, not polled: a content script that stays passive until the panel
431
+ attaches joins the page's `atoll-devtools` channel and relays through the
432
+ extension's service worker. Because that script is declared for all URLs,
433
+ Chrome warns "Read and change all your data on all websites" at install;
434
+ tabs opened before install need a reload. Details and limitations are in
435
+ [packages/devtools-extension/README.md](https://github.com/jwhenry3/atolljs/blob/main/packages/devtools-extension/README.md).
436
+
437
+ ## OpenTelemetry export
438
+
439
+ ```ts
440
+ import { exportOtel } from '@atolljs/devtools/otel';
441
+
442
+ // Before any pool spawns, like every devtools sink.
443
+ const otel = exportOtel({ endpoint: 'http://localhost:4318', serviceName: 'my-app' });
444
+ // ...
445
+ await otel.close(); // flushes; never rejects
446
+ ```
447
+
448
+ Task calls, island round trips and fetches become spans; pool, runtime and
449
+ shared-memory activity become cumulative metrics; `log` events and worker
450
+ crashes become log records. It runs alongside `connectDevtools()` or on its
451
+ own. To forward everything the aggregate server receives instead:
452
+
453
+ ```bash
454
+ npx atoll-devtools --otlp http://localhost:4318 --otlp-headers x-api-key=abc --otlp-service atoll
455
+ ```
456
+
457
+ Mapping, clock handling and retry behavior are in
458
+ [docs/devtools.md](https://github.com/jwhenry3/atolljs/blob/main/docs/devtools.md#opentelemetry-export-atolljsdevtoolsotel).
459
+
460
+ ## How events get there
461
+
132
462
  - Worker-side events reach the sink automatically: pools pass a
133
463
  `devtools` flag in INIT, workers forward `ATOLL_DEVTOOLS` messages back
134
464
  over the task channel, and the pool stamps each with
135
- `worker: {poolId, slot}` — so `memory:write`, `net:fetch`, and worker
136
- heap samples attribute to a specific worker. Only active when a sink
137
- was installed before the spawn.
465
+ `worker: {poolId, slot}`, so `memory:write`, `net:fetch`, worker heap
466
+ samples, and worker logs attribute to a specific worker. Only active when
467
+ a sink was installed before the spawn.
138
468
  - `net:fetch` covers `fetch()` on the main thread (via the probe
139
469
  `connectDevtools` installs) and inside workers (probed when forwarding
140
470
  is enabled). XHR is not covered.
141
471
  - `runtime:memory` uses Chrome's `performance.measureUserAgentSpecificMemory`
142
- on the main thread (cluster breakdown — every worker context listed by
472
+ on the main thread (cluster breakdown: every worker context listed by
143
473
  its script URL), `performance.memory` where available, and
144
- `process.memoryUsage()` on Node (heapUsed + RSS — also inside
474
+ `process.memoryUsage()` on Node (heapUsed + RSS, also inside
145
475
  `worker_threads`, so per-worker heap columns populate for Node pools).
476
+ - Commands register lazily, only while devtools is enabled: `pool.*` and
477
+ `worker.kill` with the first runner, `island.*` with the first mounted
478
+ island, `memory.*` with the first contract bind, `reactive.nodes` with the
479
+ first reactive node.
146
480
  - Zero cost when unused: `emitDevtools` is one branch without a sink, and
147
481
  `connectDevtools()` is the only thing that installs one.
148
482
  - Events buffered while the socket connects are capped (50k, oldest
@@ -153,4 +487,7 @@ ATOLL_DEVTOOLS=1 npm run start
153
487
  JSON text frames. Apps → server on `/events`: `{type:'hello', session}`
154
488
  then `{type:'batch', events[]}`. Dashboards → server on `/view`:
155
489
  `{type:'sessions'}` then `{type:'batch', session, events}` fan-out, with a
156
- bounded tail replayed to late joiners. See `src/protocol.ts`.
490
+ bounded tail replayed to late joiners. Commands travel as
491
+ `{type:'control', sessionId, id, cmd, args}` and come back as
492
+ `{type:'control-result', sessionId, id, ok, result | error}`. See
493
+ `src/protocol.ts`.
package/app/api.js ADDED
@@ -0,0 +1,68 @@
1
+ // Panel plugin API for the devtools dashboard.
2
+ //
3
+ // main.js owns the event reducer, the core views, and the transport; every
4
+ // feature panel lives in app/panels/<name>.js, exports `setup(api)`, and is
5
+ // listed in PANELS in main.js. Panels never edit main.js state shapes —
6
+ // they keep their own state, fed by the hooks below.
7
+ //
8
+ // Rendering contract: hooks.render / inspector hooks run on EVERY render()
9
+ // (rAF-batched, many times a second under load). A panel that owns inputs
10
+ // (editors, filters) must not rebuild them each call: build once, then
11
+ // update text/values in place, or re-render only when its own key changes.
12
+
13
+ export const api = {
14
+ // ── hooks (panels push callbacks; main.js calls them) ───────────────────
15
+ hooks: {
16
+ /** (session, event) for every ingested event, after the core reducer. */
17
+ event: [],
18
+ /** (session, events) once per ingested batch — the recorder taps this. */
19
+ batch: [],
20
+ /** (liveSessionIds: Set) after the session list changes. */
21
+ reconcile: [],
22
+ /** () at the end of every render(). */
23
+ render: [],
24
+ /** () once a second (charts that roll with time). */
25
+ tick: [],
26
+ /** () after reset() clears all dashboard state. */
27
+ reset: [],
28
+ /** (islandKey, island, { extra, actions }) while the island inspector shows. */
29
+ inspectIsland: [],
30
+ /** (workerKey, worker, { actions }) while the worker inspector shows. */
31
+ inspectWorker: [],
32
+ },
33
+ onEvent(fn) { this.hooks.event.push(fn); },
34
+ onBatch(fn) { this.hooks.batch.push(fn); },
35
+ onReconcile(fn) { this.hooks.reconcile.push(fn); },
36
+ onRender(fn) { this.hooks.render.push(fn); },
37
+ onTick(fn) { this.hooks.tick.push(fn); },
38
+ onReset(fn) { this.hooks.reset.push(fn); },
39
+ onInspectIsland(fn) { this.hooks.inspectIsland.push(fn); },
40
+ onInspectWorker(fn) { this.hooks.inspectWorker.push(fn); },
41
+
42
+ // ── command palette registry (the palette UI reads this) ────────────────
43
+ /** [{ id, title, group, hint?, keys?, run() }] */
44
+ palette: [],
45
+ /** Add (or replace by id) a palette entry. */
46
+ addPaletteItem(item) {
47
+ const i = this.palette.findIndex((p) => p.id === item.id);
48
+ if (i >= 0) this.palette[i] = item; else this.palette.push(item);
49
+ },
50
+
51
+ // ── filled in by main.js before panels' setup() runs ────────────────────
52
+ // state, $, esc, fmt, fmtBytes, fmtInt, key, sessOf, inSel, withGroups,
53
+ // kpi(value, label, cls?), sparkline, drawLine, drawMultiLine, fwIconImg,
54
+ // render(), scheduleRender(),
55
+ // addView({ id, label, order?, html, badge?, title? }) → section element,
56
+ // addSubview(viewId, { id, label, html, order? }) → div element,
57
+ // openView(viewId), openSub(viewId, subId),
58
+ // control(sessionId, cmd, args?, timeoutMs?) → Promise<result>,
59
+ // hasCommand(sessionId, cmd) → boolean (cached 'devtools.commands'),
60
+ // liveSessions() → SessionInfo[], selectedOrOnlyLive() → sessionId | null,
61
+ // ingest(session, events), reset(), reannounce(), isMini,
62
+ // livePaused (true drops live transport frames: replay sets it),
63
+ // addToolbarButton({ id, label, title?, order?, onClick }),
64
+ // onNavigate(fn) (after openView / subtab changes), setViewIntro(viewId, html),
65
+ // viewIntros, toast(msg, kind?, ms?),
66
+ // transport ('broadcast' | 'extension' | 'ws': 'extension' is the Chrome
67
+ // DevTools panel, broadcast semantics relayed from the inspected page).
68
+ };