
Browser Trace
- 77 installs
- 459 repo stars
- Updated July 31, 2026
- mxyhi/ok-skills
This is a copy of browser-trace by browserbase - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
browser-trace is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- browser-trace
- AI & Agent Building
- AI-coding skill
Browser Trace by the numbers
- 77 all-time installs (skills.sh)
- +5 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mxyhi/ok-skills --skill browser-traceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 459 |
| Last updated | July 31, 2026 |
| Repository | mxyhi/ok-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Browser Trace
Attach a second, read-only CDP client to a browser session that is already being driven by your main automation. The trace records the full DevTools firehose to NDJSON, polls for screenshots and DOM dumps in parallel, and slices everything into a directory tree that bash tools can search.
This skill does not drive pages — it only listens. Pair it with the browser skill, browse, Stagehand, Playwright, or anything else that speaks CDP.
When to use
- The user wants to debug a browser-automation run (failing form, missing element, hung navigation, JS exception).
- The user has a running automation and wants to attach a trace mid-flight without restarting it.
- The user wants to split a CDP firehose into network / console / DOM / page buckets.
- The user wants screenshots + DOM snapshots over time, joined to CDP events by timestamp.
If the user just wants to drive the browser, use the browser skill instead.
Setup check
node --version # require Node 18+
which browse || npm install -g browse
which jq || true # optional — used only for ad-hoc queryingVerify browse cdp exists:
browse --help | grep -q "^\s*cdp " || echo "browse cdp not available — update browse"How it works
Every Chrome DevTools target accepts multiple concurrent CDP clients. Your main automation is one client; this skill adds a second one that only enables observation domains (Network, Console, Runtime, Log, Page) and never sends action commands.
The tracer has three pieces:
1. Firehose: browse cdp <target> streams every CDP event as one JSON object per line to cdp/raw.ndjson. 2. Sampler: a polling loop calls browse screenshot --cdp <target> --path <file> and browse get html body --cdp <target> on an interval (default 2s). The helper passes --cdp when it samples so it can attach to the traced target from its own process; once a browse daemon session is attached to a CDP target, follow-up commands in that session do not need to repeat --cdp. 3. Bisector: after the run, bisect-cdp.mjs walks raw.ndjson once, slices it into per-bucket JSONL files keyed by CDP method, and additionally bisects per page using top-level Page.frameNavigated events as boundaries.
Quickstart
Local Chrome
# 1. Launch Chrome with a debugger port (any user-data-dir keeps it isolated).
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-o11y \
about:blank &
# 2. Start the tracer.
node scripts/start-capture.mjs 9222 my-run
# 3. Run your main automation against port 9222.
browse open https://example.com --cdp 9222
# ...whatever the run does...
# 4. Stop and bisect.
node scripts/stop-capture.mjs my-run
node scripts/bisect-cdp.mjs my-runBrowserbase remote
Two helpers wrap the platform-side bookkeeping: bb-capture.mjs creates or attaches to a session and starts the tracer; bb-finalize.mjs pulls platform artifacts (final session metadata, server logs, downloads) into the run dir at the end.
Browserbase ends a session as soon as its last CDP client disconnects. Create with `--keep-alive`, then attach automation to the session's `connectUrl` before or together with the tracer. bb-capture.mjs --new handles the keep-alive session and tracer setup; your automation still needs to attach.export BROWSERBASE_API_KEY=...
# 1. Create a keep-alive session AND start the tracer in one step.
# Prints the session id, connectUrl prefix, and a live debugger URL you
# can open in a browser to watch the run interactively.
node scripts/bb-capture.mjs --new my-run
# 2. Drive automation. bb-capture stamped the session id into the manifest.
SID=$(jq -r .browserbase.session_id .o11y/my-run/manifest.json)
CONNECT_URL="$(browse cloud sessions get "$SID" | jq -r .connectUrl)"
BROWSE_NAME=my-run-browser
browse open https://example.com --cdp "$CONNECT_URL" --session "$BROWSE_NAME"
browse open https://news.ycombinator.com --session "$BROWSE_NAME"
# 3. Stop the tracer, bisect, then pull platform artifacts and release.
node scripts/stop-capture.mjs my-run
node scripts/bisect-cdp.mjs my-run
node scripts/bb-finalize.mjs my-run --releaseAttaching to a session that's already running (e.g. one your production worker created) — bb-capture.mjs accepts a session id instead of --new:
# Pick a running session (filter client-side; browse cloud sessions list has no --status flag)
browse cloud sessions list | jq -r '.[] | select(.status == "RUNNING") | .id'
node scripts/bb-capture.mjs <session-id> mid-flight-debug
# ...tracer runs alongside the existing automation client; no disruption...
node scripts/stop-capture.mjs mid-flight-debug
node scripts/bisect-cdp.mjs mid-flight-debug
node scripts/bb-finalize.mjs mid-flight-debug # without --release: leave the session runningWhat you get from the Browserbase platform
bb-capture.mjs adds a browserbase block to manifest.json (session id, project, region, started_at, expires_at, debugger URL). bb-finalize.mjs writes:
<run>/browserbase/session.json— finalbrowse cloud sessions getsnapshot (proxyBytes, status, ended_at, viewport, …)<run>/browserbase/logs.json—browse cloud sessions logsoutput. Often empty. The CDP firehose incdp/raw.ndjsonis the source of truth; this is a side channel.<run>/browserbase/downloads.zip— files the session downloaded, if any (the script discards the empty 22-byte zip you get when there are none)
Session replay artifact fetching is deprecated and isn't fetched. Use the screenshots + DOM dumps in screenshots/ and dom/ for visual ground truth.
The live debugger_url in the manifest opens an interactive Chrome DevTools view served by Browserbase — handy for watching a long-running automation while the tracer captures the firehose to disk.
Filesystem layout
.o11y/<run-id>/
manifest.json run metadata: target, domains, started_at, stopped_at
index.jsonl one line per sample: {ts, screenshot, dom, url}
cdp/
raw.ndjson full CDP firehose (one JSON object per line)
summary.json {sessionId, duration, totalEvents, pages[]} — see shape below
network/{requests,responses,finished,failed,websocket}.jsonl session-wide buckets (always written)
console/{logs,exceptions}.jsonl
runtime/all.jsonl
log/entries.jsonl
page/{navigations,lifecycle,frames,dialogs,all}.jsonl
dom/all.jsonl (only if O11Y_DOMAINS includes DOM)
target/{attached,detached}.jsonl
pages/ per-page slices, indexed by top-level frameNavigated boundaries
000/ first concrete page
url.txt the URL for this page
summary.json this page's domains/network/timing block (same shape as a pages[] entry)
raw.jsonl firehose scoped to this page
network/, console/, page/, runtime/, log/, target/, dom/ same buckets, only non-empty files
screenshots/<iso-ts>.png one PNG per sample interval
dom/<iso-ts>.html one HTML dump per sample interval
browserbase/ added by bb-finalize.mjs (Browserbase runs only)
session.json final `browse cloud sessions get` snapshot (proxyBytes, status, ended_at, …)
logs.json `browse cloud sessions logs` output (often [])
downloads.zip `browse cloud sessions downloads get` output (only if the session downloaded files)When a run was started via bb-capture.mjs, manifest.json also carries a top-level browserbase block: session_id, project_id, region, started_at, expires_at, keep_alive, debugger_url.
Summary shape
cdp/summary.json is the entry point for any analysis: it has session-level totals and a pages[] array indexed by top-level Page.frameNavigated. Per-page entries are emitted in navigation order (page 0 = first concrete URL).
{
"sessionId": "45f28023-…",
"duration": { "startMs": 1777312533000, "endMs": 1777312609000, "totalMs": 76000 },
"totalEvents": 420,
"pages": [
{
"pageId": 0,
"url": "https://example.com/",
"startMs": 1777312533000, "endMs": 1777312538886, "durationMs": 5886,
"eventCount": 60,
"domains": {
"Network": { "count": 18, "errors": 1 },
"Console": { "count": 2 },
"Page": { "count": 24 },
"Runtime": { "count": 13 }
},
"network": { "requests": 4, "failed": 1, "byType": { "Document": 2, "Script": 1, "Other": 1 } }
}
]
}startMs / endMs / durationMs are wall-clock ms, derived from manifest.started_at plus the offset of each event's CDP monotonic timestamp. domains[*] only includes errors/warnings keys when non-zero.
Drilling in with query.mjs
For interactive exploration, use scripts/query.mjs <run-id> <command> instead of remembering paths:
node scripts/query.mjs my-run list # one-line table of pages
node scripts/query.mjs my-run page 1 # full summary for page 1
node scripts/query.mjs my-run page 1 network/failed # cat failed.jsonl for page 1
node scripts/query.mjs my-run errors # all errors across pages, attributed by pid
node scripts/query.mjs my-run errors 2 # errors from page 2 only
node scripts/query.mjs my-run hosts # top hosts by request count
node scripts/query.mjs my-run host api.example.com # all requests/responses for a host
node scripts/query.mjs my-run summary # full summary.jsonBehind the scenes it just reads cdp/summary.json and the cdp/pages/<pid>/ tree — feel free to bypass it with raw jq/rg once you know the shape.
Top traversal recipes
# All failed network requests (use jq -c to keep it line-delimited)
jq -c '.params' .o11y/<run>/cdp/network/failed.jsonl
# Find requests to a specific host
jq -c 'select(.params.request.url | test("api\\.example\\.com"))' \
.o11y/<run>/cdp/network/requests.jsonl
# 4xx/5xx responses
jq -c 'select(.params.response.status >= 400)
| {status: .params.response.status, url: .params.response.url}' \
.o11y/<run>/cdp/network/responses.jsonl
# Console errors only
jq -c 'select(.params.type == "error")' .o11y/<run>/cdp/console/logs.jsonl
# Sequence of URLs visited
jq -r '.params.frame.url' .o11y/<run>/cdp/page/navigations.jsonl
# Find the screenshot taken closest to a timestamp (e.g., when an exception fired)
ls .o11y/<run>/screenshots/ | sort | awk -v t=20260427T1714123NZ '
$0 >= t { print; exit }'See REFERENCE.md for the full jq recipe library and a method-by-method bisect map. See EXAMPLES.md for end-to-end debug scenarios.
Best practices
1. Use `bb-capture.mjs` on Browserbase: it enforces --keep-alive, fetches the connectUrl, captures the debugger URL, and stamps the manifest. Doing it manually invites mistakes. 2. Don't `--release` a session you don't own: bb-finalize.mjs --release is for sessions you created with --new. When attaching to a production session via bb-capture.mjs <session-id>, run bb-finalize.mjs without --release so the original automation keeps running. 3. Order matters for remote: on Browserbase, attach the main automation client before (or together with) the tracer, and create the session with --keep-alive. Otherwise the session ends as soon as the tracer's WS closes. 4. Don't poll faster than ~1s: each sample runs browser CLI read commands and screenshots Chrome. 2s is a good default. 5. Pick domains deliberately: defaults (Network Console Runtime Log Page) cover most debugging. Add DOM for DOM-tree mutations (very noisy) via O11Y_DOMAINS="$O11Y_DOMAINS DOM". 6. Reuse one Browserbase session for the automation client on remote by attaching to that session's connectUrl with browse open ... --cdp "$CONNECT_URL" --session <name>. The --session flag names the local browse daemon; it is not a Browserbase session attach flag. 7. Always run `stop-capture.mjs`, even after a crash, so background processes don't linger and the manifest gets stopped_at. 8. Bisect once per run: bisect-cdp.mjs is idempotent — it overwrites the per-bucket files from raw.ndjson each time.
Troubleshooting
- `browse cdp exited immediately`: usually means the target is unreachable (wrong port) or the Browserbase session has already ended. For remote, verify with
browse cloud sessions get <id>— ifstatusisCOMPLETED, recreate with--keep-aliveand attach automation first. - Empty `raw.ndjson` even though processes are running: confirm a CDP client is actually driving the page. The tracer only emits events that the browser generates, so an idle browser produces ~5 lines of attach/discover messages and nothing else.
- Screenshots all look identical: check
index.jsonl— ifurldoesn't change, the page hasn't navigated yet. The polling loop runs independently of the main automation's pace. - Browserbase session ends mid-run: it likely hit
--timeout. Recreate with a higher timeout (BB_SESSION_TIMEOUT=1800 node scripts/bb-capture.mjs --new ...) or remove the timeout flag. - `bb-capture.mjs <id>` says "not RUNNING": the session you tried to attach to ended. List candidates with
browse cloud sessions list | jq '.[] | select(.status == "RUNNING")'and try again. - `browserbase/logs.json` is empty `[]`: expected —
browse cloud sessions logsis sparse in practice. The CDP firehose incdp/raw.ndjsonis the source of truth. - Where's the session recording (rrweb)?: session replay artifact fetching is deprecated; this skill doesn't fetch it. Use the screenshot stream in
screenshots/and DOM dumps indom/.
For full reference, see REFERENCE.md. For example debug runs, see EXAMPLES.md.
Browser Trace — Examples
Five end-to-end debug scenarios. Each one shows: setup, running the capture, and the queries you'd run on the resulting tree.
The recipes below use raw jq on the bisected files so you can see exactly what's there. Most everyday drill-down can also be done through scripts/query.mjs <run-id> <command> — see SKILL.md.
Example 1: A form submit failed — find the request and see the page state
User says: "The signup form submit isn't working. I clicked Submit and nothing happened."
# Launch debuggable Chrome and start the tracer.
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-o11y about:blank &
node scripts/start-capture.mjs 9222 form-bug
# Reproduce the bug.
browse open https://example.com/signup --cdp 9222
browse fill 'input[name=email]' 'user@example.com'
browse fill 'input[name=password]' 'hunter2'
browse snapshot
browse click @0-7 # Submit button ref from `browse snapshot`
node scripts/stop-capture.mjs form-bug
node scripts/bisect-cdp.mjs form-bugThen the agent inspects:
cd .o11y/form-bug
# Did the POST go out?
jq -c 'select(.params.request.method == "POST")
| {url: .params.request.url, body: .params.request.postData}' \
cdp/network/requests.jsonl
# Did it 4xx/5xx?
jq -c 'select(.params.response.status >= 400)
| {status: .params.response.status, url: .params.response.url}' \
cdp/network/responses.jsonl
# Any console error around that time?
jq -c 'select(.params.type == "error")' cdp/console/logs.jsonl
# Was a JS exception thrown when you clicked?
jq -c '.params.exceptionDetails
| {text, url, line: .lineNumber}' cdp/console/exceptions.jsonl
# Open the DOM dump captured right after the click
ls dom/ | tail -3If the POST is missing entirely, the click handler is broken — open dom/<latest>.html and look at the button. If the POST returned 4xx, look at the body in network/responses.jsonl. If an exception fired, the stack frame in console/exceptions.jsonl points at the file and line.
Example 2: Audit every 4xx/5xx and every third-party request in a run
User says: "Are we leaking any data to third parties on this page? Show me every cross-origin request."
node scripts/start-capture.mjs 9222 audit
browse open https://your-site.example --cdp 9222
# ...interact with the page...
node scripts/stop-capture.mjs audit
node scripts/bisect-cdp.mjs auditQueries:
cd .o11y/audit
# Top hosts and counts
jq -r '.params.request.url' cdp/network/requests.jsonl \
| awk -F/ '{print $3}' | sort | uniq -c | sort -rn
# Everything not on your-site.example
jq -r 'select(.params.request.url | test("your-site\\.example") | not)
| .params.request.url' cdp/network/requests.jsonl | sort -u
# All non-2xx responses with their initiator
jq -c 'select(.params.response.status >= 400 and .params.response.status < 600)
| {status: .params.response.status,
url: .params.response.url,
mime: .params.response.mimeType}' cdp/network/responses.jsonlExample 3: Find where the page got stuck
User says: "The page hangs after I click Continue. It just sits there."
node scripts/start-capture.mjs 9222 hang
browse open https://example.com/checkout --cdp 9222
browse click @0-12 # Continue button
sleep 30 # let the hang play out
node scripts/stop-capture.mjs hang
node scripts/bisect-cdp.mjs hangQueries:
cd .o11y/hang
# Last navigation that completed
jq -r '.params.frame.url' cdp/page/navigations.jsonl | tail
# Pending requests: requestWillBeSent without a corresponding loadingFinished/Failed
jq -s '
([.[0][].params.requestId] - [.[1][].params.requestId] - [.[2][].params.requestId]) as $pending |
.[0] | map(select(.params.requestId | IN($pending[]))) | map(.params.request.url)
' cdp/network/requests.jsonl cdp/network/finished.jsonl cdp/network/failed.jsonl
# Any JS dialog blocking?
cat cdp/page/dialogs.jsonl
# Look at the last screenshot to see what the user is staring at
ls screenshots/ | tail -1The pending-requests query is the smoking gun: if a fetch never finishes, the page is waiting on it.
Example 4: Reproduce a JS exception from production and locate the source
User says: "Production logs say TypeError: Cannot read properties of undefined (reading 'foo') on /dashboard. I can't reproduce locally."
# Use Browserbase remote so the run uses the same Browserbase Identity / Verified browser setup as prod.
export BROWSERBASE_API_KEY=...
SESSION=$(browse cloud sessions create --keep-alive --timeout 600)
SID=$(echo "$SESSION" | jq -r .id)
URL=$(echo "$SESSION" | jq -r .connectUrl)
BROWSE_NAME=prod-repro-browser
browse open https://app.example.com/dashboard --cdp "$URL" --session "$BROWSE_NAME"
node scripts/start-capture.mjs "$URL" prod-repro
# Drive whatever flow is suspected. The daemon caches the remote target,
# so subsequent commands only need --session to pick the right daemon.
browse click @0-5 --session "$BROWSE_NAME"
browse type 'search query' --session "$BROWSE_NAME"
browse press Enter --session "$BROWSE_NAME"
sleep 5
node scripts/stop-capture.mjs prod-repro
node scripts/bisect-cdp.mjs prod-repro
browse cloud sessions update "$SID" --status REQUEST_RELEASEQueries:
cd .o11y/prod-repro
# Any matching exceptions?
jq -c '.params.exceptionDetails | select(.text | test("Cannot read properties of undefined"))
| {text, url, line: .lineNumber, col: .columnNumber, stack: .stackTrace.callFrames[0:5]}' \
cdp/console/exceptions.jsonl
# Get the Runtime.exceptionThrown timestamp and find the screenshot/dom right before it
EVT_MS=$(jq -r 'select(.params.exceptionDetails.text | test("Cannot read"))
| .params.timestamp' cdp/console/exceptions.jsonl | head -1)
EVT_ISO=$(date -u -r $((${EVT_MS%.*}/1000)) +%Y%m%dT%H%M%SZ)
ls screenshots/ | sort | awk -v t="$EVT_ISO" '$0 < t { keep=$0 } END { print keep }'
# What network requests were in flight when it threw?
jq -c --argjson t "$EVT_MS" '
select(.params.timestamp <= $t/1000 and .params.timestamp > ($t/1000 - 5))
| {ts: .params.timestamp, url: .params.request.url}
' cdp/network/requests.jsonlThe stack frame points at the prod JS file + line; the screenshot shows what the user was looking at; the network query shows what XHRs were in flight in the 5 seconds before the throw.
Example 5: Attach a trace to a Browserbase session that is already running
User says: "Our staging worker is running a Browserbase session right now and the customer says it's stuck. Can you attach without killing it?"
export BROWSERBASE_API_KEY=...
# Find running sessions (no --status flag, so filter client-side).
browse cloud sessions list | jq -r '.[] | select(.status == "RUNNING") | "\(.id)\t\(.region)\t\(.startedAt)"'
# Attach the tracer to the session you care about.
SID=<session-id-from-above>
node scripts/bb-capture.mjs "$SID" stuck-debug 2
# Open the live debugger URL in your browser to watch interactively.
open "$(jq -r '.browserbase.debugger_url' .o11y/stuck-debug/manifest.json)"
# Let it record for a minute or two while the worker does whatever it does.
sleep 120
# Stop the tracer and pull artifacts. NO --release: the worker still owns this session.
node scripts/stop-capture.mjs stuck-debug
node scripts/bisect-cdp.mjs stuck-debug
node scripts/bb-finalize.mjs stuck-debugThen look for the smoking gun:
cd .o11y/stuck-debug
# Pending requests that never finished — the most common cause of "stuck"
jq -s '
([.[0][].params.requestId] - [.[1][].params.requestId] - [.[2][].params.requestId]) as $pending |
.[0] | map(select(.params.requestId | IN($pending[])))
| map({age_s: (now - .params.timestamp), url: .params.request.url})
' cdp/network/requests.jsonl cdp/network/finished.jsonl cdp/network/failed.jsonl
# Last DOMContentLoaded / load on the top frame — when did the page actually settle?
jq -c 'select(.params.frameId == .params.loaderId or .params.frameId != null)
| select(.params.name == "DOMContentLoaded" or .params.name == "load")
| {name: .params.name, ts: .params.timestamp}' cdp/page/lifecycle.jsonl | tail
# How much has Browserbase billed in proxy bytes so far?
jq '.proxyBytes' browserbase/session.jsonKey idea: bb-capture.mjs <session-id> (no --new) only adds an tracer; it never sends action commands. The production worker keeps running. bb-finalize.mjs without --release leaves the session alive when you're done.
MIT License
Copyright (c) 2026 Browserbase, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "browser-trace",
"version": "0.1.0",
"private": true,
"type": "module"
}
Browser Trace — Reference
Technical reference for the capture pipeline, the bisect mapping, and the jq recipe library.
Architecture
┌──────────────────────────────────────┐
main automation ──▶ │ Chrome / Browserbase CDP target │ ◀── tracer (this skill)
(any framework) └──────────────────────────────────────┘
│ │
▼ ▼
drives page browse cdp <target> (firehose → raw.ndjson)
browse screenshot --cdp <target> --path <file> (sampler → screenshots/)
browse get html body --cdp <target> (sampler → dom/)CDP allows multiple concurrent clients on the same target. The tracer enables only read-only domains and never sends action commands like Input.dispatch* or Runtime.evaluate, so it cannot perturb the run.
Sampler commands pass --cdp <target> because they run from the trace helper process and need to attach to the traced target directly. Normal follow-up commands in a browse daemon session do not need to repeat --cdp after the first browse open ... --cdp <target>. If the default daemon may already be active in another mode, use a named --session for sampler or automation commands.
Scripts
All scripts read O11Y_ROOT (default .o11y) so runs land under $O11Y_ROOT/<run-id>/. They are Node ESM modules (node 18+) and depend only on browse plus the Node standard library — no npm install step. jq is referenced throughout the docs for ad-hoc querying but the scripts themselves don't need it.
start-capture.mjs <target> [run-id] [interval-sec]
Starts both background processes and writes manifest.json.
target— port number (e.g.9222) or full WebSocket URL.run-id— optional; defaults toYYYYMMDDTHHMMSSZ.interval-sec— sampler period in seconds; default2.
Honours O11Y_DOMAINS (space-separated) to control which CDP domains the firehose enables. Default: Network Console Runtime Log Page. Add DOM for DOM tree mutations, Performance for navigation timing, Security for mixed-content/cert events.
PIDs are stored in <run-dir>/.cdp.pid and <run-dir>/.loop.pid so stop-capture.mjs can find them.
stop-capture.mjs <run-id>
SIGTERM → 3s grace → SIGKILL on both background processes, then stamps manifest.json with stopped_at.
bisect-cdp.mjs <run-id>
Slices cdp/raw.ndjson two ways, then writes cdp/summary.json:
1. Session-wide buckets at cdp/<domain>/... (legacy layout, always written; see bisect map below). 2. Per-page buckets at cdp/pages/<pid>/..., indexed by top-level Page.frameNavigated boundaries. Pages are zero-padded so they lex-sort numerically (000, 001, …). Within each page only non-empty bucket files are written so empty directories don't pollute search results.
cdp/summary.json carries the run-level rollup: sessionId, duration (wall-clock ms anchored to manifest.started_at), totalEvents, and a pages[] array. Each entry has {pageId, url, startMs, endMs, durationMs, eventCount, domains, network} — same shape as the per-page summary.json.
Idempotent: rerun safely. The cdp/pages/ tree is wiped and rebuilt each call.
query.mjs <run-id> <subcommand> [args...]
Reads the bisected output and prints either tabular text or NDJSON. Subcommands:
| Subcommand | Output |
|---|---|
list | one-line page table (pid, events, duration, url) |
summary | full cdp/summary.json |
page <pid> | per-page summary.json |
page <pid> <bucket> | cat pages/<pid>/<bucket>.jsonl (e.g. network/failed, console/logs, raw) |
| `errors [pid\ | all]` |
| `hosts [pid\ | all]` |
| `host <hostname> [pid\ | all]` |
timeline | ordered nav + lifecycle markers |
Bypassable with raw jq/rg against cdp/summary.json and cdp/pages/<pid>/ once you know the layout.
snapshot-loop.mjs (internal)
Invoked by start-capture.mjs; not meant to be called directly. Loops at the configured interval, writing PNG + HTML + an entry to index.jsonl per tick. DOM dumps go through a .partial temp file so a SIGTERM mid-write never leaves a 0-byte HTML behind; stop-capture.mjs sweeps any survivors.
bb-capture.mjs --new|<session-id> [run-id] [interval-sec]
Browserbase wrapper around start-capture.mjs. With --new, runs browse cloud sessions create --keep-alive and starts the tracer. With an existing session id, fetches its connectUrl via browse cloud sessions get and asserts the session is RUNNING before attaching.
Stamps the run's manifest.json with a browserbase object containing session_id, project_id, region, started_at, expires_at, keep_alive, and the debugger_url from browse cloud sessions debug.
Reads BROWSERBASE_API_KEY. BB_SESSION_TIMEOUT (default 600) controls the timeout passed to --new sessions.
bb-finalize.mjs <run-id> [--release]
Pulls platform-side artifacts after the tracer has stopped:
- `browserbase/session.json` —
browse cloud sessions getsnapshot. Always written; contains the post-runproxyBytes,status,endedAt. - `browserbase/logs.json` —
browse cloud sessions logsoutput. Often[]. The CDP firehose is authoritative; this is a side channel for cases where Browserbase happened to record server-side log entries. - `browserbase/downloads.zip` — only kept when there's real content (size > 22 bytes — an empty Browserbase downloads zip is exactly the EOCD record).
--release calls browse cloud sessions update --status REQUEST_RELEASE to end the session. Skip it when attaching to a session you don't own (e.g. one a production worker is using).
Bisect map
| File | CDP method | What's in it |
|---|---|---|
cdp/network/requests.jsonl | Network.requestWillBeSent | every outgoing request: url, method, headers, postData, requestId |
cdp/network/responses.jsonl | Network.responseReceived | response status, headers, mimeType, remoteIPAddress, fromDiskCache |
cdp/network/finished.jsonl | Network.loadingFinished | byte count + timestamp on success |
cdp/network/failed.jsonl | Network.loadingFailed | errorText (e.g. net::ERR_ABORTED), canceled |
cdp/network/websocket.jsonl | Network.webSocket* | every WebSocket lifecycle event |
cdp/console/logs.jsonl | Runtime.consoleAPICalled | console.log/info/warn/error with args[] |
cdp/console/exceptions.jsonl | Runtime.exceptionThrown | unhandled JS errors with stack |
cdp/runtime/all.jsonl | Runtime.* | execution-context create/destroy, binding calls, etc. |
cdp/log/entries.jsonl | Log.entryAdded | browser-level warnings (CSP, deprecation, mixed content) |
cdp/page/navigations.jsonl | Page.frameNavigated | each top-level + iframe navigation |
cdp/page/lifecycle.jsonl | Page.lifecycleEvent | per-navigation milestones: init, commit, DOMContentLoaded, load, firstPaint, firstContentfulPaint, firstMeaningfulPaint, networkAlmostIdle, networkIdle |
cdp/page/frames.jsonl | Page.frame* | frame attached/detached/started/stoppedLoading |
cdp/page/dialogs.jsonl | Page.javascriptDialog* | alert / confirm / prompt / beforeunload |
cdp/page/all.jsonl | Page.* | catch-all for everything Page emits |
cdp/dom/all.jsonl | DOM.* | tree mutations (only populated if `O11Y_DOMAINS` adds `DOM`) |
cdp/target/attached.jsonl | Target.attachedToTarget | each new page/iframe target attached to the tracer |
cdp/target/detached.jsonl | Target.detachedFromTarget | each detach |
Note on response bodies
browse cdp does not embed response bodies in the firehose — that requires a synchronous Network.getResponseBody round-trip per request. If you need bodies, use browse network on (in the browser skill) which writes per-request directories with request.json + response.json including body. The two skills compose: run browse network on for bodies + browse cdp for the timeline.
jq recipe library
All recipes assume cd .o11y/<run-id>/cdp for brevity.
Network
# Top hosts by request count
jq -r '.params.request.url' network/requests.jsonl \
| awk -F/ '{print $3}' | sort | uniq -c | sort -rn | head
# All XHR/fetch (exclude subresources)
jq -c 'select(.params.type == "XHR" or .params.type == "Fetch")' \
network/requests.jsonl
# Slow responses (>1000ms) — join finished against requests by requestId
jq -s '
(.[0] | map({(.params.requestId): .params.timestamp}) | add) as $start |
.[1] | map(select(.params.encodedDataLength != null))
| map({
rid: .params.requestId,
dur_ms: ((.params.timestamp - $start[.params.requestId]) * 1000 | floor),
bytes: .params.encodedDataLength
})
| map(select(.dur_ms > 1000))
| sort_by(-.dur_ms)
' network/requests.jsonl network/finished.jsonl
# All POST bodies that aren't form-encoded
jq -c 'select(.params.request.method == "POST")
| {url: .params.request.url, body: .params.request.postData}' \
network/requests.jsonlConsole & exceptions
# Console errors with the originating url+line
jq -r 'select(.params.type == "error")
| "\(.params.stackTrace.callFrames[0].url):\(.params.stackTrace.callFrames[0].lineNumber)\t\(.params.args[0].value // .params.args[0].description // "")"' \
console/logs.jsonl
# Pretty-print every exception
jq -c '.params.exceptionDetails
| {text, line: .lineNumber, url, stack: .stackTrace.callFrames[0:3]}' \
console/exceptions.jsonlPage navigation
# Linear visit log
jq -r '.params.frame.url' page/navigations.jsonl
# Navigations only on the top frame (skip iframes)
jq -r 'select(.params.frame.parentId == null) | .params.frame.url' \
page/navigations.jsonlPage lifecycle (timing milestones)
Page.lifecycleEvent fires per-navigation for init, commit, DOMContentLoaded, load, firstPaint, firstContentfulPaint, firstMeaningfulPaint, networkAlmostIdle, networkIdle. Requires browse cdp ≥ the build that includes stagehand#2056; on older builds lifecycle.jsonl will be empty.
# Time-to-DOMContentLoaded and time-to-load per navigation (seconds since loader start)
jq -s '
group_by(.params.loaderId) | map({
loader: .[0].params.loaderId,
init: (map(select(.params.name == "init")) | first | .params.timestamp // null),
DOMContentLoaded: (map(select(.params.name == "DOMContentLoaded")) | first | .params.timestamp // null),
load: (map(select(.params.name == "load")) | first | .params.timestamp // null),
firstContentfulPaint: (map(select(.params.name == "firstContentfulPaint")) | first | .params.timestamp // null),
networkIdle: (map(select(.params.name == "networkIdle")) | first | .params.timestamp // null)
} | . + {
ttDCL_s: (if .DOMContentLoaded and .init then (.DOMContentLoaded - .init) else null end),
ttLoad_s: (if .load and .init then (.load - .init) else null end),
ttFCP_s: (if .firstContentfulPaint and .init then (.firstContentfulPaint - .init) else null end),
ttIdle_s: (if .networkIdle and .init then (.networkIdle - .init) else null end)
})
' page/lifecycle.jsonlJoining events to screenshots
index.jsonl (sibling of cdp/) holds the sampler index. To find the screenshot closest to a CDP event timestamp:
# Pick an exception timestamp (Runtime.exceptionThrown uses .params.timestamp in ms)
EVT_MS=$(jq -r '.params.timestamp' console/exceptions.jsonl | head -1)
EVT_ISO=$(date -u -r $((EVT_MS/1000)) +%Y%m%dT%H%M%SZ)
# Find the first screenshot >= that ISO timestamp
ls ../screenshots | sort | awk -v t="$EVT_ISO" '$0 >= t { print; exit }'For a quick visual diff, open ../dom/<ts>.html at the same timestamp.
Pairing with Browserbase platform data
When a run was captured through bb-capture.mjs, its manifest.json carries a browserbase block and bb-finalize.mjs adds a browserbase/ subdir. A few useful joins:
RUN=.o11y/<run-id>
# Pull session metadata into context
jq '.browserbase' "$RUN/manifest.json"
# How many bytes did Browserbase's proxy bill us?
jq '.proxyBytes' "$RUN/browserbase/session.json"
# Sum the encoded bytes the tracer saw across responses; compare to proxyBytes.
jq -s 'map(.params.encodedDataLength // 0) | add' \
"$RUN/cdp/network/finished.jsonl"
# Open the live debugger view for an in-flight run
open "$(jq -r '.browserbase.debugger_url' "$RUN/manifest.json")"
# Find every run that touched a particular Browserbase project
grep -lr '"project_id": "5a9c3bfb' .o11y/*/manifest.json
# List of session ids by run
for m in .o11y/*/manifest.json; do
jq -r '"\(.run_id)\t\(.browserbase.session_id // "local")"' "$m"
doneWhen to use browse cloud sessions debug vs the tracer
They're complementary:
- tracer (this skill) captures the firehose to disk — durable, searchable, scriptable. Use for postmortem and automated checks.
- `browse cloud sessions debug` URL is an interactive Chrome DevTools view served by Browserbase, scoped to one running session. Use when you want to watch a live run, single-step through requests, or inspect the live DOM by hand.
You can do both simultaneously: bb-capture.mjs --new prints the debugger URL when it starts, and stamps it in the manifest for later.
Notes on Browserbase data sources
browse cloud sessions logsis best-effort; in practice it's frequently empty even with--log-sessionon. Don't build queries on top of it; treat anything that lands there as a bonus.- Session replay artifact fetching is deprecated — neither helper fetches it. Use the screenshots + DOM dumps in
screenshots/anddom/. browse cloud sessions listdoesn't accept a--statusfilter; pipe through jq (select(.status == "RUNNING")).- The Browserbase proxy charges per byte.
browse cloud sessions getreturns runningproxyBytes; the tracer's network buckets give you per-host detail to attribute it.
Per-page drill-down
The same recipes work scoped to a single page. Replace cdp/<bucket>.jsonl with cdp/pages/<pid>/<bucket>.jsonl, or use query.mjs for the common patterns.
RUN=.o11y/<run-id>
# Browse the page index quickly
jq '.pages | map({pageId, url, durationMs, eventCount})' $RUN/cdp/summary.json
# Pages with the most network errors
jq '.pages | map(select(.domains.Network.errors > 0))
| map({pageId, url, errors: .domains.Network.errors})' \
$RUN/cdp/summary.json
# Pages by event volume (hot pages)
jq '.pages | sort_by(-.eventCount) | .[:5] | map({pageId, url, eventCount})' \
$RUN/cdp/summary.json
# All requests on page 2 grouped by type
jq -r '.params.type' $RUN/cdp/pages/002/network/requests.jsonl \
| sort | uniq -c | sort -rn
# Did page 1 fire firstContentfulPaint?
jq -c 'select(.params.name == "firstContentfulPaint") | .params.timestamp' \
$RUN/cdp/pages/001/page/lifecycle.jsonl
# All POST bodies submitted on page 3
jq -c 'select(.params.request.method == "POST")
| {url: .params.request.url, body: .params.request.postData}' \
$RUN/cdp/pages/003/network/requests.jsonlBash traversal cheatsheet
# Total artifact size
du -sh .o11y/<run-id>
# Every URL ever requested, deduped
jq -r '.params.request.url' .o11y/*/cdp/network/requests.jsonl | sort -u
# Find runs that hit a specific host
grep -lr 'api\.example\.com' .o11y/*/cdp/network/requests.jsonl
# Search DOM dumps for an element class that came and went
rg -l 'class="error-banner"' .o11y/<run-id>/dom/
# Tail the firehose live (re-run start-capture is fine — it appends to raw.ndjson? no, it overwrites)
tail -f .o11y/<run-id>/cdp/raw.ndjson | jq -c '{m:.method, u:.params.request.url // .params.frame.url // ""}'Configuration
| Var | Default | Effect |
|---|---|---|
O11Y_ROOT | .o11y | base directory under which <run-id>/ is created |
O11Y_DOMAINS | Network Console Runtime Log Page | space-separated CDP domains for the firehose |
BROWSERBASE_API_KEY | — | required for browse cloud sessions create / browse cloud sessions get |
The interval-second arg to start-capture.mjs controls only the sampler. The firehose is always streamed in real time.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
browse cdp exited immediately | unreachable target / completed Browserbase session | verify port is listening (curl http://localhost:9222/json/version) or session is RUNNING (browse cloud sessions get) |
error: unknown command 'cdp' | older browse build lacks the command | npm install -g browse@latest (or the alpha tag if needed) |
| Browserbase session ends as soon as tracer connects | tracer was the only client; no automation attached | create with --keep-alive, attach automation with browse open --cdp <connectUrl> --session <name> first |
index.jsonl shows "url": "" | sampler browse get url failed transiently | benign; happens during navigation transitions |
| Screenshots empty / huge / inconsistent sizes | viewport not set | browse viewport 1920 1080 --cdp <target> once before capture |
raw.ndjson grows but bisect buckets empty | wrong domains; e.g. you wanted DOM but didn't enable it | O11Y_DOMAINS="Network Console Runtime Log Page DOM" bash start-capture.mjs ... |
| Loop process leaks after crash | stop-capture.mjs not run | pkill -f snapshot-loop.mjs; PID files in <run-dir> are stale |
#!/usr/bin/env node
// Start an observability capture against a Browserbase session.
//
// Usage:
// node scripts/bb-capture.mjs --new [run-id] [interval-seconds]
// node scripts/bb-capture.mjs <session-id> [run-id] [interval-seconds]
//
// Env:
// BROWSERBASE_API_KEY required
// BB_SESSION_TIMEOUT timeout for --new sessions, seconds (default: 600)
// O11Y_ROOT, O11Y_DOMAINS — same as start-capture.mjs
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { runDir, readJson, writeJson, runCmd } from './lib.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (!process.env.BROWSERBASE_API_KEY) {
console.error('BROWSERBASE_API_KEY must be set');
process.exit(1);
}
const [target, runIdArg, intervalArg] = process.argv.slice(2);
if (!target) {
console.error('usage: bb-capture.mjs --new|<session-id> [run-id] [interval-seconds]');
process.exit(2);
}
let sessionJson;
if (target === '--new') {
const timeout = String(process.env.BB_SESSION_TIMEOUT || '600');
const r = runCmd('browse', ['cloud', 'sessions', 'create', '--keep-alive', '--timeout', timeout]);
if (!r.ok) { console.error(r.stderr || 'browse cloud sessions create failed'); process.exit(1); }
sessionJson = JSON.parse(r.stdout);
console.log(`Created Browserbase session: ${sessionJson.id}`);
} else {
const r = runCmd('browse', ['cloud', 'sessions', 'get', target]);
if (!r.ok) { console.error(r.stderr || 'browse cloud sessions get failed'); process.exit(1); }
sessionJson = JSON.parse(r.stdout);
if (sessionJson.status !== 'RUNNING') {
console.error(`Session ${target} is not RUNNING (status=${sessionJson.status}). Recreate with --keep-alive.`);
process.exit(1);
}
}
const sessionId = sessionJson.id;
const connectUrl = sessionJson.connectUrl;
let debugJson = null;
const dbg = runCmd('browse', ['cloud', 'sessions', 'debug', sessionId]);
if (dbg.ok) {
try { debugJson = JSON.parse(dbg.stdout); } catch {}
}
// Hand off to start-capture.mjs as a child process so it owns its own
// detached background processes (cdp + snapshot loop).
const startScript = path.join(__dirname, 'start-capture.mjs');
const startArgs = [startScript, connectUrl];
if (runIdArg) startArgs.push(runIdArg);
if (intervalArg) startArgs.push(intervalArg);
const start = spawnSync(process.execPath, startArgs, { stdio: ['ignore', 'pipe', 'inherit'] });
if (start.status !== 0) process.exit(start.status ?? 1);
// start-capture writes a status block to stdout; surface it minus the noisy
// signed connectUrl, then add the BB-specific lines.
const lines = start.stdout.toString().split('\n').filter(l => l && !l.startsWith('target='));
for (const l of lines) console.log(l);
// Patch the manifest with Browserbase metadata so traversal queries can later
// join CDP events back to platform info (region, debugger URL, project, etc.).
const runId = (lines.find(l => l.startsWith('run_id=')) || '').slice('run_id='.length);
if (!runId) { console.error('could not parse run_id from start-capture output'); process.exit(1); }
const manifestPath = path.join(runDir(runId), 'manifest.json');
const manifest = readJson(manifestPath, {});
const debuggerUrl = debugJson?.debuggerFullscreenUrl ?? debugJson?.debuggerUrl ?? null;
manifest.browserbase = {
session_id: sessionJson.id,
project_id: sessionJson.projectId,
region: sessionJson.region,
started_at: sessionJson.startedAt,
expires_at: sessionJson.expiresAt,
keep_alive: sessionJson.keepAlive,
debugger_url: debuggerUrl,
};
writeJson(manifestPath, manifest);
if (debuggerUrl) console.log(`Live debugger: ${debuggerUrl}`);
console.log(`session_id=${sessionId}`);
console.log(`connect_url=${(connectUrl || '').slice(0, 60)}…`);
#!/usr/bin/env node
// After stop-capture.mjs, pull final Browserbase-side artifacts (session
// metadata, server logs, downloads) into the run dir. Logs are best-effort —
// they're often sparse.
//
// Usage:
// node scripts/bb-finalize.mjs <run-id> [--release]
//
// --release send `browse cloud sessions update --status REQUEST_RELEASE` after
// finalizing (use only when this run owns the session).
import fs from 'node:fs';
import path from 'node:path';
import { runDir, readJson, ensureDir, runCmd } from './lib.mjs';
if (!process.env.BROWSERBASE_API_KEY) {
console.error('BROWSERBASE_API_KEY must be set');
process.exit(1);
}
const [runId, ...rest] = process.argv.slice(2);
if (!runId) {
console.error('usage: bb-finalize.mjs <run-id> [--release]');
process.exit(2);
}
const release = rest.includes('--release');
const RD = runDir(runId);
const manifestPath = path.join(RD, 'manifest.json');
const manifest = readJson(manifestPath);
if (!manifest) {
console.error(`manifest not found at ${RD}`);
process.exit(1);
}
const sessionId = manifest?.browserbase?.session_id;
if (!sessionId) {
console.error('manifest has no .browserbase.session_id — was this run captured via bb-capture.mjs?');
process.exit(1);
}
const bbDir = path.join(RD, 'browserbase');
ensureDir(bbDir);
// Final session metadata — proxyBytes, status, ended_at all settle here.
{
const r = runCmd('browse', ['cloud', 'sessions', 'get', sessionId]);
if (r.ok) {
fs.writeFileSync(path.join(bbDir, 'session.json'), r.stdout);
console.log('wrote session.json');
} else {
console.error('warn: browse cloud sessions get failed');
}
}
// Server-side logs. Often empty — the firehose in cdp/raw.ndjson is the source of truth.
{
const r = runCmd('browse', ['cloud', 'sessions', 'logs', sessionId]);
if (r.ok) {
fs.writeFileSync(path.join(bbDir, 'logs.json'), r.stdout);
let n = '?';
try { n = String(JSON.parse(r.stdout).length ?? '?'); } catch {}
console.log(`wrote logs.json (${n} entries)`);
}
}
// Downloads. An empty session yields a 22-byte EOCD-only zip; any real
// content is always larger.
{
const out = path.join(bbDir, 'downloads.zip');
const r = runCmd('browse', ['cloud', 'sessions', 'downloads', 'get', sessionId, '--output', out]);
if (r.ok && fs.existsSync(out)) {
const size = fs.statSync(out).size;
if (size <= 22) {
fs.unlinkSync(out);
console.log('no downloads');
} else {
console.log(`wrote downloads.zip (${size} bytes)`);
}
} else if (fs.existsSync(out)) {
fs.unlinkSync(out);
}
}
if (release) {
const r = runCmd('browse', ['cloud', 'sessions', 'update', sessionId, '--status', 'REQUEST_RELEASE']);
if (r.ok) console.log(`released session ${sessionId}`);
// Re-snapshot session.json so it reflects the final COMPLETED state with
// settled proxyBytes and endedAt instead of the pre-release values.
const r2 = runCmd('browse', ['cloud', 'sessions', 'get', sessionId]);
if (r2.ok) {
fs.writeFileSync(path.join(bbDir, 'session.json'), r2.stdout);
console.log('refreshed session.json (post-release)');
}
}
console.log(`finalized: ${bbDir}`);
for (const f of fs.readdirSync(bbDir)) console.log(f);
#!/usr/bin/env node
// Slice cdp/raw.ndjson into per-bucket and per-page JSONL files, then write a
// structured cdp/summary.json with a top-level overview and a pages[] array.
//
// Usage:
// node scripts/bisect-cdp.mjs <run-id>
//
// Layout produced:
// cdp/summary.json {sessionId, duration, totalEvents, pages[]}
// cdp/<domain>/... session-wide buckets (legacy layout, always written)
// cdp/pages/<pid>/ per-page slices, only non-empty buckets written
// url.txt, summary.json, raw.jsonl
// network/, console/, page/, runtime/, log/, target/, dom/
import fs from 'node:fs';
import path from 'node:path';
import {
runDir, ensureDir, readJson, readJsonl, writeJson, writeJsonl,
BUCKETS, isTopNav,
} from './lib.mjs';
const [runId] = process.argv.slice(2);
if (!runId) {
console.error('usage: bisect-cdp.mjs <run-id>');
process.exit(2);
}
const RD = runDir(runId);
const cdpDir = path.join(RD, 'cdp');
const rawPath = path.join(cdpDir, 'raw.ndjson');
if (!fs.existsSync(rawPath)) {
console.error(`raw.ndjson not found at ${rawPath}`);
process.exit(1);
}
const events = readJsonl(rawPath);
const manifest = readJson(path.join(RD, 'manifest.json'), {});
// CDP exposes two clocks under .params.timestamp depending on the domain:
// Network/Page → MonotonicTime, seconds since browser start (small)
// Console.messageAdded etc. → TimeSinceEpoch in ms (large, > 1e9)
// Anchor only on monotonic so wall-clock conversion stays consistent.
const isMonotonic = ts => ts != null && ts < 1e9;
const anchorCdp = events
.map(e => e?.params?.timestamp)
.find(isMonotonic) ?? null;
const startedMs = manifest.started_at ? new Date(manifest.started_at).getTime() : null;
const stoppedMs = manifest.stopped_at ? new Date(manifest.stopped_at).getTime() : null;
function toMs(ts) {
if (ts == null || anchorCdp == null || startedMs == null) return null;
return Math.floor((ts - anchorCdp) * 1000 + startedMs);
}
// Walk events in order. Each top-level Page.frameNavigated bumps the page
// counter. Events emitted before the first navigation are clamped to pid 0
// so they fold into the first concrete page (their requests really are part
// of loading that first page).
let pid = -1;
for (const ev of events) {
if (isTopNav(ev)) pid += 1;
ev._pid = pid < 0 ? 0 : pid;
}
// ---- session-wide buckets (always written, including empty files) ----
ensureDir(cdpDir);
for (const [bucket, predicate] of BUCKETS) {
const matched = events
.filter(e => predicate(e.method ?? ''))
.map(stripPid);
writeJsonl(path.join(cdpDir, `${bucket}.jsonl`), matched);
}
// ---- per-page slices ----
const pagesRoot = path.join(cdpDir, 'pages');
if (fs.existsSync(pagesRoot)) fs.rmSync(pagesRoot, { recursive: true, force: true });
ensureDir(pagesRoot);
// Group events by pid. If the run had zero events we still create page 0
// so the run dir has a predictable shape.
const pageMap = new Map();
for (const ev of events) {
if (!pageMap.has(ev._pid)) pageMap.set(ev._pid, []);
pageMap.get(ev._pid).push(ev);
}
if (pageMap.size === 0) pageMap.set(0, []);
const pageSummaries = [];
for (const [thisPid, pageEvents] of [...pageMap.entries()].sort((a, b) => a[0] - b[0])) {
const padded = String(thisPid).padStart(3, '0');
const pdir = path.join(pagesRoot, padded);
ensureDir(pdir);
// URL: first top-level frameNavigated in this page; "(initial)" if none.
const navEv = pageEvents.find(isTopNav);
const url = navEv?.params?.frame?.url ?? '(initial)';
fs.writeFileSync(path.join(pdir, 'url.txt'), url + '\n');
// raw.jsonl for this page, _pid stripped.
writeJsonl(path.join(pdir, 'raw.jsonl'), pageEvents.map(stripPid));
// Per-bucket slices, only writing files that have content.
for (const [bucket, predicate] of BUCKETS) {
const matched = pageEvents
.filter(e => predicate(e.method ?? ''))
.map(stripPid);
writeJsonl(path.join(pdir, `${bucket}.jsonl`), matched, { skipEmpty: true });
}
const summary = computePageSummary(thisPid, url, pageEvents);
writeJson(path.join(pdir, 'summary.json'), summary);
pageSummaries.push(summary);
}
// ---- top-level summary.json ----
const sessionId = manifest?.browserbase?.session_id || manifest.run_id || runId;
const summary = {
sessionId,
duration: {
startMs: startedMs,
endMs: stoppedMs,
totalMs: (startedMs != null && stoppedMs != null) ? stoppedMs - startedMs : null,
},
totalEvents: events.length,
pages: pageSummaries,
};
writeJson(path.join(cdpDir, 'summary.json'), summary);
// Compact stdout view (full file is on disk).
console.log(JSON.stringify({
sessionId,
duration: summary.duration,
totalEvents: summary.totalEvents,
pages: pageSummaries.map(p => ({
pageId: p.pageId,
url: p.url,
durationMs: p.durationMs,
eventCount: p.eventCount,
})),
}, null, 2));
// ---------------------------------------------------------------------------
function stripPid(ev) {
const { _pid, ...rest } = ev;
return rest;
}
function computePageSummary(pid, url, pageEvents) {
const ts = pageEvents
.map(e => e?.params?.timestamp)
.filter(isMonotonic);
const start = ts[0] ?? null;
const end = ts[ts.length - 1] ?? null;
const startMs = toMs(start);
const endMs = toMs(end);
// Per-CDP-domain rollup with optional errors/warnings keys.
const counts = new Map(); // domain -> count
const errors = new Map(); // domain -> errors
const warnings = new Map(); // domain -> warnings
const netTypes = new Map(); // resourceType -> count
let netRequests = 0;
let netFailed = 0;
const inc = (m, k, by = 1) => m.set(k, (m.get(k) ?? 0) + by);
// Classify each event into a logical "domain" bucket. Most CDP events go in
// the bucket named for their CDP domain (Network, Page, Runtime, …), but
// `Runtime.consoleAPICalled` is conceptually console activity, not runtime
// internals — without this remap, the Console bucket's `errors`/`warnings`
// counts would never line up with any entry in the counts map and would
// silently disappear from the per-page summary.
const domainFor = (method) =>
method === 'Runtime.consoleAPICalled' ? 'Console' : method.split('.')[0];
for (const ev of pageEvents) {
const method = ev.method;
if (!method) continue;
inc(counts, domainFor(method));
if (method === 'Network.loadingFailed') {
inc(errors, 'Network');
netFailed += 1;
} else if (method === 'Network.requestWillBeSent') {
netRequests += 1;
inc(netTypes, ev?.params?.type ?? 'Other');
} else if (method === 'Runtime.exceptionThrown') {
inc(errors, 'Runtime');
} else if (method === 'Runtime.consoleAPICalled') {
const t = ev?.params?.type;
if (t === 'error') inc(errors, 'Console');
else if (t === 'warning' || t === 'warn') inc(warnings, 'Console');
} else if (method === 'Log.entryAdded') {
const level = ev?.params?.entry?.level;
if (level === 'error') inc(errors, 'Log');
else if (level === 'warning') inc(warnings, 'Log');
}
}
const domains = {};
for (const [d, c] of [...counts.entries()].sort()) {
const block = { count: c };
if (errors.get(d)) block.errors = errors.get(d);
if (warnings.get(d)) block.warnings = warnings.get(d);
domains[d] = block;
}
const out = {
pageId: pid,
url,
startMs,
endMs,
durationMs: (startMs != null && endMs != null) ? endMs - startMs : null,
eventCount: pageEvents.length,
domains,
};
if (netRequests > 0 || netFailed > 0) {
const byType = {};
for (const [t, c] of [...netTypes.entries()].sort()) byType[t] = c;
out.network = { requests: netRequests, failed: netFailed, byType };
}
return out;
}
// Shared helpers for the browser-trace scripts.
//
// All scripts read O11Y_ROOT (default ".o11y") so runs land under
// $O11Y_ROOT/<run-id>/. No third-party deps; node stdlib only.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export function runRoot() {
return process.env.O11Y_ROOT || '.o11y';
}
export function runDir(runId) {
return path.join(runRoot(), runId);
}
export function ensureDir(p) {
fs.mkdirSync(p, { recursive: true });
}
// Wall-clock ISO seconds, no fractional part — same shape as `date -u +%Y-%m-%dT%H:%M:%SZ`.
export function isoUtcSeconds(d = new Date()) {
return d.toISOString().replace(/\.\d+/, '');
}
// Compact UTC stamp suitable for filenames: 20260427T175533Z (no separators).
export function isoStampForFilename(d = new Date()) {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
}
export function readJson(p, fallback = null) {
if (!fs.existsSync(p)) return fallback;
try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
catch { return fallback; }
}
export function writeJson(p, obj) {
ensureDir(path.dirname(p));
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
}
// Stream a JSONL file line-by-line. Returns parsed objects, skipping bad lines.
export function readJsonl(p) {
if (!fs.existsSync(p)) return [];
const out = [];
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
if (!line) continue;
try { out.push(JSON.parse(line)); } catch { /* skip */ }
}
return out;
}
// Atomic-ish JSONL write: caller has already mutated the array as desired.
// `skipEmpty: true` removes the file if there's nothing to write — used by per-page bucketing.
export function writeJsonl(p, items, { skipEmpty = false } = {}) {
if (skipEmpty && items.length === 0) {
if (fs.existsSync(p)) fs.unlinkSync(p);
return;
}
ensureDir(path.dirname(p));
const body = items.length ? items.map(o => JSON.stringify(o)).join('\n') + '\n' : '';
fs.writeFileSync(p, body);
}
export function isAlive(pid) {
if (!Number.isInteger(pid)) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
// Wrap execFileSync so transient "exit non-zero" doesn't kill the caller.
// Returns { ok, stdout, stderr, status }.
export function runCmd(cmd, args, opts = {}) {
try {
const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
return { ok: true, stdout, stderr: '', status: 0 };
} catch (err) {
return {
ok: false,
stdout: err.stdout?.toString?.() ?? '',
stderr: err.stderr?.toString?.() ?? String(err.message || err),
status: err.status ?? 1,
};
}
}
export function sleepMs(ms) {
return new Promise(r => setTimeout(r, ms));
}
// Bucket map shared by bisect (session-wide + per-page) and query helpers.
// Format: [bucketRelativePath, predicate(method)].
export const BUCKETS = [
['network/requests', m => m === 'Network.requestWillBeSent'],
['network/responses', m => m === 'Network.responseReceived'],
['network/finished', m => m === 'Network.loadingFinished'],
['network/failed', m => m === 'Network.loadingFailed'],
['network/websocket', m => m.startsWith('Network.webSocket')],
['console/logs', m => m === 'Runtime.consoleAPICalled'],
['console/exceptions', m => m === 'Runtime.exceptionThrown'],
['runtime/all', m => m.startsWith('Runtime.')],
['log/entries', m => m === 'Log.entryAdded'],
['page/navigations', m => m === 'Page.frameNavigated'],
['page/lifecycle', m => m === 'Page.lifecycleEvent'],
['page/dialogs', m => m.startsWith('Page.javascriptDialog')],
['page/frames', m => m.startsWith('Page.frame')],
['page/all', m => m.startsWith('Page.')],
['dom/all', m => m.startsWith('DOM.')],
['target/attached', m => m === 'Target.attachedToTarget'],
['target/detached', m => m === 'Target.detachedFromTarget'],
];
// Top-level frameNavigated detector (parentId null/empty == top frame).
export function isTopNav(ev) {
if (ev?.method !== 'Page.frameNavigated') return false;
const parent = ev?.params?.frame?.parentId ?? null;
return parent === null || parent === '';
}
#!/usr/bin/env node
// Drill-down helper for a captured run.
//
// Usage:
// node scripts/query.mjs <run-id> list List pages with id, url, duration, events.
// node scripts/query.mjs <run-id> summary Print cdp/summary.json (full).
// node scripts/query.mjs <run-id> page <pid> Print this page's summary.json.
// node scripts/query.mjs <run-id> page <pid> <bucket> Cat a per-page bucket file. e.g.
// network/requests, network/failed,
// console/logs, page/lifecycle, raw
// node scripts/query.mjs <run-id> errors [pid|all] All error rows (network failed,
// runtime exceptions, console errors,
// log errors). Each line tagged with
// pid + kind.
// node scripts/query.mjs <run-id> hosts [pid|all] Top hosts by request count.
// node scripts/query.mjs <run-id> host <hostname> [pid|all] Requests + responses for one host.
// node scripts/query.mjs <run-id> timeline Compact navigation+lifecycle timeline.
import fs from 'node:fs';
import path from 'node:path';
import { runDir, readJson, readJsonl, isTopNav } from './lib.mjs';
const [runId, cmd, ...args] = process.argv.slice(2);
if (!runId || !cmd) usage();
const RD = runDir(runId);
const cdpDir = path.join(RD, 'cdp');
if (!fs.existsSync(cdpDir)) {
console.error(`no run dir at ${RD}`);
process.exit(1);
}
switch (cmd) {
case 'list': cmdList(); break;
case 'summary': cmdSummary(); break;
case 'page': cmdPage(args[0], args[1]); break;
case 'errors': cmdErrors(args[0]); break;
case 'hosts': cmdHosts(args[0]); break;
case 'host': cmdHost(args[0], args[1]); break;
case 'timeline': cmdTimeline(); break;
default:
console.error(`unknown command: ${cmd}`);
usage();
}
// ---------------------------------------------------------------------------
function usage() {
console.error([
'usage: query.mjs <run-id> <command> [args...]',
'',
' list page table',
' summary full cdp/summary.json',
' page <pid> per-page summary',
' page <pid> <bucket> cat pages/<pid>/<bucket>.jsonl (e.g. network/failed, raw)',
' errors [pid|all] unified errors with pid + kind',
' hosts [pid|all] top hosts by request count',
' host <hostname> [pid|all] all requests/responses for a hostname',
' timeline nav + lifecycle markers',
].join('\n'));
process.exit(2);
}
function pageDir(pid) {
return path.join(cdpDir, 'pages', String(pid).padStart(3, '0'));
}
function listPids(filter) {
if (filter && filter !== 'all') return [Number(filter)];
const root = path.join(cdpDir, 'pages');
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root)
.filter(d => /^\d+$/.test(d))
.map(Number)
.sort((a, b) => a - b);
}
// Exact host match — uses `URL.host` (which includes the port when present)
// so `cmdHosts` output is directly consumable as input to `cmdHost`. The
// equality check still rejects impostors like `example.com.evil.tld` whose
// `host` is the full malicious string, not the prefix.
function hostMatches(url, host) {
try { return new URL(url).host === host; }
catch { return false; }
}
// ---------------------------------------------------------------------------
function cmdList() {
const summary = readJson(path.join(cdpDir, 'summary.json'));
if (!summary) { console.error('no summary.json — run bisect-cdp.mjs first'); process.exit(1); }
// Pad columns: pid, eventCount, durationSeconds, url.
const rows = summary.pages.map(p => ([
String(p.pageId),
`${p.eventCount}evt`,
`${((p.durationMs ?? 0) / 1000).toFixed(2)}s`,
p.url,
]));
const widths = rows[0]?.map((_, i) => Math.max(...rows.map(r => r[i].length))) ?? [];
for (const r of rows) {
console.log(r.map((c, i) => c.padEnd(widths[i])).join(' '));
}
}
function cmdSummary() {
const s = readJson(path.join(cdpDir, 'summary.json'));
if (!s) { console.error('no summary.json — run bisect-cdp.mjs first'); process.exit(1); }
console.log(JSON.stringify(s, null, 2));
}
function cmdPage(pidArg, bucketArg) {
if (pidArg === undefined) { console.error('page id required'); process.exit(2); }
const pdir = pageDir(pidArg);
if (!fs.existsSync(pdir)) { console.error(`no such page: ${pidArg}`); process.exit(1); }
if (!bucketArg) {
const s = readJson(path.join(pdir, 'summary.json'));
if (!s) { console.error(`no summary.json for page ${pidArg}`); process.exit(1); }
console.log(JSON.stringify(s, null, 2));
return;
}
if (bucketArg === 'raw') {
const raw = path.join(pdir, 'raw.jsonl');
if (!fs.existsSync(raw)) { console.error('(empty)'); return; }
process.stdout.write(fs.readFileSync(raw));
return;
}
const file = path.join(pdir, `${bucketArg}.jsonl`);
if (!fs.existsSync(file)) { console.error(`(empty: ${bucketArg} for page ${pidArg})`); return; }
process.stdout.write(fs.readFileSync(file));
}
function cmdErrors(filter) {
for (const pid of listPids(filter)) {
const pdir = pageDir(pid);
for (const ev of readJsonl(path.join(pdir, 'network/failed.jsonl'))) {
console.log(JSON.stringify({
pid, kind: 'network.failed',
rid: ev?.params?.requestId,
errorText: ev?.params?.errorText,
type: ev?.params?.type,
}));
}
for (const ev of readJsonl(path.join(pdir, 'console/exceptions.jsonl'))) {
console.log(JSON.stringify({
pid, kind: 'runtime.exception',
text: ev?.params?.exceptionDetails?.text,
message: ev?.params?.exceptionDetails?.exception?.description,
}));
}
for (const ev of readJsonl(path.join(pdir, 'console/logs.jsonl'))) {
if (ev?.params?.type !== 'error') continue;
const arg0 = ev?.params?.args?.[0];
console.log(JSON.stringify({
pid, kind: 'console.error',
msg: arg0?.value ?? arg0?.description ?? '',
}));
}
for (const ev of readJsonl(path.join(pdir, 'log/entries.jsonl'))) {
if (ev?.params?.entry?.level !== 'error') continue;
console.log(JSON.stringify({
pid, kind: 'log.error',
source: ev?.params?.entry?.source,
text: ev?.params?.entry?.text,
}));
}
}
}
function cmdHosts(filter) {
const counts = new Map();
for (const pid of listPids(filter)) {
for (const ev of readJsonl(path.join(pageDir(pid), 'network/requests.jsonl'))) {
const url = ev?.params?.request?.url;
if (!url) continue;
let host;
try { host = new URL(url).host; } catch { host = ''; }
counts.set(host, (counts.get(host) ?? 0) + 1);
}
}
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
for (const [host, n] of sorted) {
console.log(`${String(n).padStart(4)} ${host}`);
}
}
function cmdHost(hostname, filter) {
if (!hostname) { console.error('hostname required'); process.exit(2); }
for (const pid of listPids(filter)) {
const pdir = pageDir(pid);
for (const ev of readJsonl(path.join(pdir, 'network/requests.jsonl'))) {
const url = ev?.params?.request?.url ?? '';
if (!hostMatches(url, hostname)) continue;
console.log(JSON.stringify({
pid, kind: 'request',
method: ev?.params?.request?.method,
url,
type: ev?.params?.type,
}));
}
for (const ev of readJsonl(path.join(pdir, 'network/responses.jsonl'))) {
const url = ev?.params?.response?.url ?? '';
if (!hostMatches(url, hostname)) continue;
console.log(JSON.stringify({
pid, kind: 'response',
status: ev?.params?.response?.status,
url,
}));
}
}
}
function cmdTimeline() {
// Read raw.ndjson directly so nav + lifecycle events come out in the order
// they actually fired. The bisected per-method buckets group by type and
// would otherwise print all NAVs before any lifecycle markers, even when
// navigations occurred between lifecycle phases.
const rawPath = path.join(cdpDir, 'raw.ndjson');
if (!fs.existsSync(rawPath)) {
console.error('no raw.ndjson — capture may not have started');
process.exit(1);
}
for (const ev of readJsonl(rawPath)) {
if (isTopNav(ev)) {
console.log(`[NAV ${ev?.params?.frame?.url ?? '?'}]`);
} else if (ev?.method === 'Page.lifecycleEvent') {
console.log(`[${ev?.params?.name ?? '?'}]`);
}
}
}
#!/usr/bin/env node
// Periodic screenshot + DOM HTML + URL sampler. Invoked by start-capture.mjs;
// not meant to be run directly.
//
// Each tick samples the traced CDP target through browse. Passing --cdp here
// ensures this helper attaches to the traced target even when it runs outside
// the user's main automation flow.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { isoStampForFilename, sleepMs } from './lib.mjs';
const [target, RD, intervalArg] = process.argv.slice(2);
if (!target || !RD) {
console.error('usage: snapshot-loop.mjs <target> <run-dir> [interval-seconds]');
process.exit(2);
}
const intervalMs = (Number(intervalArg) || 2) * 1000;
const indexPath = path.join(RD, 'index.jsonl');
let stopping = false;
process.on('SIGTERM', () => { stopping = true; });
process.on('SIGINT', () => { stopping = true; });
function getJsonField(stdout, field) {
if (!stdout) return '';
try {
const parsed = JSON.parse(stdout);
return typeof parsed?.[field] === 'string' ? parsed[field] : '';
} catch {
return '';
}
}
while (!stopping) {
const ts = isoStampForFilename();
const png = path.join(RD, 'screenshots', `${ts}.png`);
const html = path.join(RD, 'dom', `${ts}.html`);
const tmp = `${html}.partial`;
// Best-effort screenshot. If browse fails we just don't get one this tick.
spawnSync('browse', ['screenshot', '--cdp', target, '--path', png], { stdio: 'ignore' });
if (fs.existsSync(png) && fs.statSync(png).size === 0) {
fs.unlinkSync(png);
}
// DOM dump via temp file → rename, so we never leave a 0-byte HTML behind.
try {
const r = spawnSync('browse', ['get', 'html', 'body', '--cdp', target], { encoding: 'utf8' });
const htmlBody = getJsonField(r.stdout, 'html');
if (htmlBody) {
fs.writeFileSync(tmp, htmlBody);
fs.renameSync(tmp, html);
}
} catch { /* best-effort */ }
// Cleanup any leftover .partial from a previous interrupted iteration.
if (fs.existsSync(tmp)) {
try { fs.unlinkSync(tmp); } catch {}
}
// URL from the traced target. Returns {"url": "..."}.
let urlValue = '';
const u = spawnSync('browse', ['get', 'url', '--cdp', target], { encoding: 'utf8' });
urlValue = getJsonField(u.stdout, 'url');
const screenshotRel = fs.existsSync(png) ? `screenshots/${ts}.png` : '';
const domRel = fs.existsSync(html) ? `dom/${ts}.html` : '';
fs.appendFileSync(indexPath,
JSON.stringify({ ts, screenshot: screenshotRel, dom: domRel, url: urlValue }) + '\n');
await sleepMs(intervalMs);
}
#!/usr/bin/env node
// Start an observability capture against a CDP target.
//
// Usage:
// node scripts/start-capture.mjs <port|ws-url> [run-id] [interval-seconds]
//
// Env:
// O11Y_ROOT base directory for runs (default: .o11y)
// O11Y_DOMAINS space-separated CDP domains (default: "Network Console Runtime Log Page")
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
runDir, ensureDir, isoUtcSeconds, isAlive, sleepMs, writeJson,
} from './lib.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const [target, runIdArg, intervalArg] = process.argv.slice(2);
if (!target) {
console.error('usage: start-capture.mjs <port|ws-url> [run-id] [interval-seconds]');
process.exit(2);
}
const runId = runIdArg || isoUtcSeconds().replace(/[-:]/g, '');
const interval = Number(intervalArg) || 2;
const domainsList = (process.env.O11Y_DOMAINS || 'Network Console Runtime Log Page').trim().split(/\s+/);
const domainArgs = domainsList.flatMap(d => ['--domain', d]);
const RD = runDir(runId);
ensureDir(path.join(RD, 'cdp'));
ensureDir(path.join(RD, 'screenshots'));
ensureDir(path.join(RD, 'dom'));
writeJson(path.join(RD, 'manifest.json'), {
run_id: runId,
target,
domains: domainsList.join(' '),
interval_seconds: interval,
started_at: isoUtcSeconds(),
});
// Spawn the CDP firehose in the background. Detach + unref so it survives this
// process exiting. browse cdp writes one JSON object per line to stdout.
const rawFd = fs.openSync(path.join(RD, 'cdp', 'raw.ndjson'), 'w');
const errFd = fs.openSync(path.join(RD, 'cdp', 'stderr.log'), 'w');
const cdp = spawn('browse', ['cdp', target, ...domainArgs], {
detached: true,
stdio: ['ignore', rawFd, errFd],
});
cdp.unref();
fs.writeFileSync(path.join(RD, '.cdp.pid'), String(cdp.pid));
// Spawn the periodic screenshot/DOM/url sampler (separate process so it can
// be SIGTERM'd independently from the CDP stream).
const loopFd = fs.openSync(path.join(RD, 'snapshot-loop.log'), 'w');
const loopScript = path.join(__dirname, 'snapshot-loop.mjs');
const loop = spawn(process.execPath, [loopScript, target, RD, String(interval)], {
detached: true,
stdio: ['ignore', loopFd, loopFd],
});
loop.unref();
fs.writeFileSync(path.join(RD, '.loop.pid'), String(loop.pid));
// Give browse cdp a beat to fail loudly on bad targets so the user sees the
// real error instead of a silent zero-event capture.
await sleepMs(1000);
if (!isAlive(cdp.pid)) {
console.error(`browse cdp exited immediately — check ${RD}/cdp/stderr.log`);
try { console.error(fs.readFileSync(path.join(RD, 'cdp', 'stderr.log'), 'utf8')); } catch {}
try { process.kill(loop.pid); } catch {}
process.exit(1);
}
console.log(`run_id=${runId}`);
console.log(`run_dir=${RD}`);
console.log(`target=${target}`);
console.log(`cdp_pid=${cdp.pid}`);
console.log(`loop_pid=${loop.pid}`);
#!/usr/bin/env node
// Stop an in-progress capture and stamp the manifest with stopped_at.
//
// Usage:
// node scripts/stop-capture.mjs <run-id>
import fs from 'node:fs';
import path from 'node:path';
import { runDir, readJson, writeJson, isoUtcSeconds, isAlive, sleepMs } from './lib.mjs';
const [runId] = process.argv.slice(2);
if (!runId) {
console.error('usage: stop-capture.mjs <run-id>');
process.exit(2);
}
const RD = runDir(runId);
if (!fs.existsSync(RD)) {
console.error(`run dir not found: ${RD}`);
process.exit(1);
}
for (const pidFile of ['.cdp.pid', '.loop.pid']) {
const p = path.join(RD, pidFile);
if (!fs.existsSync(p)) continue;
const pid = parseInt(fs.readFileSync(p, 'utf8').trim(), 10);
if (!Number.isInteger(pid)) { fs.unlinkSync(p); continue; }
try { process.kill(pid, 'SIGTERM'); } catch {}
for (let i = 0; i < 3 && isAlive(pid); i++) {
await sleepMs(1000);
}
if (isAlive(pid)) {
try { process.kill(pid, 'SIGKILL'); } catch {}
}
fs.unlinkSync(p);
}
const manifestPath = path.join(RD, 'manifest.json');
const manifest = readJson(manifestPath);
if (manifest) {
manifest.stopped_at = isoUtcSeconds();
writeJson(manifestPath, manifest);
}
// Sweep half-written DOM dumps if the loop got SIGTERM'd mid-write.
const domDir = path.join(RD, 'dom');
if (fs.existsSync(domDir)) {
for (const f of fs.readdirSync(domDir)) {
if (f.endsWith('.partial')) {
try { fs.unlinkSync(path.join(domDir, f)); } catch {}
}
}
}
console.log(`stopped: ${RD}`);