Skip to content

fix(extension): capture initial-page-load XHR/fetch bodies (injection-timing race)#67

Merged
nafees87n merged 5 commits into
masterfrom
fix/network-body-capture-injection-race
Jun 10, 2026
Merged

fix(extension): capture initial-page-load XHR/fetch bodies (injection-timing race)#67
nafees87n merged 5 commits into
masterfrom
fix/network-body-capture-injection-race

Conversation

@nafees87n

Copy link
Copy Markdown
Contributor

What

Fixes a v2 body-capture bug: an SPA's initial page-load XHR/fetch responses were missed, so LTS auto-correlation chained 0 rows (those bootstrap responses carry the IDs/slugs correlation needs).

Stacked on #66 (advanced settings) — base branch is feat/network-recording-advanced-settings. Re-target to master once #66 merges.

Refs RQ-2894.

Root cause

The web-sdk fetch/XHR interceptor was injected via executeScript from webNavigation.onCommitted and only armed on the START round-trip — so it landed after the page's first requests fired. Those request bodies were never captured (no webRequest fallback for xhr/fetch — it's hard-suppressed). On fast/cached SPAs this silently lost the correlation-critical bootstrap responses.

Fix — register before navigation, buffer from t=0

  • about:blank → register → navigate: create the recorded tab at about:blank, open the side panel synchronously (preserves the sidePanel.open() user gesture — about:blank doesn't consume it), then await registering the web-sdk UMD + body recorder as one ordered document_start MAIN-world content script (registerContentScripts), then chrome.tabs.update to the real URL. The interceptor is guaranteed armed before request bug: 307 in mock server leads to failure when used user with net:http module #1, and the URL loads exactly once (no double-send).
  • Buffer-until-START: the page script arms the interceptor at injection and buffers captures until START, then flushes — so even an early request is retained.
  • Pull-based READY→START handshake with retry: the page re-posts READY until START arrives. (A one-shot READY raced the content-script relay on fast loads and silently dropped, leaving the buffer un-flushed → 0 entries. This was the practicesoftwaretesting.com failure.)
  • Non-http(s) guards: clientHandler + tabService onCommitted/onDOMContentLoaded handlers now skip non-http(s) URLs, so the about:blank step doesn't log "Cannot access contents of url about:blank" / "Receiving end does not exist".
  • Lifecycle: unregister the body-recorder scripts when the last recording stops; clean up orphaned registrations on SW init.

Also

  • Raise DEFAULT_MAX_PAYLOAD_SIZE 200KB → 10MB — a "safe maximum": covers realistic API/JSON bodies, stays well under Chrome's ~32MB chrome.runtime.sendMessage ceiling (bodies stream one-per-message) so an oversized body truncates gracefully (RESPONSE_TOO_LARGE flag) instead of throwing and losing the entry. (An earlier LTS run sent maxPayloadSize: 200 — 200 bytes — and stripped all bodies; unit is bytes, documented.)

Known limitation (documented, not coded around)

START/flush delivery rides the client content-script relay, which attaches only on HTML top documents. A recorded tab whose top document is non-HTML (raw JSON/XML/no-doctype) won't capture xhr/fetch bodies. Acceptable — LTS records web apps, not raw endpoints. A comment documents how to add an SW-side watchdog if that assumption ever changes (deliberately not added — avoids machinery for a case real targets don't hit).

Approach validation

The about:blank approach was chosen after a head-to-head evaluation (vs. CDP/debugger, static-shim, accept-the-race). It's the only one that structurally guarantees request #1 is captured (await-before-navigate → document_start ordering), with zero new permissions. CDP was rejected (persistent debugging banner, mutual-exclusion with the user's DevTools, debugger permission, not race-free for body retrieval).

Testing

Manually validated on realworld.show and practicesoftwaretesting.com (the latter intermittently failed before the READY-retry fix): bootstrap XHRs captured with request+response bodies, panel counts them, no double-send, side panel opens, no console errors.

Build clean (only pre-existing TS7030/circular-dep warnings).

🤖 Generated with Claude Code

