Repository navigation
2026 10 10 capture design
Status: DESIGNED 2026-10-10, branch issue-64-capture (#64). Plan:
docs/superpowers/plans/2026-10-10-capture.md. Epic E3 of the programme
2026-10-09-workspaces-and-observability-design.md (cited programme R<n>),
built on E2's monitor (2026-10-09-session-monitor-design.md, cited E2 r<n>).
Penpot file Admin UI: pages Workspace · Console, Workspace · Network,
App · show, and the mode / captured|live|paused components — read, never
edited, by the implementers.
open_session(capture=true): the page's console and traffic, captured by the
session's one BiDi connection, readable by URI, shown in the admin UI, and
pointed at by a tool result when the call caused an error (programme R8–R11,
R16, R17).
Dr K, 2026-10-10, on the shape of it:
- "Can we use only one socket for everything? … make the socket more like an event feed and some dispatcher … if capture is off … we do what we do now and open and close it. If it's on, then we bank on the currently open socket."
- "If we are getting all of it anyway … the admin view [gets] a toggle to show it all … if we 'show it all' we don't necessarily even need to keep it … raw feed sent to the admin view with the SSE … the admin view itself can keep a sane max in the browser."
- "If we need to, we can make a larger refactor to get to an ideal state. I want this code to ultimately work like we had all the requirements up front … Also remember we have profiling to handle the performance."
- "Let's drop 3.10 support … wrap this into E3."
Measured on the live Grid, 2026-10-10 (Grid 4.48.0, Chrome 152, Firefox 155; probes and raw output in the session scratchpad, all 13 sessions ended):
| Chrome | Firefox | |
|---|---|---|
| Console replay to a new subscriber (programme Verify first 4) | every new connection that subscribes log.entryAdded gets the whole buffer, in order: 100 per browsing context, surviving navigation; a second subscribe on the same connection replays nothing |
replayed only to the session's first log subscription, current document only; later subscribers get nothing |
| Events delivered to | the subscribing connection only | every websocket on the session, subscribed or not; subscriptions outlive the socket that made them |
| Entry identity | none: type, level, method, text, args, timestamp(ms), source.context, source.realm, stackTrace; timestamps collide (4 of 6 in one batch) |
same fields |
| Uncaught exception / unhandled rejection |
type: javascript, level: error, text: "Error: …", a stackTrace, no method
|
same |
Data collector (Verify first 5): network.addDataCollector({dataTypes, maxEncodedDataSize, collectorType: "blob"})
|
accepted; maxEncodedDataSize (1–200 000 000) not enforced; collects with no network subscription
|
accepted, enforced (oversized → no such network data); collects only while some network.* subscription is active
|
network.getData |
session-owned: works from any socket, after navigation, after the adding socket closed; {type: string} text, {type: base64} binary, gzip decoded |
same |
request dataType |
yes | yes (≥ 146) |
| Secrets in events |
request.headers (Authorization twice, cookie), request.cookies[].value, goog:postData (the body inline) |
Authorization, Cookie
|
| Navigation marks |
browsingContext.navigationStarted → navigationCommitted → domContentLoaded → load on the same socket, in order with log entries; historyUpdated, fragmentNavigated; contextCreated (with parent) replayed on subscribe |
same |
| iframes | console and network on a session-wide subscription, source.context / context the iframe's |
same |
How others multiplex BiDi (2026-10-10): Puppeteer's BidiConnection and
Playwright's bidiConnection hold one transport per browser, route replies
by id through a callback registry with a per-command timeout, and events by
method to an emitter — Playwright off the receive path
(Promise.resolve().then). Selenium Python's WebSocketConnection is one per
driver, polls for replies, runs callbacks on its socket thread. The known
failure of a shared socket is a slow subscriber on the receive path starving
command replies. BiDi subscriptions filter by event name and by context or user
context only — never by log level or resource type (W3C
session.SubscribeParameters).
Live tails (2026-10-10): Datadog Live Tail streams everything, stores
nothing, samples under load and says so in its docs; the query view reads
stored, indexed data. Chrome DevTools records only while open. Playwright MCP
defaults to no static resources and info+; chrome-devtools-mcp filters on
read, since the last navigation, last three on request.
Toolbars (2026-10-10): DevTools and Firefox put Clear first; counts go in a status line ("24 requests"); nobody shows "0 dropped". Datadog makes Live a mode of the view, not a filter.
Programme R2, R3, R5–R11, R16, R17 and E2 r6–r8, r12, r16 bind this spec. Dr K's are marked; the rest are Claude's, for Dr K on the PR, each with what it costs if wrong.
- Dr K, 2026-10-10: one BiDi connection per session, a dispatcher, and two lifetimes. Everything that speaks BiDi — capture, site data, the monitor — goes through one client. While the monitor holds a connection for a session (capture on), every user of that session shares it; otherwise a user opens one, uses it, and closes it. Selenium's BiDi client leaves our code path.
- Dr K, 2026-10-10: a stored, filtered buffer for agents; a live, unstored
tail for the admin page. The buffer is what
workspace://console|network, the admin's Captured view and the hint read. The tail streams everything, masked, to an admin page that asks for it, and is never kept on the server; the page keeps the last 2 000 rows. - Dr K, 2026-10-10: successful static requests are not kept, only counted. A request whose resource type is stylesheet, image, font, script or media and whose response is 2xx or 3xx is counted per navigation and dropped; every failure is kept. Cost if wrong: an agent cannot list a successful image after the fact (the tail can).
- Claude:
debugconsole entries are not kept, only counted — the same rule as static requests, as the Penpot boards draw (console-liveshows adebugrow the Captured view does not). Cost: one predicate. - Dr K, 2026-10-10: drop Python 3.10; the floor is 3.11, in this PR, with every 3.10 workaround removed.
- Claude: capture subscribes before the first page.
open_session(capture= true)opens the connection, subscribes, and adds the data collector before the browser navigates — Firefox replays nothing to a late subscriber and collects bodies only while subscribed. A reopen after a reap does the same. Cost if wrong: none measured; the alternative loses the first page. - Claude: a connection closes clean. Before closing, it unsubscribes by subscription id and removes the data collectors it added, so Firefox's fan-out never floods another socket and no body outlives its reader.
- Claude: nothing runs on the read loop. The reader parses and routes; each subscriber has its own bounded queue (1 000) drained by its own task, overflow dropped and counted; each command its own timeout. A slow capture can never delay a site-data reply or a spare-tab intercept answer.
- Claude: Chrome's replay is aligned, not deduplicated by key. On a
reconnect the replayed burst (same
source.context) is matched in order against that context's stored tail on(realm, timestamp, type, level, text, first stack frame), and only the unmatched suffix is appended — identical tuples are legitimate, so a set would drop real entries. - Claude: buffer sizes. Per workspace: 1 000 console entries and 1 000
requests (oldest evicted), the last 5 navigations marked; expiring
capture.ttlafter the last entry (default 10 800 s); a request's detail keeps at most 32 headers of 4 kB each. Cost: two constants. - Claude: bodies stay in the browser.
network_body(id)fetches one on demand through the session's connection (network.getData), capped at 100 kB of text (markedtruncated); binary returns its type and size only. The capture index maps our request id to the browser's. A body for a browser that has ended is a 404 naming why. Cost: the browser decides how long a body lives (session-owned, evicted at ~200 MB). - Claude: the hint counts what this call caused. One call at a time per
session (the lock), so a call's start and end bound its events; at the end
one
session.statusround trip on the session's connection lets in-flight events land first. Counted:errorconsole entries and uncaught exceptions; 4xx/5xx and failed document/fetch/xhr requests. Not warnings, not failed images (programme R10). The hint is"page": {"errors": n, "failed_requests": n, "see": "<uri>"}, present only when a count is non-zero; a flow run sums its steps'. - Claude: masking happens at capture, once, for the buffer and the tail.
Header names
authorization,proxy-authorization,cookie,set-cookie(any case) become•••;request.cookies[].valuetoo;goog:postDatais dropped; any value the secrets catalogue bound in this workspace (writewith a secret; carried to the session that replaces one, PR review) is replaced wherever it appears in a URL, header, console text or argument. URLs keep their query string only on the newest navigation, as the history does (AGENTS.md, site data). - Claude: E2's bus stays value-free. The tail is capture's own fan-out,
not a bus event;
call.finished(E2 r8) carries no values and is emitted here, its first consumer being the hint. - Claude: the spare tab is invisible to capture. Site data's spare tab
(
site_data/spare.py) is a context capture ignores, by id, for its whole life; its intercepted requests are neither kept nor tailed. - Claude: performance is measured, not guessed. The plan's last task measures dispatcher latency and queue depth on a real page load with capture on; E4 (metrics, traces) and the cluster's profiler watch it after.
- Claude, PR review: the deadline close counts as Grid activity, so the browser gets one more idle timeout (Verify first 5): bounded, and closing clean beats leaving Firefox's fan-out and the collector behind. Wrong, it costs one idle timeout per pause; revisit with ending at the deadline.
- Claude, PR review: Chrome reports a fetch's failure only once its body is
read (Verify first 2), so such a call has no hint;
references/TROUBLESHOOTING.mdsays where to look instead. - Claude, PR review: a reply waits up to 0.5 s behind a flood, the price of dropping nothing (Verify first 4); E4's metrics watch it.
- Claude, PR review: the admin bundle budget is 50 KB, from 45, for the Console and Network tabs.
An agent driving a workspace with capture on can read what the page logged and fetched since its last navigation, is told when a call it made caused an error, can fetch one response body; an operator sees the same in the admin UI and can watch everything live; and every BiDi conversation with a browser goes through one client.
| Not built | Why |
|---|---|
| Interception, mocking, offline, HAR export, screencast, CDP | programme E3 Out |
| Console or network as a flow condition | programme Next round |
| Keeping captured data across a restart, or in Redis | programme R9 |
| Keeping the live tail on the server | ruling 2 |
| Per-level or per-type subscriptions | BiDi has none (Research) |
| A second replica holding connections | programme R5 |
A new package below every other layer (no protocol imports; tests/test_boundaries.py
gains it). It replaces monitor/bidi.py and Grid.bidi().
-
Connection(asyncio,websockets):open(url),close()(unsubscribe every subscription id it holds, remove every collector it added, then close — ruling 7),command(method, params, timeout) -> dict(id-matched, its own timeout, raisesBidiError(error, message)),subscribe(events, contexts=None) -> subscription id,unsubscribe(id), andlisten(events) -> Subscriber: a bounded queue (1 000, overflow dropped and counted) drained by a task that calls the subscriber's handler. The reader only parses and routes (ruling 8). Unsolicited events no subscriber asked for are discarded (Firefox fan-out).droppedand anon_closecallback as E2's socket had. -
Channel— the synchronous facade worker threads use, bound to a loop:call(method, params, timeout=…)and a context-managedlisten(events, handler). It hands work to the loop (anyio.from_threadorasyncio.run_coroutine_threadsafe) and blocks only the calling worker. -
open_channel(session_id)— a context manager: the monitor's heldConnectionfor that session if there is one (closing the context does not close it), else a new one opened for the block and closed after.
E2's monitor keeps its watches, deadlines and /status liveness, and now holds
a bidi.Connection (not its own socket class) for a watch owed capture.
-
events_for["capture"]=log.entryAdded,network.beforeRequestSent,network.responseCompleted,network.fetchError,browsingContext.contextCreated,browsingContext.contextDestroyed,browsingContext.navigationStarted,browsingContext.navigationCommitted,browsingContext.historyUpdated,browsingContext.fragmentNavigated. -
Synchronous attach:
Monitor.attach(workspace, session_id, reason)is awaited by the open path: open the connection, subscribe, add the data collector (dataTypes: ["response"],maxEncodedDataSize: 10 000 000,collectorType: "blob"), return — before the first navigation (ruling 6). A failure to attach fails the open with a 503 naming the Grid's BiDi route, and quits the browser. - E2's deferred minors this closes: resubscribe when a reason is added; a
cancelled open leaks nothing;
_socket_failedcleared on success; reconnect backs off (1, 2, 4 … 30 s);stop()iterates a copy; a raising monitor never failsend_browser. - At the deadline (programme R3): close clean (ruling 7);
capturinggoes false; the admin shows capture paused. The next call holds it again before it runs. Never under a call:Monitor.calling/calledcount a session's calls in flight (each a touch), the connection is kept while any is, and the deadline counts from the last one's end — a call borrows the connection (its flush, a save's spare tab), and a close mid-call took them with it. A hold a call tried and failed backs off like a look's, so a hung/se/bidicosts one callATTACH_TIMEOUT, not every call. - On reconnect: resubscribe, then Chrome's replay is aligned (ruling 9).
transfer.capture, transfer.restore and spare.spare_tab take a Channel,
not Selenium's driver. Every call becomes a BiDi command:
browsingContext.create|navigate|close, network.addIntercept|provideResponse| removeIntercept, script.evaluate, storage.getCookies|setCookie; the
intercept's network.beforeRequestSent arrives through channel.listen on its
own subscriber (ruling 8). The spare tab's context id is registered with
capture for its life (ruling 15). Behaviour, results and every site-data test
stay as they are; core/actions.py opens open_channel(session_id) where it
opened grid.bidi(session_id).
Below the protocol layers. Capture.ingest(workspace, session_id, method, params) is the monitor's on_bidi.
-
Console entry:
{seq, at, level, type, text, source: {url, line}, context, nav}—textcapped at 4 kB;debugcounted, not kept (ruling 4). -
Request (merged from its events):
{id, seq, at, method, url, status, status_text, type, mime, size, took_ms, failed, error, context, nav, navigation: bool, request_headers, response_headers, headers_dropped}— each side's headers kept while names and values fit 16 kB, a name cut to 256 characters, the rest counted inheaders_dropped;idour own (<nav>.<n>), mapped to the browser's id in the index; resource type from the request'sdestination/initiatorTypeor the MIME type; timings normalised per browser (Chrome relative, Firefox epoch), unknown when two clocks were mixed (below 0 or past an hour); size frombodySize/bytesReceived, nevercontent.size. Successful static counted, not kept (ruling 3). -
Navigation marks from
navigationStarted/Committedof the top-level context; the last 5 kept. -
Masking (ruling 13) by one
Maskerthe buffer and the tail share; the catalogue values bound in the workspace come from the secrets binding, in every spelling a URL, a JSON or an HTML echo gives them, and carry to the session that replaces one. A session still held is never evicted; an idle one evicted is parked, secrets and all, and a hold again takes it back. -
Store: per workspace, a
cachetools.TTLCache-backed holder of twocollections.deque(maxlen=1000), counts, marks and the index; TTL slides on every entry. An explicitopen_sessionwithout capture discards the workspace's buffers (programme R9). -
Tail:
Capture.tail(workspace) -> TailSubscription— exists only while a page listens; bounded per-listener queue (500), overflow counted and reported in the stream as{"dropped": n}. -
The call window:
Capture.mark(workspace) -> seqat a call's start;Capture.caused(workspace, since_seq) -> {errors, failed_requests}at its end, after thesession.statusround trip (ruling 12).
-
"capture": (None, None, _as_flag)besiderecordinworkspace/settings.py; replayed on a silent reopen after a reap; never inherited by an explicitopen_session; off by default (programme R8). -
capture.ttl(seconds, default 10 800) — a new config sectioncapture(a valid section name: no underscore, no env collision). -
open_session(capture=true)on MCP,POST /browser {"capture": true}over HTTP: the open path callsMonitor.attach(..., "capture")before navigating.
At the recipe's host — Workspaces.act, a flow step, the bound write — E2 r8's
seam: Monitor.touch(session_id), call.finished published (values-free:
workspace, tool, surface, outcome, browser, ms), and, with capture on, the
hint added to the result (ruling 12). Both surfaces return it; an HTTP result
carries the same page object.
| URI | What |
|---|---|
workspace://console |
entries since the last navigation; ?level=error|warn|all (default all kept), ?pages=n (1–5) |
workspace://network |
requests since the last navigation, newest first; ?filter=failed|xhr|all (default all kept), ?pages=n; counts of what was not kept |
workspace://network/{id} |
one request in detail, headers masked |
- With capture off, each answers
{"capture": false, "how": "open_session(capture=true)"}— not an error. -
network_body(id)— a capability row incore/capabilities.py: tool andGET /browser/network/{id}/body; read-only (reads()annotation — it writes nothing); MCP visibility["model", "app"]so the request view's Fetch body can call it; ruling 11's caps; 404 when the browser is gone, 400 for an unknown id. A binary body is answered from its row (type and size), never asked; a text body its row says is over 8 MB is refused (400) unasked; the rest is asked on a connection of its own (open_channel(private=True)), because Chrome answersnetwork.getDatadecoded in one frame, and a frame past the 16 MiB limit closes its socket — that one, a 400, never the one capture holds. The text is masked before its cut. -
workspace://currentgainscaptureandcapturing(programme R16). - Every text an agent reads names these by URI (AGENTS.md). Skill: a routing
row and
references/TROUBLESHOOTING.mdgains "a call failed and the page said why" (read the hint, then the console).
Rows for console, network and network/{id} (component console,
network, request), per Penpot App · show: the context view draws
capture/capturing and its Console and Network chips drill in; the request
view's Fetch body calls network_body through callServerTool. The inventory
guard (E7) covers them; MAX_SHOWN applies.
Per Penpot Workspace · Console and Workspace · Network:
-
Toolbar, identical on both tabs, left to right: Clear · filter chips
(Console: All, Errors, Warnings; Network: All, XHR/fetch, Failed) · filter box
(
Filter messages/Filter URLs) · This page ▾ (Captured only) · … · Pause/Resume (Live only) · the Captured | Live switch (mode / captured|live|paused). -
Captured reads the buffer through the admin API
(
GET /admin/workspaces/{key}/console|network, the same shapes as the resources). Footer:5 requests · 19 static not kept · workspace://network. -
Live opens
GET /admin/workspaces/{key}/tail?kind=console|network(SSE, admin door, signed like/admin/events), keeps the last 2 000 rows in the page, Pause holds rendering and counts new rows. Footer:Live · 1,204 requests,Paused · 1,204 requests · 37 new,12 droppedin amber only when non-zero. - The tabs are usable only with capture on (
capture-offboard); the pills ● REC, ● capture, capture paused on the card and summary; the summary's CAPTURE row.
requires-python = ">=3.11", classifiers from 3.11; test.yml PR legs 3.11 and
3.14, the sweep 3.11 → 3.14; publish.yml comments; the tomli fallbacks gone
(pyproject.toml test extra, Dockerfile, tests/test_packaging.py,
tests/test_prompts.py, tests/test_skill.py); tests/fakes.py patch_os's
3.10 accessor branch gone; .github/instructions/python.instructions.md,
.github/copilot-instructions.md, AGENTS.md and CONTRIBUTING.md say 3.11, and
"advisory" moves to Test (3.11). 3.11 features become allowed (tomllib,
except*, TaskGroup, typing.Self); nothing is rewritten to use them.
AGENTS.md: "WebDriver BiDi … used only for site data" becomes the one-client
rule (rulings 1, 7, 8); the capture section (rulings 2–4, 12–15); the 3.11
floor. README within its budget, CHANGELOG [Unreleased] (capture; network_body;
3.11 floor as BREAKING), wiki regenerated plus notes/network_body.notes.md,
skill as §7.
-
bidi/: against a local websockets server (E2's harness): routing, timeouts, per-subscriber queues (a blocked subscriber does not delay a reply), clean close, unsolicited events discarded. -
site_data/: the existing suite, against a fakeChannelthat records commands; one integration flow is not added (AGENTS "less is more"). -
capture/: merging, masking (every rule in 13), static/debug counting, alignment (Chrome replay fixtures from the probes), TTL, the hint window. - Surfaces:
test_surfaces.pyparity fornetwork_body; resources;showinventory; admin API and tail (auth, signing, bounded queue). - Live verification (plan's last tasks): on the Grid, Chrome and Firefox, capture on: a page load, console errors, a failed fetch, a body, save and restore site data over the shared connection, a reconnect; then dispatcher latency and queue depth measured (ruling 16).
- Measured 2026-10-10: programme Verify first 4 and 5 — see Research.
-
Unverified:
session.statusas a flush — events emitted before the reply arrive before it on one connection. The plan's live task checks it; if not, the hint waits a fixed 50 ms after the round trip. -
Unverified: Firefox delivering a spare-tab intercept event to the held
connection and to a scoped
listenalike, with capture ignoring it.
Live results, 2026-10-10 (plan Task 13), against the cluster Grid: hub and
nodes 4.48.0 (revision 27f5213), KEDA from zero, sessionTimeout 300 s;
Chrome 152.0.7977.82, Firefox 155.0.1; this branch as built by its plan
tasks, before the final review's fixes, on Python 3.13.5, websockets 17.2,
selenium 4.51.0, fastmcp 4.1.0. The server ran from the worktree with a
throwaway token; the in-process probes held a real
bidi.Connection and Capture exactly as Monitor._hold does. No CDP. The
scripts are throwaway and not in the repo. Every session was ended: the Grid
listed 0 sessions before (nodes at zero) and 0 after; 26 were opened,
each ended in a finally by the server's DELETE /browser or the Grid's own
DELETE, and none needed the fallback.
-
session.statusas a flush — measured, it is one;FLUSH_GRACEstays 0.script.evaluateof 1 000console.logs, then at onceflush(): on 20 of 20 runs per browser (each browser twice) all 1 000 were inCapturewhen it returned. At the evaluate's own reply only 76–1 000 (Chrome) and 186–998 (Firefox) had landed, so the round trip is what lets them in; a flush took 2.6 ms (Chrome) and 3.8 ms (Firefox) at the median, 23 ms at most. What it cannot do is wait for an event the browser has not sent: Chrome sends a request'sresponseCompletedonly once its body is read, so a script that awaitsfetch()of a 404 and not its body ends before the 404 exists — not in the hint on 10 of 10 runs, in it on 10 of 10 once the script read the body; Firefox counted both 10 of 10. The flush is right; the call window is what the browser has said by its end. -
The spare tab on the held connection — measured, both hear it and capture ignores it, on both browsers. A site-data save's spare tab run through the capture's own connection: the intercept's
network.beforeRequestSent(isBlocked: true,…/__selenium-flow-spare__) reached both the capture subscriber and the spare tab's scoped listener (Firefox's scoped listener also heard the tab's ownfavicon.ico);Capture.countsand the request total did not move (6 spare-tab events on Chrome, 7 on Firefox, all ignored by context). Over HTTP,POST /browser/save-site-datawith a second origin in the history left no__selenium-flow-spare__row and the counts unchanged; afterwards a navigation to<origin>/__selenium-flow-spare__loaded the site's own 404 in 0.07 s, so no intercept was left behind. -
Dispatcher latency and queue depth (ruling 16) — measured. One held connection, an HTML page on this server, six floods per browser of 5 000
console.logand 200 same-originfetches (≈ 5 400 events), during each 20session.statusround trips and one site-data save; idle figures from the same connection just before. Per flood, across the six, before the fix:Chrome Firefox Flood, page side 1.6–1.8 s 0.8–0.9 s Capture lag p50 / p95 / max 2.1–3.9 / 11–25 / 14–26 ms 3.8–8.8 / 13–40 / 16–40 ms Queue peak(limit 1 000)472–1 000 692–1 000 dropped0 in 5 runs; 740 in 1 (and 474 in 1 of 2 earlier runs) 0 in 3 runs; 28, 637, 990 in 3 session.statusRTT p50 / max, idle0.7–1.7 / 1.3–2.9 ms 0.7–2.1 / 1.4–42 ms session.statusRTT p50 / max, flood1.4–30 / 48–146 ms 1.7–2.7 / 88–127 ms Site-data save, idle → flood 0.06–0.12 → 1.6–1.8 s 0.21–0.38 → 0.49–0.68 s Replies were never queued behind capture: the RTT tracks the browser's own load, and the save during a flood most likely waits on the page's busy main thread for its classic
execute_script(not isolated here). But the capture queue itself overflowed. Handling an event costs well under 0.1 ms, so the live pass's hypothesis was that the queue fills because the reader routes a whole buffered burst before the subscriber's task gets a turn, not because capture is slow. The final review confirmed it on a loopback probe (websocketsreads a buffered burst without suspending, while the subscriber handled one event per turn): of 5 000 frames, 3 998 were dropped as built and 0 with both fixes below, at 5 000 and 20 000. A droppedresponseCompletedleaves a request without its end.After the fix, 2026-10-10 (the reader yields every 64 frames, each subscriber drains a batch of up to 5 ms per turn, and the fell-behind warning re-arms only once its queue has drained to empty): the same six floods per browser, same Grid, browsers and page, with each flood's 200 fetches'
took_msadded (D1's fix). Both sessions were quit in afinally; the Grid listed 0 sessions before and after.Chrome Firefox Flood, page side 1.5–2.1 s 0.8–1.3 s Capture lag p50 / p95 / max 0.1–0.3 / 0.5–1.6 / 1.2–4.9 ms 0.2–0.4 / 0.5–1.7 / 1.0–11 ms Queue peak(limit 1 000)55–64 64 dropped0 in 6 of 6 0 in 6 of 6 "fell behind" warnings 0 0 session.statusRTT p50 / max, idle0.9–2.2 / 1.9–3.4 ms 1.6–3.4 / 1.9–44 ms session.statusRTT p50 / max, flood1.1–53 / 81–513 ms 1.5–17 / 83–323 ms Site-data save, idle → flood 0.06–0.10 → 1.5–2.1 s 0.21–0.38 → 0.52–1.04 s The fetches' took_msp50 / max111–209 / 199–389 ms, none 0 or unknown 35–188 / 65–209 ms Every event of every flood was handled. The lag is now short because the reader no longer reads ahead of the subscriber: the wait has moved into the socket, and a reply sent behind thousands of events waits until they are handled, so the RTT's maximum during a flood rose (Chrome 513 ms against 146 before; Firefox 323 against 127), well inside the 5 s reply timeout. For E4 this says what to watch:
droppedandpeakper subscriber, and the RTT under load. Firefox'stook_msis now network time, as DevTools shows it, rather than the event stamps' 128–147 ms, which included delivering the body. -
Live pass — over HTTP and the admin API, per browser, page
https://example.com/:-
Open:
POST /browser {"capture": true}answeredcapture: true;GET /browsercapturing: true(both). -
The hint: the script call (
console.error, an uncaught throw, a 404fetch, afetchto port 9) returnedpage: {errors: 3, failed_requests: 1, see: workspace://console?level=error}on Chrome (3 of 3 runs) and Firefox (2 of 3; the third hadfailed_requests: 0); the quietextract1 s later had nopage. The three errors areboom, the throw and the unhandled rejection. Chrome's one failed request is port 9 (net::ERR_UNSAFE_PORT) — its 404 lands after the call (item 2); Firefox sends no network event at all for port 9, so its one is the 404, counted only when it beats the call's end. -
Captured view:
console?level=error3 entries,network?filter=failedthe failures, newest first,Authorizationshown•••and an ordinary header as sent. -
Body:
POST /browser/network-bodyof the 404: text, 577 characters,truncated: false, on both (the row'ssizeis the encoded 334–475 bytes). - Save and no intercept left: as item 3.
-
Live tail:
tail_url&kind=consolestreamed the script's three error rows within the 5 s window; nodropped(both). -
Capture off: after
DELETE /browserand an open without capture, the console answered{"capture": false, "how": "open_session(capture=true)"}; an open with capture after it found 0 entries, so the buffer had gone. -
Admin page vs the Penpot boards (Firefox's data, viewed in a Chrome
browser driven over this server's own tools; Console, Network, the
request detail with Fetch body, Live, Pause and capture off). Same
layout, toolbar, chips with counts, This page, Captured/Live switch,
Pause/Resume, the Live · n and Paused · n · k new footers, the CAPTURE
row and the pills. Differences: a console row's source is the full URL and
line (
https://example.com/:2), the boards a file name (app.js:142); with capture off the boards grey out Console and Network behind a tooltip, the page keeps both tabs clickable and says so in the pane; the pills read live · capture, the boards REC · capture · live; no initiator, during, page or request-body rows (plan ruling 9). Not compared: Network in Live mode and the cleared boards.
Also measured: the deadline close is activity. A session with only a held connection (subscribed, one collector), closed clean at 240 s — the monitor's deadline close,
session.unsubscribeandnetwork.removeDataCollector— was reaped 306 s after the close on both browsers (Chrome 545.8 s, Firefox 547.5 s after opening), not at ~330 s. So closing at the deadline gives the browser one more idle timeout.Open, for the controller (found here; the first three, and the admin's console source and muted tabs, were fixed in the final review's wave):
-
Chrome's
took_msis always 0. Chrome stamps every network event of a request with the request's start (timestampequalsrequest.timings.timeOrigin; 200 of 200 requests), so the duration taken from the events' own timestamps (plan ruling 14) is 0 for every Chrome request. Firefox's are right (12–147 ms). Plan ruling 14's fallback — a duration fromtimings, relative ms on Chrome, epoch ms on Firefox — applies. Fixed:timeOrigin + responseEndlesstimeOrigin + requestTimeon both browsers, the event stamps only without aresponseEnd, and unknown rather than a false 0. - Drops under a burst (item 4): the capture subscriber's queue of 1 000 overflows on a page logging thousands of entries at once. Fixed; see item 4's second table.
- The fell-behind warning repeats. It is meant to be logged once per streak, but the streak ends at the next event accepted, so a saturated queue logged it 114 (Chrome) and 288 (Firefox) times in six floods. Fixed: a streak ends only once the queue has drained to empty.
-
Ending a captured Firefox logs a failed hold. Firefox closes the held
socket when its session is deleted, so the monitor tries to hold again
before the end reaches it and logs
no BiDi connection … trying again(6 of 6 captured Firefox ends; never on Chrome, whose socket stays open). Nothing leaks: the end drops the watch a moment later.
Also changed in the final review's wave (the controller's rulings, for Dr K; §2, §4 and §7 above now describe the design after them, and after the PR review's):
- The first call after the deadline closed the connection holds it again before it runs, so the call that comes back from a pause is captured.
- Each side of a request keeps headers only while their names and values
fit 16 kB, names cut to 256 characters; later headers are dropped and
counted in the request's
headers_dropped. 32 of 4 kB a side, 1 000 rows a workspace, was a bound a hostile page could make half a gigabyte. -
network_bodymasks the session's bound secrets in the text before its cut; answers a binary body from its row's type and size without asking the browser; and refuses (400) a text body its row says is over 8 MB, because Chrome answersnetwork.getDatain one frame and one past the connection's 16 MiB limit closes the connection capture holds.
-
Open:
- Ending a watched browser at its deadline ourselves (programme R3).
- A flow condition on console or network.
- Request bodies (
dataTypes: ["request"]) — both browsers support them. - HAR export of the buffer.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network