Skip to content

Commit 66f029a

Browse files
Cryptoteepclaude
andcommitted
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 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 #6376 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 47ede39 commit 66f029a

6 files changed

Lines changed: 756 additions & 8 deletions

File tree

packages/core/src/js/replay/mobilereplay.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint, Integration, Metric } from '@sentry/core';
2-
3-
import { debug } from '@sentry/core';
1+
import type {
2+
Breadcrumb,
3+
BreadcrumbHint,
4+
Client,
5+
DynamicSamplingContext,
6+
ErrorEvent,
7+
Event,
8+
EventHint,
9+
Integration,
10+
Metric,
11+
} from '@sentry/core';
12+
13+
import { addBreadcrumb, debug } from '@sentry/core';
414

515
import type { ResolvedNetworkOptions } from './networkUtils';
616

@@ -9,7 +19,12 @@ import { hasHooks } from '../utils/clientutils';
919
import { isExpoGo, notMobileOs } from '../utils/environment';
1020
import { registerFeatureMarker } from '../utils/featureMarkers';
1121
import { NATIVE } from '../wrapper';
12-
import { makeEnrichXhrBreadcrumbsForMobileReplay } from './xhrUtils';
22+
import {
23+
makeEnrichXhrBreadcrumbsForMobileReplay,
24+
REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY,
25+
resolveXhrResponseBody,
26+
shouldCaptureResponseBodyAsync,
27+
} from './xhrUtils';
1328

1429
const MOBILE_REPLAY_NETWORK_DETAILS_INTEGRATION_NAME = 'MobileReplayNetworkDetails';
1530
const MOBILE_REPLAY_NETWORK_BODIES_INTEGRATION_NAME = 'MobileReplayNetworkBodies';
@@ -433,6 +448,35 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
433448