nafees87n and others added 3 commits June 9, 2026 18:46
Add four config options to startNetworkRecording (Chrome/Edge only):
- disableCache: wipe HTTP cache for the origin at record start
- wipeServiceWorkers: unregister SWs + clear Cache API at start
- recordAjax (default true): when false, xhr/fetch not recorded at all
- requestScope (default all): "top-level" records only frameId 0

Cache/SW wipes coalesce into one fire-and-forget chrome.browsingData.remove
inside the tabs.create callback (preserves the sidePanel.open gesture);
browsingData permission added to chrome+edge manifests only, feature-guarded
so Firefox/Safari no-op. Harness updated with the four toggles.

Refs RQ-3330

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- disableCache: document that it clears the ENTIRE browser HTTP cache (all
  sites), not just the recorded origin — Chrome ignores the origins filter for
  the `cache` data type.
- injectBodyRecorder: fix stale comment — when recordAjax=false, xhr/fetch are
  dropped by the webRequest path too (isAjaxRequest), so ajax is not recorded at
  all (not "falls to the webRequest skeleton path").

Comment-only; no functional change. Addresses the two confirmed (doc-only)
findings from the multi-agent PR review.

Refs RQ-3330

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-timing race)

v2 body capture missed an SPA's bootstrap XHR/fetch responses because the web-sdk
interceptor was injected via executeScript from webNavigation.onCommitted and only
armed on the START round-trip — landing after the page's first requests fired.
Those responses carry the IDs/slugs LTS auto-correlation needs, so correlation
chained 0 rows.

Fix — register before the recorded navigation, buffer from t=0:
- Create the recorded tab at about:blank, open the side panel synchronously
  (gesture intact), THEN register the web-sdk UMD + body recorder as one ordered
  document_start MAIN-world content script (registerContentScripts), THEN navigate
  to the URL. The interceptor is guaranteed armed before request #1. The URL loads
  only once, so nothing is double-captured.
- Page script arms the interceptor at injection and BUFFERS captures until START,
  then flushes — so even an early request is retained.
- Pull-based READY->START handshake with retry: the page re-posts READY until START
  arrives (the one-shot version raced the content-script relay on fast loads and
  silently dropped, leaving the buffer un-flushed / 0 entries).
- Guard clientHandler + tabService onCommitted/DOMContentLoaded handlers against
  non-http(s) URLs so the about:blank step doesn't log access errors.
- Unregister the body-recorder scripts when the last recording stops; clean up
  orphaned registrations on SW init.

Also raise DEFAULT_MAX_PAYLOAD_SIZE 200KB -> 10MB (safe maximum: covers realistic
API/JSON bodies, stays well under Chrome's ~32MB sendMessage ceiling so oversized
bodies truncate gracefully instead of throwing).

Validated on realworld.show and practicesoftwaretesting.com: bootstrap XHRs now
captured with request+response bodies; panel counts them; no double-send.

Stacked on the advanced-settings branch (PR #66).
Refs RQ-2894

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base automatically changed from feat/network-recording-advanced-settings to master June 10, 2026 10:33
nafees87n and others added 2 commits June 10, 2026 16:08
…pture-injection-race

# Conflicts:
#	browser-extension/mv3/src/service-worker/services/networkRecording/index.ts
Buffer leak: the body recorder is registered into every http(s) tab while a
recording is active, but only the recorded tab gets START. On every other tab
the interceptor sat in "buffering" forever, accumulating raw request/response
bodies for the page's lifetime. Now:
- the READY handshake, on giving up (no START after ~6s), transitions to
  "stopped" and frees the buffer — so non-recorded tabs stop capturing;
- the pre-START buffer is hard-capped (MAX_BUFFERED_ENTRIES, drop-oldest) so it
  can't grow unbounded even before give-up / on a slow-to-START recorded tab.
Late START (>6s, effectively never for a recorded tab) is ignored — the
recorded-tab live path is unaffected (START arrives in ms).

Focus: navigate the recorded tab with { active: true } so Chrome moves focus to
the page instead of leaving it in the omnibox (the about:blank tab held no
focus). Best-effort nudge.

Addresses the buffer-leak finding from the PR review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nafees87n nafees87n merged commit 5e561d0 into master Jun 10, 2026
4 checks passed
@nafees87n nafees87n deleted the fix/network-body-capture-injection-race branch June 10, 2026 11:55
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.

2 participants