Skip to content

feat: record XMLHttpRequest traffic, not just fetch - #18

Merged
ribdsp merged 1 commit into
mainfrom
feat/record-xhr
Aug 31, 2026
Merged

ribdsp merged 1 commit into
mainfrom
feat/record-xhr

Conversation

@ribdsp

@ribdsp ribdsp commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What was wrong

bugbait/src/lib/record.ts had zero occurrences of XMLHttpRequest. The only network recorder was
installFetchRecorder, so an app on axios's default adapter, jQuery, or an older SDK produced a
recording with an empty network timeline — and read_network returning nothing does not tell an agent
"this recorder cannot see XHR", it tells it "the page made no requests". A wrong answer that looks like
an answer.

What this adds

installXhrRecorder(), next to the fetch one, patching XMLHttpRequest.prototype.open and .send.

  • Same event shape, read out of the code rather than invented. record.addCustomEvent('network-request', { url, method, status, ok, durationMs, bodySummary }) — the field set documented at traces/src/lib/replay/rrweb-events.ts:33 and parsed by traces/src/lib/webmcp/tools/read-network.ts. No new field, no renamed field.
  • Teardown mirrors the fetch patch. installFetchRecorder returns a closure that restores window.fetch; this returns one that restores both prototype methods. Both are called from handle.stop().
  • Terminal state via loadend, the one event that fires for all four outcomes (load, error, abort, timeout). status === 0 separates the three that never produced a response — the same split the fetch patch gets from try/catch.
  • A shared contentTypeSummary helper, extracted from the fetch patch, so the two cannot drift on the exact "…, not summarised" string read_network renders.

Masking: no body, in either direction

bodySummary is derived from the Content-Type header alone. Reading the response would mean
responseText, and that is where this recorder stops. The request body handed to send is passed
straight through uninspected.

maskAllInputs: true is the standing rule for typed values and a network payload is the same class of
risk — docs/threat-model.md (T4). A checkout is exactly the app whose XHR bodies carry an address and
a card number. Reading the header also sidesteps the InvalidStateError that responseText throws when
responseType is arraybuffer, blob or json.

The abort test asserts this rather than trusting it: it sends {"card":"4111111111111111", "address":…}
and asserts neither string appears anywhere in the recorded event.

Not throwing on an already-patched XHR

Two directions, both covered:

  • Someone patched before us. The patch calls through to whatever it found on the prototype, so a page that brought its own XHR wrapper keeps it and this records on top. Teardown restores what was found, not the pristine method, so the other library's patch outlives the recording. Asserted.
  • Someone froze the prototype. A property-descriptor check on open and send; if either is non-writable, installXhrRecorder returns a no-op teardown. A missing network timeline is a far smaller problem than a startRecording that throws.

typeof XMLHttpRequest === 'undefined' returns a no-op teardown too, so the caller needs no branch.

Public API surface: unchanged

installXhrRecorder is not exported. The tests drive it through startRecording() / handle.stop()
with a stubbed XMLHttpRequest, so nothing new is on the module's surface — no need for the
stop-and-report the brief asked for.

Drift from the brief

bugbait had no tests at all, so "tests in the same style as existing recorder tests" had no local
referent: no *.test.ts under bugbait/src, no test script, no vitest config. Rather than skip the
tests, this adds the runner and uses traces/src/lib/webmcp/tool-change.test.ts as the style reference
(file-level docblock explaining why the test exists, // Arrange / // Act / // Assert, long
descriptive it names).

New devDependencies only, pinned to the versions traces already uses so the two workspaces agree:
vitest@^4.1.11, jsdom@^25.0.0. No new runtime dependency. npm install in bugbait: 0
vulnerabilities.

There is no .github/workflows/ directory, so CI neither runs nor is broken by this suite; it runs via
npm test in bugbait.

Prose this change falsified

Fixed in the same commit, since my change is what made them untrue:

  • README.md:330 — the five-options table row now names both patches, and says why patching one and not the other is the worse failure.
  • README.md:340-343 — the "gap worth knowing" paragraph described exactly the gap this closes. Replaced with what read_network can and cannot see: both transports patched; navigator.sendBeacon, WebSockets and EventSource still invisible; every committed fixture predates the XHR patch and uses fetch only, so the test file is what covers that path.
  • A new paragraph states the body rule, and is careful about the asymmetry: the fetch patch does read a JSON response and record its shape (array, 0 items, object, keys: …, which is the whole clue for the empty-province bug) — the XHR patch reads nothing. An earlier draft of that paragraph claimed neither patch reads a body, which was false for fetch.
  • traces/src/lib/replay/rrweb-events.ts:32 — one clause, monkey-patches window.fetch → monkey-patches both window.fetch and XMLHttpRequest.prototype. This is outside bugbait; it is a comment documenting this exact contract from the consumer's side, and leaving it false was the worse option.

Fixtures

The three files in traces/public/recordings/ are byte-identical — git status reports zero changes
under that path. Nothing was regenerated or re-recorded.

Verification

bugbait traces
npx tsc --noEmit exit 0 exit 0
npm run lint exit 0 exit 0, same 8 pre-existing warnings as #16
npx vitest run 1 file / 7 tests passed 25 files / 303 tests passed (baseline unchanged)

Zero ESLint rules disabled. No assertion weakened, skipped or deleted anywhere.

The seven tests

  1. a successful GET — asserts the full six-field payload with toEqual, plus that the underlying open/send still ran with the caller's own arguments
  2. a 500 — ok: false, status preserved intact
  3. an abort — status: 0, bodySummary: 'no response', and no trace of the request body
  4. XMLHttpRequest undefined — startRecording still returns a working handle, stop() does not throw
  5. an XHR another library already patched — records through it, and gives that patch back on stop
  6. after stop() — the request still works and the recording does not grow
  7. a response with no Content-Type — bodySummary: 'not summarised', not a guess

`installFetchRecorder` sees `fetch` and nothing else, so an app on axios's
default adapter, jQuery or an older SDK recorded an empty network timeline —
and `read_network` returning nothing reads as "the page made no requests"
rather than "this recorder cannot see the ones it made". That is the worst
shape a gap can take, because it looks like an answer.

`installXhrRecorder` patches `XMLHttpRequest.prototype.open` and `.send` and
emits the same `network-request` custom event, so a mixed app reports both
transports through one shape and the agent never has to know which was used.
Teardown restores both methods, mirroring the fetch patch.

No body is read in either direction: `bodySummary` comes from the
`Content-Type` header alone, and the request body is passed through
uninspected. docs/threat-model.md (T4) is the reason — a checkout's XHR body
carries an address and a card number.

bugbait had no test runner, so this adds vitest and jsdom as devDependencies
along with the first suite: a successful GET, a 500, an abort, a host with no
`XMLHttpRequest`, an XHR another library has already patched, teardown, and a
response that declares no content type.

The three fixtures in traces/public/recordings/ are untouched.
@vercel

vercel Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
traces Ready Ready Preview Aug 31, 2026 6:29am

@ribdsp
ribdsp merged commit af413c5 into main Aug 31, 2026
4 of 5 checks passed
@ribdsp
ribdsp deleted the feat/record-xhr branch August 31, 2026 06:31

This branch was successfully deployed

1 active deployment
Preview — b48769aa Deployed Aug 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant