feat(replay): capture fetch (Blob/ArrayBuffer) response bodies in Session Replay network details - #6473
Conversation
…sion Replay network details React Native's fetch polyfill is built on XMLHttpRequest with responseType 'blob', so every fetch response body previously surfaced as [UNPARSEABLE_BODY_TYPE] in the Replay network tab even when the payload was plain JSON or text. Binary bodies can only be read asynchronously, while xhr breadcrumbs are forwarded to the native SDKs synchronously. When an allow-listed xhr breadcrumb carries a text-like (JSON/XML/text/form) Blob or ArrayBuffer response and body capture is enabled, the breadcrumb is now held in beforeBreadcrumb, the body is read (FileReader for Blob with a 500ms timeout, capped at NETWORK_BODY_MAX_SIZE by slicing before the read; manual UTF-8 decode for ArrayBuffer since Hermes has no TextDecoder), and the same breadcrumb is re-added with the resolved body on the hint. Its original timestamp is preserved. Genuinely binary payloads (images, octet-stream) keep the UNPARSEABLE_BODY_TYPE marker without being read, and read failures or timeouts fall back to the same marker. Closes getsentry#6376 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
…f-8 decoder dropping bytes after an interrupted sequence - resolveXhrResponseBody now snapshots request headers, raw response headers and the response body size synchronously, and the re-added breadcrumb is enriched only from that snapshot — the live xhr may have been reused or cleared during the async gap (Bugbot: stale XHR data). - the manual UTF-8 fallback decoder no longer discards bytes following a truncated/interrupted multi-byte sequence; the consumed prefix decodes to a single U+FFFD (maximal subpart), matching TextDecoder. - drop non-null assertions flagged by oxlint no-unnecessary-type-assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lid code point A structurally complete sequence that decodes to a surrogate or a code point above U+10FFFF previously advanced by one byte, so its continuation bytes were re-decoded as stray bytes and produced extra U+FFFDs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e479547. Configure here.
| .then(undefined, (error: unknown) => { | ||
| debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to re-add network breadcrumb`, error); | ||
| }); | ||
| return null; |
There was a problem hiding this comment.
Held fetch breadcrumbs miss error events
High Severity
Returning null from beforeBreadcrumb while awaiting FileReader drops allow-listed fetch xhr breadcrumbs from the scope until the read finishes. RN fetch always uses responseType: 'blob', and FileReader completion runs after promise microtasks, so errors thrown in fetch response handlers (and hard crashes in that window) no longer include that network breadcrumb — a regression versus the previous synchronous UNPARSEABLE_BODY_TYPE path. Flagged under the PR review correctness guidance for instrumentation that must not lose telemetry on common error paths.
Additional Locations (2)
Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit e479547. Configure here.
alwx
left a comment
There was a problem hiding this comment.
Nicely scoped and well tested. A few small things inline — none blocking.
| export const NETWORK_BODY_MAX_SIZE = 150_000; | ||
|
|
||
| /** How long to wait for an async body read (FileReader) before giving up. */ | ||
| export const NETWORK_BODY_READ_TIMEOUT_MS = 500; |
There was a problem hiding this comment.
500ms feels tight for a large JSON on a cold Hermes VM.
| * engine provides one and falls back to a manual decoder otherwise (Hermes | ||
| * has no TextDecoder). Invalid sequences decode to U+FFFD. | ||
| */ | ||
| export function decodeUtf8(bytes: Uint8Array): string { |
There was a problem hiding this comment.
That's a lot of UTF-8 decoding to own. Hermes has had TextDecoder since RN 0.71
|
Since there is no activity in this PR so I'm going to close it and carry the work over to #6533. |
…sion Replay network details React Native's fetch polyfill is built on XMLHttpRequest with responseType 'blob', so every fetch response body previously surfaced as [UNPARSEABLE_BODY_TYPE] in the Replay network tab even when the payload was plain JSON or text. Binary bodies can only be read asynchronously, while xhr breadcrumbs are forwarded to the native SDKs synchronously. When an allow-listed xhr breadcrumb carries a text-like (JSON/XML/text/form) Blob or ArrayBuffer response and body capture is enabled, the breadcrumb is held in beforeBreadcrumb, the body is read (FileReader for Blob with a 500ms timeout, capped at NETWORK_BODY_MAX_SIZE by slicing before the read; manual UTF-8 decode for ArrayBuffer since Hermes has no TextDecoder), and the same breadcrumb is re-added with the resolved body on the hint. Its original timestamp is preserved. Genuinely binary payloads (images, octet-stream) keep the UNPARSEABLE_BODY_TYPE marker without being read, and read failures or timeouts fall back to the same marker. Squashed from #6473. Closes #6376
…sion Replay network details (#6533) * feat(replay): capture fetch (Blob/ArrayBuffer) response bodies in Session Replay network details React Native's fetch polyfill is built on XMLHttpRequest with responseType 'blob', so every fetch response body previously surfaced as [UNPARSEABLE_BODY_TYPE] in the Replay network tab even when the payload was plain JSON or text. Binary bodies can only be read asynchronously, while xhr breadcrumbs are forwarded to the native SDKs synchronously. When an allow-listed xhr breadcrumb carries a text-like (JSON/XML/text/form) Blob or ArrayBuffer response and body capture is enabled, the breadcrumb is held in beforeBreadcrumb, the body is read (FileReader for Blob with a 500ms timeout, capped at NETWORK_BODY_MAX_SIZE by slicing before the read; manual UTF-8 decode for ArrayBuffer since Hermes has no TextDecoder), and the same breadcrumb is re-added with the resolved body on the hint. Its original timestamp is preserved. Genuinely binary payloads (images, octet-stream) keep the UNPARSEABLE_BODY_TYPE marker without being read, and read failures or timeouts fall back to the same marker. Squashed from #6473. Closes #6376 * fix(replay): keep held network breadcrumbs on the scope, defer only the native sync Holding the breadcrumb in beforeBreadcrumb (returning null) until the async body read finished removed it from the scope entirely, so an error captured in that window lost it from event.breadcrumbs. The common `if (!res.ok) throw` path hits this reliably: the app never reads the body, so nothing yields long enough for the FileReader to land before the throw. Breadcrumbs are also filtered by timestamp into replay segments on Android, so a re-added crumb could be dropped from the replay when it straddled a segment boundary. The breadcrumb is now returned as before and lands on the JS scope immediately. Only the sync to native — which is what feeds the Replay network tab, since the native converters build the rrweb span from the synced xhr breadcrumb — is deferred, via deferBreadcrumbNativeSync/syncBreadcrumbToNative in scopeSync, and performed once with the resolved body. buildResolvedNetworkBreadcrumb builds the native-bound copy so the breadcrumb already on the scope is never mutated. Also in this change: - Retry a truncated blob read with a shorter slice. Slicing at a byte offset can cut a multi-byte UTF-8 sequence, and iOS decodes via -[NSString initWithData:encoding:], which returns nil for the whole chunk rather than substituting U+FFFD — so bodies over the cap silently fell back to UNPARSEABLE_BODY_TYPE. readBlobAsText now rejects with a distinguishable BLOB_DECODE_FAILED and the read retries with up to 3 fewer bytes, which is guaranteed to reach a character boundary. Timeouts and read errors are not retried. - Match javascript/ecmascript content types as text-like. - Update the networkDetailAllowUrls docs, which still said fetch bodies were unsupported. - Correct the abort() comment: RN's FileReader never enters LOADING, so abort() dispatches no events and does not cancel the native read. - Note in decodeUtf8 that the manual decoder is the path actually taken — neither Hermes nor JSC ships TextDecoder, so the TextDecoder branch only runs in tests. Adds test/replay/networkBodyCapture.test.ts, which exercises the whole flow against the real @sentry/core pipeline and the real scope sync patch. It guards the object-identity assumption the deferral relies on: the breadcrumb returned from beforeBreadcrumb must be the exact object passed to scope.addBreadcrumb. * Update CHANGELOG.md Co-authored-by: Antonis Lilis <antonis.lilis@sentry.io> --------- Co-authored-by: Andreev Kirill Andreevich <andreev.gh2017@yandex.ru> Co-authored-by: Antonis Lilis <antonis.lilis@sentry.io>


📜 Description
Closes #6376
In React Native,
fetchis the whatwg-fetch polyfill built onXMLHttpRequestwithresponseType = 'blob', so everyfetchresponse body landed in the binary branch of_getResponseBodyStringand surfaced as[UNPARSEABLE_BODY_TYPE]in the Replay network tab — even when the payload was plain JSON or text. This PR inlines parseable Blob/ArrayBuffer response bodies like text bodies, following the approach sketched in the issue.The async capture path
Binary bodies can only be read asynchronously (
FileReader), but xhr breadcrumbs are forwarded to the native SDKs synchronously (thebeforeAddBreadcrumbenrichment hook runs right beforescope.addBreadcrumb, which syncs to native). So the enrichment itself can't wait for the read.Instead, when body capture is enabled and an allow-listed xhr breadcrumb carries a text-like binary response, the wrapped
beforeBreadcrumb(same wrapping pattern the integration already uses forbeforeSend):null),FileReaderforblobwith a 500 ms timeout, slicing toNETWORK_BODY_MAX_SIZEbefore the read so a huge payload is never fully read into memory; manual UTF-8 decode forarraybuffer(Hermes has noTextDecoder; uses it when available),addBreadcrumbwith the resolved body carried on the hint. Itstimestampwas set on the first pass and is preserved; the user'sbeforeBreadcrumbruns only on the first pass; the resolved-body hint key makes the second pass skip the hold branch, and the existingbeforeAddBreadcrumbenrichment uses the resolved body instead of readingxhr.response.Guardrails, per the issue's considerations:
networkDetailAllowUrls(minusnetworkDetailDenyUrls) withnetworkCaptureBodiesenabled;beforeBreadcrumbisn't wrapped at all otherwise.text/*, form-urlencoded) qualify; everything else keeps theUNPARSEABLE_BODY_TYPEmarker as before.UNPARSEABLE_BODY_TYPE— never worse data than today.NETWORK_BODY_MAX_SIZEwith the existingMAX_BODY_SIZE_EXCEEDEDwarning.Trade-off to be aware of: a held breadcrumb reaches the scope (and native) a few ms later than its neighbors, so strict insertion order among breadcrumbs can shift; the breadcrumb's own timestamp stays correct. Request bodies for
fetch(Request/RequestInit.bodyBlobs) are intentionally left for a follow-up, as noted in the issue.💚 How did you test it?
networkUtils,xhrUtilsandmobilereplaycovering: content-type classification, the manual UTF-8 decoder (multi-byte, invalid sequences, no-TextDecoder path), FileReader success/error/timeout, blob slicing + truncation warning, the hold/re-add flow, single execution of the user'sbeforeBreadcrumb, user drops staying dropped, and no wrapping when capture is off.test/replaysuite green (115 tests);tsc -p tsconfig.build.json,oxlintandoxfmtclean.📝 Checklist
networkDetailAllowUrls+networkCaptureBodies, auth-like headers still stripped).🤖 Generated with Claude Code