434449
// Wrap beforeSend to run processEvent after user's beforeSend
435450
const clientOptions = client.getOptions();
451+
452+
// Binary (Blob/ArrayBuffer) response bodies — which is every `fetch`
453+
// response, since RN's fetch polyfill uses XHR with responseType 'blob' —
454+
// can only be read asynchronously, but the breadcrumb is forwarded to the
455+
// native SDKs synchronously. Hold such breadcrumbs here (return null),
456+
// read the body, then re-add the same breadcrumb (timestamp is already
457+
// set, so it keeps its original time) with the body resolved on the hint.
458+
if (networkOptions.captureBodies && networkOptions.allowUrls.length > 0) {
459+
const originalBeforeBreadcrumb = clientOptions.beforeBreadcrumb;
460+
clientOptions.beforeBreadcrumb = (breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null => {
461+
if (hint && REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY in hint) {
462+
// second pass with the resolved body — the user's beforeBreadcrumb already ran
463+
return breadcrumb;
464+
}
465+
const result = originalBeforeBreadcrumb ? originalBeforeBreadcrumb(breadcrumb, hint) : breadcrumb;
466+
if (result === null || !shouldCaptureResponseBodyAsync(result, hint, networkOptions)) {
467+
return result;
468+
}
469+
const xhr = hint.xhr;
470+
resolveXhrResponseBody(xhr)
471+
.then(resolvedBody => {
472+
addBreadcrumb(result, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolvedBody });
473+
})
474+
.then(undefined, (error: unknown) => {
475+
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to re-add network breadcrumb`, error);
476+
});
477+
return null;
478+
};
479+
}
436480
const originalBeforeSend = clientOptions.beforeSend;
437481
clientOptions.beforeSend = async (event: ErrorEvent, hint: EventHint): Promise<ErrorEvent | null> => {
438482
let result: ErrorEvent | null = event;

packages/core/src/js/replay/networkUtils.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ function _serializeFormData(formData: FormData): string {
5757

5858
export const NETWORK_BODY_MAX_SIZE = 150_000;
5959

60+
/** How long to wait for an async body read (FileReader) before giving up. */
61+
export const NETWORK_BODY_READ_TIMEOUT_MS = 500;
62+
6063
export const DEFAULT_NETWORK_HEADERS = ['content-type', 'content-length', 'accept'];
6164

6265
const DENY_HEADERS = new Set([
@@ -174,6 +177,132 @@ export function getBodyString(body: unknown): NetworkBody | undefined {
174177
}
175178
}
176179

180+
/**
181+
* Whether a Content-Type describes a payload that is safe to decode into text
182+
* (JSON, XML, form data, `text/*`). Genuinely binary payloads (images, media,
183+
* octet-stream) are excluded so they stay marked as unparseable.
184+
*/
185+
export function isTextLikeContentType(contentType: string | null | undefined): boolean {
186+
if (!contentType) {
187+
return false;
188+
}
189+
const normalized = contentType.toLowerCase();
190+
return (
191+
normalized.startsWith('text/') ||
192+
normalized.includes('json') ||
193+
normalized.includes('xml') ||
194+
normalized.includes('x-www-form-urlencoded')
195+
);
196+
}
197+
198+
/**
199+
* Read a Blob as UTF-8 text via FileReader (React Native's Blob has no `text()`).
200+
* Rejects on read error, abort or after `timeoutMs`.
201+
*/
202+
export function readBlobAsText(blob: Blob, timeoutMs: number): Promise<string> {
203+
return new Promise((resolve, reject) => {
204+
const reader = new FileReader();
205+
const timeout = setTimeout(() => {
206+
// reject first — abort() may fire onabort synchronously
207+
reject(new Error(`Timed out reading response body after ${timeoutMs}ms`));
208+
try {
209+
reader.abort();
210+
} catch {
211+
// ignore — already rejected
212+
}
213+
}, timeoutMs);
214+
reader.onload = () => {
215+
clearTimeout(timeout);
216+
const result = reader.result;
217+
if (typeof result === 'string') {
218+
resolve(result);
219+
} else {
220+
reject(new Error('FileReader did not produce a string result'));
221+
}
222+
};
223+
reader.onerror = () => {
224+
clearTimeout(timeout);
225+
reject(reader.error ?? new Error('FileReader failed'));
226+
};
227+
reader.onabort = () => {
228+
clearTimeout(timeout);
229+
reject(new Error('FileReader aborted'));
230+
};
231+
reader.readAsText(blob);
232+
});
233+
}
234+
235+
type TextDecoderLike = { decode(input: Uint8Array): string };
236+
237+
/* oxlint-disable eslint(no-bitwise) -- decoding UTF-8 is inherently bit manipulation */
238+
/**
239+
* Decode UTF-8 bytes into a string. Uses the global TextDecoder when the JS
240+
* engine provides one and falls back to a manual decoder otherwise (Hermes
241+
* has no TextDecoder). Invalid sequences decode to U+FFFD.
242+
*/
243+
export function decodeUtf8(bytes: Uint8Array): string {
244+
const TextDecoderConstructor = (globalThis as { TextDecoder?: new () => TextDecoderLike }).TextDecoder;
245+
if (TextDecoderConstructor) {
246+
try {
247+
return new TextDecoderConstructor().decode(bytes);
248+
} catch {
249+
// fall through to the manual decoder
250+
}
251+
}
252+
253+
let out = '';
254+
let i = 0;
255+
while (i < bytes.length) {
256+
const byte = bytes[i]!;
257+
let codePoint: number;
258+
let extraBytes: number;
259+
if (byte < 0x80) {
260+
codePoint = byte;
261+
extraBytes = 0;
262+
} else if ((byte & 0xe0) === 0xc0) {
263+
codePoint = byte & 0x1f;
264+
extraBytes = 1;
265+
} else if ((byte & 0xf0) === 0xe0) {
266+
codePoint = byte & 0x0f;
267+
extraBytes = 2;
268+
} else if ((byte & 0xf8) === 0xf0) {
269+
codePoint = byte & 0x07;
270+
extraBytes = 3;
271+
} else {
272+
out += '�';
273+
i += 1;
274+
continue;
275+
}
276+
277+
if (i + extraBytes >= bytes.length) {
278+
// truncated sequence at the end of the buffer
279+
out += '�';
280+
break;
281+
}
282+
283+
let valid = true;
284+
for (let j = 1; j <= extraBytes; j++) {
285+
const continuation = bytes[i + j]!;
286+
if ((continuation & 0xc0) !== 0x80) {
287+
valid = false;
288+
break;
289+
}
290+
codePoint = (codePoint << 6) | (continuation & 0x3f);
291+
}
292+
293+
if (!valid || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) {
294+
out += '�';
295+
i += 1;
296+
continue;
297+
}
298+
299+
out += String.fromCodePoint(codePoint);
300+
i += extraBytes + 1;
301+
}
302+
return out;
303+
}
304+
/* oxlint-enable eslint(no-bitwise) */
305+
177306
/**
178307
* Filter a headers map down to the set explicitly captured (defaults + user-supplied)
179308
* and strip authorization-like headers. Header name comparison is case-insensitive;

packages/core/src/js/replay/xhrUtils.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import { dropUndefinedKeys } from '@sentry/core';
55
import type { NetworkBody, RequestBody, ResolvedNetworkOptions } from './networkUtils';
66

77
import {
8+
decodeUtf8,
89
filterHeaders,
910
getBodySize,
1011
getBodyString,
12+
isTextLikeContentType,
13+
NETWORK_BODY_MAX_SIZE,
14+
NETWORK_BODY_READ_TIMEOUT_MS,
1115
parseAllResponseHeaders,
1216
parseContentLengthHeader,
17+
readBlobAsText,
1318
shouldCaptureNetworkDetails,
1419
} from './networkUtils';
1520

@@ -19,6 +24,15 @@ interface NetworkBreadcrumbSide {
1924
_meta?: { warnings: string[] };
2025
}
2126

27+
/**
28+
* Hint key carrying a response body that was read asynchronously (Blob /
29+
* ArrayBuffer responses) before the breadcrumb was re-added. When present,
30+
* enrichment uses it instead of reading `xhr.response` synchronously.
31+
*/
32+
export const REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY = '__mobile_replay_resolved_response_body__';
33+
34+
type ResolvedBodyCarrier = { [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]?: NetworkBody };
35+
2236
const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = {
2337
allowUrls: [],
2438
denyUrls: [],
@@ -75,7 +89,11 @@ function enrichXhrBreadcrumb(
7589

7690
if (shouldCaptureNetworkDetails(url, networkOptions)) {
7791
request = _buildRequestDetails(input, xhr, networkOptions);
78-
response = _buildResponseDetails(xhr, networkOptions);
92+
response = _buildResponseDetails(
93+
xhr,
94+
networkOptions,
95+
(hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY],
96+
);
7997
}
8098

8199
breadcrumb.data = dropUndefinedKeys({
@@ -108,6 +126,7 @@ function _buildRequestDetails(
108126
function _buildResponseDetails(
109127
xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest,
110128
networkOptions: ResolvedNetworkOptions,
129+
resolvedBody: NetworkBody | undefined,
111130
): NetworkBreadcrumbSide | undefined {
112131
let rawHeaders: string | null = null;
113132
try {
@@ -119,7 +138,7 @@ function _buildResponseDetails(
119138

120139
let body: NetworkBody | undefined;
121140
if (networkOptions.captureBodies) {
122-
body = _getResponseBodyString(xhr);
141+
body = resolvedBody ?? _getResponseBodyString(xhr);
123142
}
124143

125144
return _toBreadcrumbSide(headers, body);
@@ -174,6 +193,81 @@ type XhrHint = XhrBreadcrumbHint & {
174193
input?: RequestBody;
175194
};
176195

196+
/**
197+
* Whether this xhr breadcrumb's response body can only be captured asynchronously:
198+
* a binary responseType (`blob` / `arraybuffer`) holding a text-like payload, for
199+
* an allow-listed URL with body capture enabled. React Native's `fetch` polyfill
200+
* always uses responseType `blob`, so every `fetch` response takes this path.
201+
*/
202+
export function shouldCaptureResponseBodyAsync(
203+
breadcrumb: Breadcrumb,
204+
hint: BreadcrumbHint | undefined,
205+
networkOptions: ResolvedNetworkOptions,
206+
): hint is XhrHint {
207+
if (breadcrumb.category !== 'xhr' || !hint) {
208+
return false;
209+
}
210+
if ((hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY] !== undefined) {
211+
// already resolved — this is the re-added breadcrumb
212+
return false;
213+
}
214+
const xhr = (hint as Partial<XhrHint>).xhr;
215+
if (!xhr || (xhr.responseType !== 'blob' && xhr.responseType !== 'arraybuffer') || xhr.response == null) {
216+
return false;
217+
}
218+
if (!networkOptions.captureBodies) {
219+
return false;
220+
}
221+
const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : undefined;
222+
if (!shouldCaptureNetworkDetails(url, networkOptions)) {
223+
return false;
224+
}
225+
let contentType: string | null = null;
226+
try {
227+
contentType = xhr.getResponseHeader('content-type');
228+
} catch {
229+
// ignore — treated as non-text below
230+
}
231+
return isTextLikeContentType(contentType);
232+
}
233+
234+
/**
235+
* Read the body of a binary (`blob` / `arraybuffer`) XHR response and serialize
236+
* it like a text body (size cap + truncation warning). Resolves to an
237+
* UNPARSEABLE_BODY_TYPE warning on read failure or timeout — never rejects.
238+
*/
239+
export async function resolveXhrResponseBody(xhr: XMLHttpRequest): Promise<NetworkBody> {
240+
try {
241+
if (xhr.responseType === 'blob') {
242+
const blob = xhr.response as Blob;
243+
const truncated = blob.size > NETWORK_BODY_MAX_SIZE;
244+
// Slice before reading so a huge payload is never fully read into memory.
245+
const capped = truncated ? blob.slice(0, NETWORK_BODY_MAX_SIZE) : blob;
246+
const text = await readBlobAsText(capped, NETWORK_BODY_READ_TIMEOUT_MS);
247+
return _toCappedBody(text, truncated);
248+
}
249+
if (xhr.responseType === 'arraybuffer') {
250+
const buffer = xhr.response as ArrayBuffer;
251+
const truncated = buffer.byteLength > NETWORK_BODY_MAX_SIZE;
252+
const bytes = new Uint8Array(buffer, 0, truncated ? NETWORK_BODY_MAX_SIZE : buffer.byteLength);
253+
return _toCappedBody(decodeUtf8(bytes), truncated);
254+
}
255+
} catch {
256+
// fall through to the unparseable marker
257+
}
258+
return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } };
259+
}
260+
261+
function _toCappedBody(text: string, truncated: boolean): NetworkBody {
262+
// The byte cap above already keeps `text` at or below the char cap
263+
// (UTF-8 is at least one byte per char), so only the warning is left to add.
264+
const body = getBodyString(text) ?? { body: text };
265+
if (truncated) {
266+
return { ...body, _meta: { warnings: [...(body._meta?.warnings ?? []), 'MAX_BODY_SIZE_EXCEEDED'] } };
267+
}
268+
return body;
269+
}
270+
177271
function _getBodySize(
178272
body: XMLHttpRequest['response'],
179273
responseType: XMLHttpRequest['responseType'],

0 commit comments

Comments
 (0)