feat: record XMLHttpRequest traffic, not just fetch - #18
Merged
Merged
Conversation
`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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
bugbait/src/lib/record.tshad zero occurrences ofXMLHttpRequest. The only network recorder wasinstallFetchRecorder, so an app on axios's default adapter, jQuery, or an older SDK produced arecording with an empty network timeline — and
read_networkreturning 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, patchingXMLHttpRequest.prototype.openand.send.record.addCustomEvent('network-request', { url, method, status, ok, durationMs, bodySummary })— the field set documented attraces/src/lib/replay/rrweb-events.ts:33and parsed bytraces/src/lib/webmcp/tools/read-network.ts. No new field, no renamed field.installFetchRecorderreturns a closure that restoreswindow.fetch; this returns one that restores both prototype methods. Both are called fromhandle.stop().loadend, the one event that fires for all four outcomes (load, error, abort, timeout).status === 0separates the three that never produced a response — the same split the fetch patch gets from try/catch.contentTypeSummaryhelper, extracted from the fetch patch, so the two cannot drift on the exact"…, not summarised"stringread_networkrenders.Masking: no body, in either direction
bodySummaryis derived from theContent-Typeheader alone. Reading the response would meanresponseText, and that is where this recorder stops. The request body handed tosendis passedstraight through uninspected.
maskAllInputs: trueis the standing rule for typed values and a network payload is the same class ofrisk —
docs/threat-model.md(T4). A checkout is exactly the app whose XHR bodies carry an address anda card number. Reading the header also sidesteps the
InvalidStateErrorthatresponseTextthrows whenresponseTypeisarraybuffer,bloborjson.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:
openandsend; if either is non-writable,installXhrRecorderreturns a no-op teardown. A missing network timeline is a far smaller problem than astartRecordingthat throws.typeof XMLHttpRequest === 'undefined'returns a no-op teardown too, so the caller needs no branch.Public API surface: unchanged
installXhrRecorderis not exported. The tests drive it throughstartRecording()/handle.stop()with a stubbed
XMLHttpRequest, so nothing new is on the module's surface — no need for thestop-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.tsunderbugbait/src, notestscript, no vitest config. Rather than skip thetests, this adds the runner and uses
traces/src/lib/webmcp/tool-change.test.tsas the style reference(file-level docblock explaining why the test exists,
// Arrange/// Act/// Assert, longdescriptive
itnames).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 installin bugbait: 0vulnerabilities.
There is no
.github/workflows/directory, so CI neither runs nor is broken by this suite; it runs vianpm testin 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 whatread_networkcan and cannot see: both transports patched;navigator.sendBeacon, WebSockets andEventSourcestill invisible; every committed fixture predates the XHR patch and usesfetchonly, so the test file is what covers that path.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 statusreports zero changesunder that path. Nothing was regenerated or re-recorded.
Verification
npx tsc --noEmitnpm run lintnpx vitest runZero ESLint rules disabled. No assertion weakened, skipped or deleted anywhere.
The seven tests
toEqual, plus that the underlyingopen/sendstill ran with the caller's own argumentsok: false, status preserved intactstatus: 0,bodySummary: 'no response', and no trace of the request bodyXMLHttpRequestundefined —startRecordingstill returns a working handle,stop()does not throwstopstop()— the request still works and the recording does not growContent-Type—bodySummary: 'not summarised', not a guess