From c7f4b73c1c1b0b6067c61806091d0ec6a916e3c3 Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Tue, 28 Jul 2026 15:09:17 +0200 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 4 + packages/core/src/js/replay/mobilereplay.ts | 53 +++- packages/core/src/js/replay/networkUtils.ts | 133 +++++++++ packages/core/src/js/replay/xhrUtils.ts | 166 ++++++++++- .../core/test/replay/mobilereplay.test.ts | 148 +++++++++- .../core/test/replay/networkUtils.test.ts | 143 ++++++++- packages/core/test/replay/xhrUtils.test.ts | 272 ++++++++++++++++++ 7 files changed, 898 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea29b7f08..5d693d99ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module...{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`. +- Session Replay network details now capture `fetch` (Blob/ArrayBuffer) response bodies ([#6473](https://github.com/getsentry/sentry-react-native/pull/6473)) + + React Native's `fetch` is built on XMLHttpRequest with `responseType: 'blob'`, so fetch response bodies previously always showed `[UNPARSEABLE_BODY_TYPE]` in the Replay network tab. Text-like binary payloads (JSON, XML, `text/*`, form data) are now read asynchronously and inlined like text bodies — still only for URLs matching `networkDetailAllowUrls` with `networkCaptureBodies` enabled, with the same size cap. Genuinely binary payloads (images, media) remain marked as unparseable. + ### Changes - Expose `instrumentStateGraph` for manual LangGraph instrumentation ([#6520](https://github.com/getsentry/sentry-react-native/pull/6520)) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 9064d8304c..c1ce99372c 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -1,6 +1,16 @@ -import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint, Integration, Metric } from '@sentry/core'; - -import { debug } from '@sentry/core'; +import type { + Breadcrumb, + BreadcrumbHint, + Client, + DynamicSamplingContext, + ErrorEvent, + Event, + EventHint, + Integration, + Metric, +} from '@sentry/core'; + +import { addBreadcrumb, debug } from '@sentry/core'; import type { ResolvedNetworkOptions } from './networkUtils'; @@ -9,7 +19,12 @@ import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { registerFeatureMarker } from '../utils/featureMarkers'; import { NATIVE } from '../wrapper'; -import { makeEnrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; +import { + makeEnrichXhrBreadcrumbsForMobileReplay, + REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY, + resolveXhrResponseBody, + shouldCaptureResponseBodyAsync, +} from './xhrUtils'; const MOBILE_REPLAY_NETWORK_DETAILS_INTEGRATION_NAME = 'MobileReplayNetworkDetails'; const MOBILE_REPLAY_NETWORK_BODIES_INTEGRATION_NAME = 'MobileReplayNetworkBodies'; @@ -433,6 +448,36 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Wrap beforeSend to run processEvent after user's beforeSend const clientOptions = client.getOptions(); + + // Binary (Blob/ArrayBuffer) response bodies — which is every `fetch` + // response, since RN's fetch polyfill uses XHR with responseType 'blob' — + // can only be read asynchronously, but the breadcrumb is forwarded to the + // native SDKs synchronously. Hold such breadcrumbs here (return null), + // read the body, then re-add the same breadcrumb (timestamp is already + // set, so it keeps its original time) with the resolved body and a + // metadata snapshot on the hint — the xhr itself may be reused by then. + if (networkOptions.captureBodies && networkOptions.allowUrls.length > 0) { + const originalBeforeBreadcrumb = clientOptions.beforeBreadcrumb; + clientOptions.beforeBreadcrumb = (breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null => { + if (hint && REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY in hint) { + // second pass with the resolved body — the user's beforeBreadcrumb already ran + return breadcrumb; + } + const result = originalBeforeBreadcrumb ? originalBeforeBreadcrumb(breadcrumb, hint) : breadcrumb; + if (result === null || !shouldCaptureResponseBodyAsync(result, hint, networkOptions)) { + return result; + } + const xhr = hint.xhr; + resolveXhrResponseBody(xhr) + .then(resolved => { + addBreadcrumb(result, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolved }); + }) + .then(undefined, (error: unknown) => { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to re-add network breadcrumb`, error); + }); + return null; + }; + } const originalBeforeSend = clientOptions.beforeSend; clientOptions.beforeSend = async (event: ErrorEvent, hint: EventHint): Promise => { let result: ErrorEvent | null = event; diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 469bb4b125..9468216466 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -57,6 +57,9 @@ function _serializeFormData(formData: FormData): string { 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; + export const DEFAULT_NETWORK_HEADERS = ['content-type', 'content-length', 'accept']; const DENY_HEADERS = new Set([ @@ -174,6 +177,136 @@ export function getBodyString(body: unknown): NetworkBody | undefined { } } +/** + * Whether a Content-Type describes a payload that is safe to decode into text + * (JSON, XML, form data, `text/*`). Genuinely binary payloads (images, media, + * octet-stream) are excluded so they stay marked as unparseable. + */ +export function isTextLikeContentType(contentType: string | null | undefined): boolean { + if (!contentType) { + return false; + } + const normalized = contentType.toLowerCase(); + return ( + normalized.startsWith('text/') || + normalized.includes('json') || + normalized.includes('xml') || + normalized.includes('x-www-form-urlencoded') + ); +} + +/** + * Read a Blob as UTF-8 text via FileReader (React Native's Blob has no `text()`). + * Rejects on read error, abort or after `timeoutMs`. + */ +export function readBlobAsText(blob: Blob, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + const timeout = setTimeout(() => { + // reject first — abort() may fire onabort synchronously + reject(new Error(`Timed out reading response body after ${timeoutMs}ms`)); + try { + reader.abort(); + } catch { + // ignore — already rejected + } + }, timeoutMs); + reader.onload = () => { + clearTimeout(timeout); + const result = reader.result; + if (typeof result === 'string') { + resolve(result); + } else { + reject(new Error('FileReader did not produce a string result')); + } + }; + reader.onerror = () => { + clearTimeout(timeout); + reject(reader.error ?? new Error('FileReader failed')); + }; + reader.onabort = () => { + clearTimeout(timeout); + reject(new Error('FileReader aborted')); + }; + reader.readAsText(blob); + }); +} + +type TextDecoderLike = { decode(input: Uint8Array): string }; + +/* oxlint-disable eslint(no-bitwise) -- decoding UTF-8 is inherently bit manipulation */ +/** + * Decode UTF-8 bytes into a string. Uses the global TextDecoder when the JS + * 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 { + const TextDecoderConstructor = (globalThis as { TextDecoder?: new () => TextDecoderLike }).TextDecoder; + if (TextDecoderConstructor) { + try { + return new TextDecoderConstructor().decode(bytes); + } catch { + // fall through to the manual decoder + } + } + + let out = ''; + let i = 0; + while (i < bytes.length) { + const byte = bytes[i] ?? 0; + let codePoint: number; + let extraBytes: number; + if (byte < 0x80) { + codePoint = byte; + extraBytes = 0; + } else if ((byte & 0xe0) === 0xc0) { + codePoint = byte & 0x1f; + extraBytes = 1; + } else if ((byte & 0xf0) === 0xe0) { + codePoint = byte & 0x0f; + extraBytes = 2; + } else if ((byte & 0xf8) === 0xf0) { + codePoint = byte & 0x07; + extraBytes = 3; + } else { + out += '�'; + i += 1; + continue; + } + + let consumed = 0; + while (consumed < extraBytes && i + 1 + consumed < bytes.length) { + const continuation = bytes[i + 1 + consumed] ?? 0; + if ((continuation & 0xc0) !== 0x80) { + break; + } + codePoint = (codePoint << 6) | (continuation & 0x3f); + consumed += 1; + } + + if (consumed < extraBytes) { + // truncated or interrupted sequence: the consumed prefix decodes to one + // U+FFFD and decoding resumes at the offending byte (maximal subpart) + out += '�'; + i += consumed + 1; + continue; + } + + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + // structurally complete sequence encoding an invalid code point + // (surrogate or beyond U+10FFFF): the whole sequence is one U+FFFD + out += '�'; + i += extraBytes + 1; + continue; + } + + out += String.fromCodePoint(codePoint); + i += extraBytes + 1; + } + return out; +} +/* oxlint-enable eslint(no-bitwise) */ + /** * Filter a headers map down to the set explicitly captured (defaults + user-supplied) * and strip authorization-like headers. Header name comparison is case-insensitive; diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 593197e6e7..2ff34d76c9 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -5,11 +5,16 @@ import { dropUndefinedKeys } from '@sentry/core'; import type { NetworkBody, RequestBody, ResolvedNetworkOptions } from './networkUtils'; import { + decodeUtf8, filterHeaders, getBodySize, getBodyString, + isTextLikeContentType, + NETWORK_BODY_MAX_SIZE, + NETWORK_BODY_READ_TIMEOUT_MS, parseAllResponseHeaders, parseContentLengthHeader, + readBlobAsText, shouldCaptureNetworkDetails, } from './networkUtils'; @@ -19,6 +24,28 @@ interface NetworkBreadcrumbSide { _meta?: { warnings: string[] }; } +/** + * Hint key carrying the result of `resolveXhrResponseBody` for a breadcrumb + * that was held while its binary (Blob / ArrayBuffer) response body was read + * asynchronously. When present, enrichment reads the body and all request / + * response metadata from this snapshot instead of the live `xhr`, which may + * have been reused or cleared by the time the breadcrumb is re-added. + */ +export const REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY = '__mobile_replay_resolved_response_body__'; + +/** + * An asynchronously resolved response body plus the request / response + * metadata snapshotted synchronously at the time the breadcrumb was held. + */ +export interface ResolvedXhrResponse { + body: NetworkBody; + requestHeaders: Record | undefined; + rawResponseHeaders: string | null; + responseBodySize: number | undefined; +} + +type ResolvedBodyCarrier = { [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]?: ResolvedXhrResponse }; + const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = { allowUrls: [], denyUrls: [], @@ -63,10 +90,12 @@ function enrichXhrBreadcrumb( const now = Date.now(); const { startTimestamp = now, endTimestamp = now, input, xhr } = xhrHint; + // A held-and-re-added breadcrumb carries a snapshot taken while the xhr was + // still current — read from it, never from the (possibly reused) live xhr. + const resolved = (hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]; + const reqSize = getBodySize(input); - const resSize = xhr.getResponseHeader('content-length') - ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) - : _getBodySize(xhr.response, xhr.responseType); + const resSize = resolved ? resolved.responseBodySize : _getXhrResponseBodySize(xhr); let request: NetworkBreadcrumbSide | undefined; let response: NetworkBreadcrumbSide | undefined; @@ -74,8 +103,12 @@ function enrichXhrBreadcrumb( const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : undefined; if (shouldCaptureNetworkDetails(url, networkOptions)) { - request = _buildRequestDetails(input, xhr, networkOptions); - response = _buildResponseDetails(xhr, networkOptions); + request = _buildRequestDetails( + input, + resolved ? resolved.requestHeaders : xhr.__sentry_xhr_v3__?.request_headers, + networkOptions, + ); + response = _buildResponseDetails(xhr, networkOptions, resolved); } breadcrumb.data = dropUndefinedKeys({ @@ -91,11 +124,10 @@ function enrichXhrBreadcrumb( function _buildRequestDetails( input: RequestBody | undefined, - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, + requestHeaders: Record | undefined, networkOptions: ResolvedNetworkOptions, ): NetworkBreadcrumbSide | undefined { - const sentryXhr = xhr.__sentry_xhr_v3__; - const headers = filterHeaders(sentryXhr?.request_headers, networkOptions.requestHeaders); + const headers = filterHeaders(requestHeaders, networkOptions.requestHeaders); let body: NetworkBody | undefined; if (networkOptions.captureBodies) { @@ -108,23 +140,33 @@ function _buildRequestDetails( function _buildResponseDetails( xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, networkOptions: ResolvedNetworkOptions, + resolved: ResolvedXhrResponse | undefined, ): NetworkBreadcrumbSide | undefined { - let rawHeaders: string | null = null; - try { - rawHeaders = xhr.getAllResponseHeaders(); - } catch { - // ignore — some environments may throw before the request is complete - } + const rawHeaders = resolved ? resolved.rawResponseHeaders : _getAllResponseHeaders(xhr); const headers = filterHeaders(parseAllResponseHeaders(rawHeaders), networkOptions.responseHeaders); let body: NetworkBody | undefined; if (networkOptions.captureBodies) { - body = _getResponseBodyString(xhr); + body = resolved ? resolved.body : _getResponseBodyString(xhr); } return _toBreadcrumbSide(headers, body); } +function _getAllResponseHeaders(xhr: XMLHttpRequest): string | null { + try { + return xhr.getAllResponseHeaders(); + } catch { + // some environments may throw before the request is complete + return null; + } +} + +function _getXhrResponseBodySize(xhr: XMLHttpRequest): number | undefined { + const contentLength = xhr.getResponseHeader('content-length'); + return contentLength ? parseContentLengthHeader(contentLength) : _getBodySize(xhr.response, xhr.responseType); +} + function _toBreadcrumbSide( headers: Record | undefined, body: NetworkBody | undefined, @@ -174,6 +216,100 @@ type XhrHint = XhrBreadcrumbHint & { input?: RequestBody; }; +/** + * Whether this xhr breadcrumb's response body can only be captured asynchronously: + * a binary responseType (`blob` / `arraybuffer`) holding a text-like payload, for + * an allow-listed URL with body capture enabled. React Native's `fetch` polyfill + * always uses responseType `blob`, so every `fetch` response takes this path. + */ +export function shouldCaptureResponseBodyAsync( + breadcrumb: Breadcrumb, + hint: BreadcrumbHint | undefined, + networkOptions: ResolvedNetworkOptions, +): hint is XhrHint { + if (breadcrumb.category !== 'xhr' || !hint) { + return false; + } + if ((hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY] !== undefined) { + // already resolved — this is the re-added breadcrumb + return false; + } + const xhr = (hint as Partial).xhr; + if (!xhr || (xhr.responseType !== 'blob' && xhr.responseType !== 'arraybuffer') || xhr.response == null) { + return false; + } + if (!networkOptions.captureBodies) { + return false; + } + const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : undefined; + if (!shouldCaptureNetworkDetails(url, networkOptions)) { + return false; + } + let contentType: string | null = null; + try { + contentType = xhr.getResponseHeader('content-type'); + } catch { + // ignore — treated as non-text below + } + return isTextLikeContentType(contentType); +} + +/** + * Read the body of a binary (`blob` / `arraybuffer`) XHR response and serialize + * it like a text body (size cap + truncation warning), together with the + * request / response metadata snapshotted synchronously — by the time the body + * read settles, the xhr may have been reused or cleared, so the re-added + * breadcrumb must not read from it again. Resolves the body to an + * UNPARSEABLE_BODY_TYPE warning on read failure or timeout — never rejects. + */ +export async function resolveXhrResponseBody( + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, +): Promise { + let requestHeaders: Record | undefined; + let rawResponseHeaders: string | null = null; + let responseBodySize: number | undefined; + try { + requestHeaders = xhr.__sentry_xhr_v3__?.request_headers; + rawResponseHeaders = _getAllResponseHeaders(xhr); + responseBodySize = _getXhrResponseBodySize(xhr); + } catch { + // keep the defaults — the metadata snapshot is best-effort + } + return { body: await _readBinaryResponseBody(xhr), requestHeaders, rawResponseHeaders, responseBodySize }; +} + +async function _readBinaryResponseBody(xhr: XMLHttpRequest): Promise { + try { + if (xhr.responseType === 'blob') { + const blob = xhr.response as Blob; + const truncated = blob.size > NETWORK_BODY_MAX_SIZE; + // Slice before reading so a huge payload is never fully read into memory. + const capped = truncated ? blob.slice(0, NETWORK_BODY_MAX_SIZE) : blob; + const text = await readBlobAsText(capped, NETWORK_BODY_READ_TIMEOUT_MS); + return _toCappedBody(text, truncated); + } + if (xhr.responseType === 'arraybuffer') { + const buffer = xhr.response as ArrayBuffer; + const truncated = buffer.byteLength > NETWORK_BODY_MAX_SIZE; + const bytes = new Uint8Array(buffer, 0, truncated ? NETWORK_BODY_MAX_SIZE : buffer.byteLength); + return _toCappedBody(decodeUtf8(bytes), truncated); + } + } catch { + // fall through to the unparseable marker + } + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; +} + +function _toCappedBody(text: string, truncated: boolean): NetworkBody { + // The byte cap above already keeps `text` at or below the char cap + // (UTF-8 is at least one byte per char), so only the warning is left to add. + const body = getBodyString(text) ?? { body: text }; + if (truncated) { + return { ...body, _meta: { warnings: [...(body._meta?.warnings ?? []), 'MAX_BODY_SIZE_EXCEEDED'] } }; + } + return body; +} + function _getBodySize( body: XMLHttpRequest['response'], responseType: XMLHttpRequest['responseType'], diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 80236b5a0d..c597317885 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1,12 +1,26 @@ -import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint } from '@sentry/core'; +import type { + Breadcrumb, + BreadcrumbHint, + Client, + DynamicSamplingContext, + ErrorEvent, + Event, + EventHint, +} from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { addBreadcrumb } from '@sentry/core'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; +import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); +jest.mock('@sentry/core', () => ({ + ...(jest.requireActual('@sentry/core') as object), + addBreadcrumb: jest.fn(), +})); describe('Mobile Replay Integration', () => { let mockCaptureReplay: jest.MockedFunction; @@ -578,6 +592,138 @@ describe('Mobile Replay Integration', () => { }); }); + describe('beforeBreadcrumb wrapping (async binary response bodies)', () => { + let mockAddBreadcrumb: jest.MockedFunction; + let wrapClientOptions: { + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; + }; + let wrapClient: jest.Mocked; + + const flushMicrotasks = (): Promise => new Promise(resolve => setImmediate(resolve)); + + const setupIntegration = (options?: Parameters[0]): void => { + const integration = mobileReplayIntegration({ + networkDetailAllowUrls: ['api.example.com'], + ...options, + }); + integration.setup?.(wrapClient); + }; + + const getBinaryXhrBreadcrumbAndHint = (body = '{"ok":true}'): { breadcrumb: Breadcrumb; hint: BreadcrumbHint } => ({ + breadcrumb: { category: 'xhr', timestamp: 123, data: { url: 'https://api.example.com/users' } }, + hint: { + startTimestamp: 1, + endTimestamp: 2, + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: {}, + }, + getResponseHeader: (key: string) => (key === 'content-type' ? 'application/json' : null), + getAllResponseHeaders: () => 'content-type: application/json', + response: new TextEncoder().encode(body).buffer, + responseType: 'arraybuffer', + }, + }, + }); + + beforeEach(() => { + mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction; + wrapClientOptions = {}; + wrapClient = { + on: jest.fn(), + getOptions: jest.fn(() => wrapClientOptions), + getIntegrationByName: jest.fn().mockReturnValue(undefined), + addIntegration: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('holds a binary xhr breadcrumb and re-adds it with the resolved body on the hint', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + const result = wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); + expect(result).toBeNull(); + + await flushMicrotasks(); + + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + breadcrumb, + expect.objectContaining({ + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { + body: { body: '{"ok":true}' }, + requestHeaders: {}, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 11, + }, + }), + ); + }); + + it('passes through breadcrumbs that do not need an async body read', async () => { + setupIntegration(); + const breadcrumb: Breadcrumb = { category: 'console', message: 'hello' }; + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, {})).toBe(breadcrumb); + + await flushMicrotasks(); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('passes the re-added breadcrumb through without holding it again', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + const resolvedHint = { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } } }; + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint)).toBe(breadcrumb); + + await flushMicrotasks(); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('runs the user beforeBreadcrumb once, on the first pass only', async () => { + const userBeforeBreadcrumb = jest.fn((breadcrumb: Breadcrumb) => breadcrumb); + wrapClientOptions.beforeBreadcrumb = userBeforeBreadcrumb as ( + breadcrumb: Breadcrumb, + hint?: BreadcrumbHint, + ) => Breadcrumb | null; + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint)).toBeNull(); + await flushMicrotasks(); + + const resolvedHint = mockAddBreadcrumb.mock.calls[0]?.[1]; + wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint); + + expect(userBeforeBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('respects a user beforeBreadcrumb that drops the breadcrumb', async () => { + wrapClientOptions.beforeBreadcrumb = () => null; + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint)).toBeNull(); + + await flushMicrotasks(); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not wrap beforeBreadcrumb when body capture is disabled', () => { + setupIntegration({ networkCaptureBodies: false }); + expect(wrapClientOptions.beforeBreadcrumb).toBeUndefined(); + }); + + it('does not wrap beforeBreadcrumb when no URLs are allow-listed', () => { + const integration = mobileReplayIntegration({ networkDetailAllowUrls: [] }); + integration.setup?.(wrapClient); + expect(wrapClientOptions.beforeBreadcrumb).toBeUndefined(); + }); + }); + describe('platform checks', () => { it('should return noop integration in Expo Go', () => { jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); diff --git a/packages/core/test/replay/networkUtils.test.ts b/packages/core/test/replay/networkUtils.test.ts index 9bacfc9ce1..9bf08658a6 100644 --- a/packages/core/test/replay/networkUtils.test.ts +++ b/packages/core/test/replay/networkUtils.test.ts @@ -1,4 +1,10 @@ -import { getBodySize, parseContentLengthHeader } from '../../src/js/replay/networkUtils'; +import { + decodeUtf8, + getBodySize, + isTextLikeContentType, + parseContentLengthHeader, + readBlobAsText, +} from '../../src/js/replay/networkUtils'; describe('networkUtils', () => { describe('parseContentLengthHeader()', () => { @@ -56,4 +62,139 @@ describe('networkUtils', () => { expect(getBodySize(arrayBuffer)).toBe(8); }); }); + + describe('isTextLikeContentType()', () => { + it.each([ + ['application/json', true], + ['application/json; charset=utf-8', true], + ['application/hal+json', true], + ['text/plain', true], + ['text/html; charset=utf-8', true], + ['application/xml', true], + ['image/svg+xml', true], + ['application/x-www-form-urlencoded', true], + ['APPLICATION/JSON', true], + ['image/png', false], + ['application/octet-stream', false], + ['video/mp4', false], + ['', false], + [null, false], + [undefined, false], + ])('classifies %s as %s', (contentType, expected) => { + expect(isTextLikeContentType(contentType)).toBe(expected); + }); + }); + + describe('decodeUtf8()', () => { + const encode = (text: string): Uint8Array => new TextEncoder().encode(text); + + it('decodes ASCII and multi-byte characters via TextDecoder when available', () => { + expect(decodeUtf8(encode('{"ok":true}'))).toBe('{"ok":true}'); + expect(decodeUtf8(encode('Привет 你好 🎉'))).toBe('Привет 你好 🎉'); + }); + + describe('without TextDecoder (Hermes)', () => { + let originalTextDecoder: unknown; + + beforeEach(() => { + originalTextDecoder = (globalThis as { TextDecoder?: unknown }).TextDecoder; + delete (globalThis as { TextDecoder?: unknown }).TextDecoder; + }); + + afterEach(() => { + (globalThis as { TextDecoder?: unknown }).TextDecoder = originalTextDecoder; + }); + + it('decodes ASCII and multi-byte characters with the manual decoder', () => { + expect(decodeUtf8(encode('{"ok":true}'))).toBe('{"ok":true}'); + expect(decodeUtf8(encode('Привет 你好 🎉'))).toBe('Привет 你好 🎉'); + }); + + it('replaces invalid sequences with U+FFFD instead of throwing', () => { + expect(decodeUtf8(new Uint8Array([0x61, 0xff, 0x62]))).toBe('a�b'); + // multi-byte sequence truncated at the end of the buffer + expect(decodeUtf8(new Uint8Array([0x61, 0xd0]))).toBe('a�'); + // truncated sequence with a valid continuation prefix yields one U+FFFD + expect(decodeUtf8(new Uint8Array([0x61, 0xe2, 0x82]))).toBe('a�'); + }); + + it('does not drop bytes that follow an interrupted multi-byte sequence', () => { + // lead byte followed by a non-continuation byte: the tail is kept + expect(decodeUtf8(new Uint8Array([0xe2, 0x41, 0x42]))).toBe('�AB'); + // valid continuation prefix, then interrupted: one U+FFFD, tail kept + expect(decodeUtf8(new Uint8Array([0xe2, 0x82, 0x41]))).toBe('�A'); + }); + + it('consumes a complete sequence that encodes an invalid code point as one U+FFFD', () => { + // UTF-16 surrogate U+D800 encoded as UTF-8 (CESU-8 style) + expect(decodeUtf8(new Uint8Array([0x61, 0xed, 0xa0, 0x80, 0x62]))).toBe('a�b'); + // code point above U+10FFFF (0x110000) + expect(decodeUtf8(new Uint8Array([0x61, 0xf4, 0x90, 0x80, 0x80, 0x62]))).toBe('a�b'); + }); + }); + }); + + describe('readBlobAsText()', () => { + afterEach(() => { + delete (globalThis as { FileReader?: unknown }).FileReader; + jest.useRealTimers(); + }); + + it('resolves with the blob content', async () => { + installMockFileReader(); + await expect(readBlobAsText(new Blob(['{"ok":true}']), 500)).resolves.toBe('{"ok":true}'); + }); + + it('rejects when the reader errors', async () => { + installMockFileReader({ failWith: new Error('read failed') }); + await expect(readBlobAsText(new Blob(['x']), 500)).rejects.toThrow('read failed'); + }); + + it('rejects after the timeout when the reader never completes', async () => { + jest.useFakeTimers(); + installMockFileReader({ neverComplete: true }); + const promise = readBlobAsText(new Blob(['x']), 500); + const assertion = expect(promise).rejects.toThrow('Timed out'); + jest.advanceTimersByTime(500); + await assertion; + }); + }); }); + +function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boolean } = {}): void { + class MockFileReader { + public result: string | null = null; + public error: Error | null = null; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + public onabort: (() => void) | null = null; + + public readAsText(blob: Blob): void { + if (behavior.neverComplete) { + return; + } + if (behavior.failWith) { + this.error = behavior.failWith; + queueMicrotask(() => this.onerror?.()); + return; + } + blob.text().then( + text => { + this.result = text; + this.onload?.(); + }, + (error: Error) => { + this.error = error; + this.onerror?.(); + }, + ); + } + + public abort(): void { + // match the browser behaviour of firing onabort asynchronously-ish; + // readBlobAsText has already rejected by the time this runs + this.onabort?.(); + } + } + (globalThis as { FileReader?: unknown }).FileReader = MockFileReader; +} diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index ff577d99d4..d8475cb557 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -1,9 +1,14 @@ import type { Breadcrumb } from '@sentry/core'; +import type { ResolvedNetworkOptions } from '../../src/js/replay/networkUtils'; + import { NETWORK_BODY_MAX_SIZE } from '../../src/js/replay/networkUtils'; import { enrichXhrBreadcrumbsForMobileReplay, makeEnrichXhrBreadcrumbsForMobileReplay, + REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY, + resolveXhrResponseBody, + shouldCaptureResponseBodyAsync, } from '../../src/js/replay/xhrUtils'; describe('xhrUtils', () => { @@ -326,9 +331,276 @@ describe('xhrUtils', () => { expect(responseHeaders['x-request-id']).toBe('req-456'); expect(responseHeaders['x-rate-limit']).toBeUndefined(); }); + + it('uses an asynchronously resolved response body from the hint instead of reading the xhr', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions()); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const hint = { + ...getBlobXhrHint(), + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { + body: { body: '{"resolved":true}' }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 17, + }, + }; + enrich(breadcrumb, hint); + + expect((breadcrumb.data?.response as { body?: string }).body).toBe('{"resolved":true}'); + }); + + it('reads headers and sizes from the hint snapshot, not the live xhr, for re-added breadcrumbs', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions()); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + // the live xhr in getBlobXhrHint reports content-type: application/json + const hint = { + ...getBlobXhrHint(), + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { + body: { body: '{"resolved":true}' }, + requestHeaders: { 'content-type': 'text/xml' }, + rawResponseHeaders: 'content-type: text/html', + responseBodySize: 42, + }, + }; + enrich(breadcrumb, hint); + + expect(breadcrumb.data?.response_body_size).toBe(42); + const requestHeaders = (breadcrumb.data?.request as { headers?: Record }).headers; + const responseHeaders = (breadcrumb.data?.response as { headers?: Record }).headers; + expect(requestHeaders).toEqual({ 'content-type': 'text/xml' }); + expect(responseHeaders).toEqual({ 'content-type': 'text/html' }); + }); + }); + + describe('shouldCaptureResponseBodyAsync', () => { + it('returns true for an allow-listed blob response with a text-like content type', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + expect(shouldCaptureResponseBodyAsync(breadcrumb, getBlobXhrHint(), getCaptureAllOptions())).toBe(true); + }); + + it('returns true for arraybuffer responses', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + expect( + shouldCaptureResponseBodyAsync(breadcrumb, getArrayBufferXhrHint('{"ok":true}'), getCaptureAllOptions()), + ).toBe(true); + }); + + it('returns false for non-xhr breadcrumbs and missing hints', () => { + expect(shouldCaptureResponseBodyAsync({ category: 'http' }, getBlobXhrHint(), getCaptureAllOptions())).toBe( + false, + ); + expect(shouldCaptureResponseBodyAsync({ category: 'xhr' }, undefined, getCaptureAllOptions())).toBe(false); + }); + + it('returns false for text-like responseTypes that are readable synchronously', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + expect(shouldCaptureResponseBodyAsync(breadcrumb, getValidXhrHint(), getCaptureAllOptions())).toBe(false); + }); + + it('returns false for binary content types (kept as unparseable)', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const hint = getBlobXhrHint({ contentType: 'image/png' }); + expect(shouldCaptureResponseBodyAsync(breadcrumb, hint, getCaptureAllOptions())).toBe(false); + }); + + it('returns false when body capture is disabled or the URL is not allow-listed', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + expect( + shouldCaptureResponseBodyAsync(breadcrumb, getBlobXhrHint(), { + ...getCaptureAllOptions(), + captureBodies: false, + }), + ).toBe(false); + expect( + shouldCaptureResponseBodyAsync(breadcrumb, getBlobXhrHint(), { + ...getCaptureAllOptions(), + allowUrls: ['api.other.com'], + }), + ).toBe(false); + }); + + it('returns false when the hint already carries a resolved body (re-added breadcrumb)', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const hint = { + ...getBlobXhrHint(), + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } }, + }; + expect(shouldCaptureResponseBodyAsync(breadcrumb, hint, getCaptureAllOptions())).toBe(false); + }); + }); + + describe('resolveXhrResponseBody', () => { + afterEach(() => { + delete (globalThis as { FileReader?: unknown }).FileReader; + }); + + it('reads a JSON blob response into a text body with a metadata snapshot', async () => { + installMockFileReader(); + const { xhr } = getBlobXhrHint(); + await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ + body: { body: '{"ok":true}' }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 11, + }); + }); + + it('truncates blob bodies over the size cap and slices before reading', async () => { + installMockFileReader(); + const big = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100); + const { xhr } = getBlobXhrHint({ body: big }); + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + expect(resolved.body.body?.length).toBe(NETWORK_BODY_MAX_SIZE); + expect(resolved.body._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('decodes arraybuffer responses including multi-byte characters', async () => { + const body = '{"name":"Пёс 🐕"}'; + const { xhr } = getArrayBufferXhrHint(body); + await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ + body: { body }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: new TextEncoder().encode(body).byteLength, + }); + }); + + it('resolves the body to an unparseable marker when the read fails', async () => { + installMockFileReader({ failWith: new Error('boom') }); + const { xhr } = getBlobXhrHint(); + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + }); + + it('resolves the body to an unparseable marker on timeout', async () => { + jest.useFakeTimers(); + try { + installMockFileReader({ neverComplete: true }); + const { xhr } = getBlobXhrHint(); + const promise = resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + jest.runAllTimers(); + const resolved = await promise; + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + } finally { + jest.useRealTimers(); + } + }); + + it('snapshots metadata synchronously, unaffected by the xhr being reused mid-read', async () => { + installMockFileReader(); + const { xhr } = getBlobXhrHint(); + const promise = resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + // simulate the app reusing the xhr before the async body read settles + xhr.getAllResponseHeaders = () => 'content-type: text/html'; + (xhr as { __sentry_xhr_v3__: unknown }).__sentry_xhr_v3__ = { + method: 'POST', + url: 'https://api.example.com/other', + request_headers: { 'content-type': 'text/plain' }, + }; + (xhr as { response: unknown }).response = null; + + await expect(promise).resolves.toEqual({ + body: { body: '{"ok":true}' }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 11, + }); + }); }); }); +function getCaptureAllOptions(): ResolvedNetworkOptions { + return { + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }; +} + +function getBlobXhrHint(options: { body?: string; contentType?: string } = {}) { + const { body = '{"ok":true}', contentType = 'application/json' } = options; + const blob = new Blob([body]); + return { + startTimestamp: 1, + endTimestamp: 2, + input: undefined, + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, + getResponseHeader: (key: string) => (key === 'content-type' ? contentType : null), + getAllResponseHeaders: () => `content-type: ${contentType}`, + response: blob as unknown as { ok: boolean }, + responseText: '', + responseType: 'blob' as const, + }, + }; +} + +function getArrayBufferXhrHint(body: string) { + const buffer = new TextEncoder().encode(body).buffer; + return { + startTimestamp: 1, + endTimestamp: 2, + input: undefined, + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, + getResponseHeader: (key: string) => (key === 'content-type' ? 'application/json' : null), + getAllResponseHeaders: () => 'content-type: application/json', + response: buffer as unknown as { ok: boolean }, + responseText: '', + responseType: 'arraybuffer' as const, + }, + }; +} + +function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boolean } = {}): void { + class MockFileReader { + public result: string | null = null; + public error: Error | null = null; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + public onabort: (() => void) | null = null; + + public readAsText(blob: Blob): void { + if (behavior.neverComplete) { + return; + } + if (behavior.failWith) { + this.error = behavior.failWith; + queueMicrotask(() => this.onerror?.()); + return; + } + blob.text().then( + text => { + this.result = text; + this.onload?.(); + }, + (error: Error) => { + this.error = error; + this.onerror?.(); + }, + ); + } + + public abort(): void { + this.onabort?.(); + } + } + (globalThis as { FileReader?: unknown }).FileReader = MockFileReader; +} + function getValidXhrHint() { const responseHeadersRaw = 'content-type: application/json\r\ncontent-length: 13'; return { From fcd8ab340d7ab180ee7bac0727701672adee91ab Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 28 Jul 2026 15:09:37 +0200 Subject: [PATCH 2/3] fix(replay): keep held network breadcrumbs on the scope, defer only the native sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 +- packages/core/src/js/replay/mobilereplay.ts | 43 +++-- packages/core/src/js/replay/networkUtils.ts | 38 +++- packages/core/src/js/replay/xhrUtils.ts | 78 ++++++-- packages/core/src/js/scopeSync.ts | 57 +++++- .../core/test/replay/mobilereplay.test.ts | 68 ++++--- .../test/replay/networkBodyCapture.test.ts | 173 ++++++++++++++++++ .../core/test/replay/networkUtils.test.ts | 27 ++- packages/core/test/replay/xhrUtils.test.ts | 106 ++++++++++- packages/core/test/scopeSync.test.ts | 52 +++++- 10 files changed, 574 insertions(+), 74 deletions(-) create mode 100644 packages/core/test/replay/networkBodyCapture.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d693d99ea..c2fc6efaa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module...{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`. -- Session Replay network details now capture `fetch` (Blob/ArrayBuffer) response bodies ([#6473](https://github.com/getsentry/sentry-react-native/pull/6473)) +- Session Replay network details now capture `fetch` (Blob/ArrayBuffer) response bodies ([#6473](https://github.com/getsentry/sentry-react-native/pull/6473), [#6533](https://github.com/getsentry/sentry-react-native/pull/6533)) - React Native's `fetch` is built on XMLHttpRequest with `responseType: 'blob'`, so fetch response bodies previously always showed `[UNPARSEABLE_BODY_TYPE]` in the Replay network tab. Text-like binary payloads (JSON, XML, `text/*`, form data) are now read asynchronously and inlined like text bodies — still only for URLs matching `networkDetailAllowUrls` with `networkCaptureBodies` enabled, with the same size cap. Genuinely binary payloads (images, media) remain marked as unparseable. + React Native's `fetch` is built on `XMLHttpRequest` with `responseType: 'blob'`, so fetch response bodies previously always showed `[UNPARSEABLE_BODY_TYPE]` in the Replay network tab. Text-like binary payloads (JSON, XML, JavaScript, `text/*`, form data) are now read asynchronously and inlined like text bodies — still only for URLs matching `networkDetailAllowUrls` with `networkCaptureBodies` enabled, with the same ~150 KB cap. Genuinely binary payloads (images, media) remain marked as unparseable. + + Because a binary body can only be read asynchronously while breadcrumbs are forwarded to the native SDKs synchronously, the breadcrumb still reaches the JavaScript scope immediately — error events captured while the read is in flight keep it — and only the sync to native is deferred until the body has been read. The breadcrumb keeps its original timestamp, so the replay places it correctly. Note that the breadcrumb attached to a JS error event still shows `[UNPARSEABLE_BODY_TYPE]` for these responses; the resolved body appears in the Replay network tab. ### Changes diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index c1ce99372c..d280f88532 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -10,18 +10,19 @@ import type { Metric, } from '@sentry/core'; -import { addBreadcrumb, debug } from '@sentry/core'; +import { debug } from '@sentry/core'; import type { ResolvedNetworkOptions } from './networkUtils'; import { isHardCrash } from '../misc'; +import { deferBreadcrumbNativeSync, syncBreadcrumbToNative } from '../scopeSync'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { registerFeatureMarker } from '../utils/featureMarkers'; import { NATIVE } from '../wrapper'; import { + buildResolvedNetworkBreadcrumb, makeEnrichXhrBreadcrumbsForMobileReplay, - REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY, resolveXhrResponseBody, shouldCaptureResponseBodyAsync, } from './xhrUtils'; @@ -182,8 +183,13 @@ export interface MobileReplayOptions { * Authorization-like headers (`authorization`, `cookie`, `set-cookie`, * `x-api-key`, `x-auth-token`, `proxy-authorization`) are always stripped. * - * Currently only XHR requests are supported (this covers `axios` and similar - * libraries). Fetch body capture will be added in a follow-up. + * Both `XMLHttpRequest` (this covers `axios` and similar libraries) and + * `fetch` are supported — React Native's `fetch` is a polyfill built on + * `XMLHttpRequest`. Response bodies returned as `Blob`/`ArrayBuffer` (which is + * every `fetch` response) are read asynchronously and inlined when the + * `content-type` is text-like (JSON, XML, `text/*`, form data); genuinely + * binary payloads stay marked as unparseable. Request bodies passed as + * `Blob`/`ArrayBuffer` are not inlined yet. * * Note: `RegExp` patterns are matched in JavaScript for request enrichment, but * only their string source is forwarded to the native SDKs (a `RegExp` can't @@ -451,31 +457,36 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Binary (Blob/ArrayBuffer) response bodies — which is every `fetch` // response, since RN's fetch polyfill uses XHR with responseType 'blob' — - // can only be read asynchronously, but the breadcrumb is forwarded to the - // native SDKs synchronously. Hold such breadcrumbs here (return null), - // read the body, then re-add the same breadcrumb (timestamp is already - // set, so it keeps its original time) with the resolved body and a - // metadata snapshot on the hint — the xhr itself may be reused by then. + // can only be read asynchronously, while breadcrumbs are forwarded to the + // native SDKs synchronously and cannot be updated afterwards. + // + // The breadcrumb is therefore still returned here, so it lands on the + // JavaScript scope immediately and error events captured while the read is in + // flight keep it. Only the sync to native — which is what feeds the Replay + // network tab — is deferred until the body has been read, and then performed + // once with the resolved body. The breadcrumb keeps its original timestamp, + // so the replay places it correctly regardless of the delay. if (networkOptions.captureBodies && networkOptions.allowUrls.length > 0) { const originalBeforeBreadcrumb = clientOptions.beforeBreadcrumb; clientOptions.beforeBreadcrumb = (breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null => { - if (hint && REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY in hint) { - // second pass with the resolved body — the user's beforeBreadcrumb already ran - return breadcrumb; - } const result = originalBeforeBreadcrumb ? originalBeforeBreadcrumb(breadcrumb, hint) : breadcrumb; if (result === null || !shouldCaptureResponseBodyAsync(result, hint, networkOptions)) { return result; } + + deferBreadcrumbNativeSync(result); const xhr = hint.xhr; resolveXhrResponseBody(xhr) .then(resolved => { - addBreadcrumb(result, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolved }); + syncBreadcrumbToNative(buildResolvedNetworkBreadcrumb(result, hint, resolved, networkOptions)); }) .then(undefined, (error: unknown) => { - debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to re-add network breadcrumb`, error); + debug.error( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to sync network breadcrumb with resolved response body to native`, + error, + ); }); - return null; + return result; }; } const originalBeforeSend = clientOptions.beforeSend; diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 9468216466..ee1047c5f5 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -179,8 +179,8 @@ export function getBodyString(body: unknown): NetworkBody | undefined { /** * Whether a Content-Type describes a payload that is safe to decode into text - * (JSON, XML, form data, `text/*`). Genuinely binary payloads (images, media, - * octet-stream) are excluded so they stay marked as unparseable. + * (JSON, XML, form data, JavaScript, `text/*`). Genuinely binary payloads + * (images, media, octet-stream) are excluded so they stay marked as unparseable. */ export function isTextLikeContentType(contentType: string | null | undefined): boolean { if (!contentType) { @@ -191,10 +191,27 @@ export function isTextLikeContentType(contentType: string | null | undefined): b normalized.startsWith('text/') || normalized.includes('json') || normalized.includes('xml') || + normalized.includes('javascript') || + normalized.includes('ecmascript') || normalized.includes('x-www-form-urlencoded') ); } +/** + * Rejection message used when a blob read completed but the engine could not + * decode the bytes in the requested encoding. Distinguished from other failures + * because it is the one case worth retrying with a different byte range. + */ +export const BLOB_DECODE_FAILED = 'SentryBlobDecodeFailed'; + +/** Whether an error is the decode failure signalled by `readBlobAsText`. */ +export function isBlobDecodeError(error: unknown): boolean { + return error instanceof Error && error.message === BLOB_DECODE_FAILED; +} + +/** The maximum number of bytes a single UTF-8 encoded code point can occupy. */ +export const MAX_UTF8_SEQUENCE_BYTES = 4; + /** * Read a Blob as UTF-8 text via FileReader (React Native's Blob has no `text()`). * Rejects on read error, abort or after `timeoutMs`. @@ -203,7 +220,11 @@ export function readBlobAsText(blob: Blob, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); const timeout = setTimeout(() => { - // reject first — abort() may fire onabort synchronously + // Reject before aborting: on engines that implement the full FileReader + // state machine, `abort()` dispatches `onabort` synchronously. On React + // Native it dispatches nothing at all (the reader never enters LOADING, + // see Libraries/Blob/FileReader.js) and does not cancel the native read, + // it only discards the result — so rejecting here is what ends the wait. reject(new Error(`Timed out reading response body after ${timeoutMs}ms`)); try { reader.abort(); @@ -217,7 +238,11 @@ export function readBlobAsText(blob: Blob, timeoutMs: number): Promise { if (typeof result === 'string') { resolve(result); } else { - reject(new Error('FileReader did not produce a string result')); + // iOS decodes via `-[NSString initWithData:encoding:]`, which yields nil + // (surfacing here as a null result) when the bytes are not valid in the + // requested encoding — e.g. a byte slice that cut a multi-byte UTF-8 + // sequence in half. + reject(new Error(BLOB_DECODE_FAILED)); } }; reader.onerror = () => { @@ -237,8 +262,9 @@ type TextDecoderLike = { decode(input: Uint8Array): string }; /* oxlint-disable eslint(no-bitwise) -- decoding UTF-8 is inherently bit manipulation */ /** * Decode UTF-8 bytes into a string. Uses the global TextDecoder when the JS - * engine provides one and falls back to a manual decoder otherwise (Hermes - * has no TextDecoder). Invalid sequences decode to U+FFFD. + * engine provides one and falls back to a manual decoder otherwise. Hermes and + * JSC expose `TextEncoder` but no `TextDecoder`, so on React Native the manual + * decoder is the path actually taken. Invalid sequences decode to U+FFFD. */ export function decodeUtf8(bytes: Uint8Array): string { const TextDecoderConstructor = (globalThis as { TextDecoder?: new () => TextDecoderLike }).TextDecoder; diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 2ff34d76c9..81a805024f 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -9,7 +9,9 @@ import { filterHeaders, getBodySize, getBodyString, + isBlobDecodeError, isTextLikeContentType, + MAX_UTF8_SEQUENCE_BYTES, NETWORK_BODY_MAX_SIZE, NETWORK_BODY_READ_TIMEOUT_MS, parseAllResponseHeaders, @@ -26,16 +28,16 @@ interface NetworkBreadcrumbSide { /** * Hint key carrying the result of `resolveXhrResponseBody` for a breadcrumb - * that was held while its binary (Blob / ArrayBuffer) response body was read - * asynchronously. When present, enrichment reads the body and all request / - * response metadata from this snapshot instead of the live `xhr`, which may - * have been reused or cleared by the time the breadcrumb is re-added. + * whose binary (Blob / ArrayBuffer) response body was read asynchronously. When + * present, enrichment reads the body and all request / response metadata from + * this snapshot instead of the live `xhr`, which may have been reused or cleared + * by the time the read settles. */ export const REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY = '__mobile_replay_resolved_response_body__'; /** * An asynchronously resolved response body plus the request / response - * metadata snapshotted synchronously at the time the breadcrumb was held. + * metadata snapshotted synchronously at the time the read was started. */ export interface ResolvedXhrResponse { body: NetworkBody; @@ -90,8 +92,8 @@ function enrichXhrBreadcrumb( const now = Date.now(); const { startTimestamp = now, endTimestamp = now, input, xhr } = xhrHint; - // A held-and-re-added breadcrumb carries a snapshot taken while the xhr was - // still current — read from it, never from the (possibly reused) live xhr. + // An asynchronously resolved snapshot taken while the xhr was still current — + // read from it, never from the (possibly reused) live xhr. const resolved = (hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]; const reqSize = getBodySize(input); @@ -231,7 +233,7 @@ export function shouldCaptureResponseBodyAsync( return false; } if ((hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY] !== undefined) { - // already resolved — this is the re-added breadcrumb + // already resolved — nothing left to read asynchronously return false; } const xhr = (hint as Partial).xhr; @@ -258,7 +260,7 @@ export function shouldCaptureResponseBodyAsync( * Read the body of a binary (`blob` / `arraybuffer`) XHR response and serialize * it like a text body (size cap + truncation warning), together with the * request / response metadata snapshotted synchronously — by the time the body - * read settles, the xhr may have been reused or cleared, so the re-added + * read settles, the xhr may have been reused or cleared, so the enriched * breadcrumb must not read from it again. Resolves the body to an * UNPARSEABLE_BODY_TYPE warning on read failure or timeout — never rejects. */ @@ -278,15 +280,42 @@ export async function resolveXhrResponseBody( return { body: await _readBinaryResponseBody(xhr), requestHeaders, rawResponseHeaders, responseBodySize }; } +/** + * Build a copy of an already-enriched xhr breadcrumb with its network details + * rebuilt from an asynchronously resolved response body. + * + * The original breadcrumb is left untouched: it is already on the JavaScript + * scope (and possibly attached to events), and the normalized copy the scope + * holds is a different object anyway. The returned breadcrumb is meant to be + * forwarded to the native SDKs, which is what feeds the Replay network tab. + */ +export function buildResolvedNetworkBreadcrumb( + breadcrumb: Breadcrumb, + hint: BreadcrumbHint, + resolved: ResolvedXhrResponse, + networkOptions: ResolvedNetworkOptions, +): Breadcrumb { + const data = { ...breadcrumb.data }; + // Drop the details built from the synchronous pass so they are rebuilt from + // the snapshot rather than merged with it. + delete data.request; + delete data.response; + + const enriched: Breadcrumb = { ...breadcrumb, data }; + enrichXhrBreadcrumb(enriched, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolved }, networkOptions); + return enriched; +} + async function _readBinaryResponseBody(xhr: XMLHttpRequest): Promise { try { if (xhr.responseType === 'blob') { const blob = xhr.response as Blob; const truncated = blob.size > NETWORK_BODY_MAX_SIZE; + if (!truncated) { + return _toCappedBody(await readBlobAsText(blob, NETWORK_BODY_READ_TIMEOUT_MS), false); + } // Slice before reading so a huge payload is never fully read into memory. - const capped = truncated ? blob.slice(0, NETWORK_BODY_MAX_SIZE) : blob; - const text = await readBlobAsText(capped, NETWORK_BODY_READ_TIMEOUT_MS); - return _toCappedBody(text, truncated); + return _toCappedBody(await _readCappedBlobAsText(blob), true); } if (xhr.responseType === 'arraybuffer') { const buffer = xhr.response as ArrayBuffer; @@ -300,6 +329,31 @@ async function _readBinaryResponseBody(xhr: XMLHttpRequest): Promise { + let lastError: unknown; + for (let dropped = 0; dropped < MAX_UTF8_SEQUENCE_BYTES; dropped++) { + try { + return await readBlobAsText(blob.slice(0, NETWORK_BODY_MAX_SIZE - dropped), NETWORK_BODY_READ_TIMEOUT_MS); + } catch (error) { + lastError = error; + if (!isBlobDecodeError(error)) { + throw error; + } + } + } + throw lastError; +} + function _toCappedBody(text: string, truncated: boolean): NetworkBody { // The byte cap above already keeps `text` at or below the char cap // (UTF-8 is at least one byte per char), so only the warning is left to add. diff --git a/packages/core/src/js/scopeSync.ts b/packages/core/src/js/scopeSync.ts index 2d3eac35c3..3dd55af434 100644 --- a/packages/core/src/js/scopeSync.ts +++ b/packages/core/src/js/scopeSync.ts @@ -13,6 +13,49 @@ import { NATIVE } from './wrapper'; */ const syncedToNativeMap = new WeakMap(); +/** + * Breadcrumbs whose sync to the native SDKs is owned by whoever marked them, + * rather than by the scope patch below. + */ +const deferredNativeSyncBreadcrumbs = new WeakSet(); + +/** + * Normalize a breadcrumb the same way it is normalized before being added to the + * scope, so the native SDKs always receive the same shape. + */ +function normalizeBreadcrumbForNative(breadcrumb: Breadcrumb): Breadcrumb { + return { + ...breadcrumb, + level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, + data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, + }; +} + +/** + * Mark a breadcrumb so that adding it to a synced scope does **not** forward it + * to the native SDKs. The caller becomes responsible for calling + * `syncBreadcrumbToNative` once its data is complete. + * + * This exists for breadcrumbs that carry data which can only be resolved + * asynchronously (see the Mobile Replay network body capture): the breadcrumb + * still reaches the JavaScript scope synchronously — so error events captured in + * the meantime keep it — while the native SDKs, which cannot update a breadcrumb + * after the fact, receive it once and already enriched. + * + * The mark is consumed by the first synced scope the breadcrumb is added to. + */ +export function deferBreadcrumbNativeSync(breadcrumb: Breadcrumb): void { + deferredNativeSyncBreadcrumbs.add(breadcrumb); +} + +/** + * Forward a breadcrumb to the native SDKs without adding it to a scope. Used to + * complete a `deferBreadcrumbNativeSync` handover. + */ +export function syncBreadcrumbToNative(breadcrumb: Breadcrumb): void { + NATIVE.addBreadcrumb(normalizeBreadcrumbForNative(breadcrumb)); +} + /** * Hooks into the scope set methods and sync new data added to the given scope with the native SDKs. */ @@ -53,14 +96,18 @@ export function enableSyncToNative(scope: Scope): void { }); fillTyped(scope, 'addBreadcrumb', original => (breadcrumb, maxBreadcrumbs): Scope => { - const mergedBreadcrumb: Breadcrumb = { - ...breadcrumb, - level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, - data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, - }; + // Consume the mark before normalizing — the normalized copy is a different object. + const nativeSyncDeferred = deferredNativeSyncBreadcrumbs.delete(breadcrumb); + const mergedBreadcrumb = normalizeBreadcrumbForNative(breadcrumb); original.call(scope, mergedBreadcrumb, maxBreadcrumbs); + if (nativeSyncDeferred) { + // Whoever deferred the sync forwards this breadcrumb to native itself, + // once the data it is waiting for has resolved. + return scope; + } + const finalBreadcrumb = scope.getLastBreadcrumb(); if (finalBreadcrumb) { NATIVE.addBreadcrumb(finalBreadcrumb); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index c597317885..f7421d29ee 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -9,18 +9,14 @@ import type { } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { addBreadcrumb } from '@sentry/core'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; +import * as scopeSync from '../../src/js/scopeSync'; import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); -jest.mock('@sentry/core', () => ({ - ...(jest.requireActual('@sentry/core') as object), - addBreadcrumb: jest.fn(), -})); describe('Mobile Replay Integration', () => { let mockCaptureReplay: jest.MockedFunction; @@ -593,7 +589,8 @@ describe('Mobile Replay Integration', () => { }); describe('beforeBreadcrumb wrapping (async binary response bodies)', () => { - let mockAddBreadcrumb: jest.MockedFunction; + let mockDeferBreadcrumbNativeSync: jest.SpiedFunction; + let mockSyncBreadcrumbToNative: jest.SpiedFunction; let wrapClientOptions: { beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; }; @@ -629,7 +626,8 @@ describe('Mobile Replay Integration', () => { }); beforeEach(() => { - mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction; + mockDeferBreadcrumbNativeSync = jest.spyOn(scopeSync, 'deferBreadcrumbNativeSync').mockImplementation(() => {}); + mockSyncBreadcrumbToNative = jest.spyOn(scopeSync, 'syncBreadcrumbToNative').mockImplementation(() => {}); wrapClientOptions = {}; wrapClient = { on: jest.fn(), @@ -639,27 +637,37 @@ describe('Mobile Replay Integration', () => { } as unknown as jest.Mocked; }); - it('holds a binary xhr breadcrumb and re-adds it with the resolved body on the hint', async () => { + it('keeps the breadcrumb on the scope and defers only the native sync', async () => { setupIntegration(); const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + // Arrange/Act: the breadcrumb must not be dropped — error events captured + // while the body read is in flight need to keep it. const result = wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); - expect(result).toBeNull(); + + expect(result).toBe(breadcrumb); + expect(mockDeferBreadcrumbNativeSync).toHaveBeenCalledWith(breadcrumb); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); await flushMicrotasks(); - expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - breadcrumb, - expect.objectContaining({ - [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { - body: { body: '{"ok":true}' }, - requestHeaders: {}, - rawResponseHeaders: 'content-type: application/json', - responseBodySize: 11, - }, - }), - ); + expect(mockSyncBreadcrumbToNative).toHaveBeenCalledTimes(1); + }); + + it('syncs a copy carrying the resolved body, leaving the scope breadcrumb untouched', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); + await flushMicrotasks(); + + const synced = mockSyncBreadcrumbToNative.mock.calls[0]?.[0] as Breadcrumb; + expect(synced).not.toBe(breadcrumb); + expect(synced.timestamp).toBe(123); + expect((synced.data?.response as { body?: string }).body).toBe('{"ok":true}'); + expect(synced.data?.response_body_size).toBe(11); + // The scope copy keeps whatever the synchronous enrichment produced. + expect(breadcrumb.data?.response).toBeUndefined(); }); it('passes through breadcrumbs that do not need an async body read', async () => { @@ -669,10 +677,11 @@ describe('Mobile Replay Integration', () => { expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, {})).toBe(breadcrumb); await flushMicrotasks(); - expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + expect(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); }); - it('passes the re-added breadcrumb through without holding it again', async () => { + it('does not defer again for a hint that already carries a resolved body', async () => { setupIntegration(); const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); const resolvedHint = { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } } }; @@ -680,10 +689,11 @@ describe('Mobile Replay Integration', () => { expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint)).toBe(breadcrumb); await flushMicrotasks(); - expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + expect(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); }); - it('runs the user beforeBreadcrumb once, on the first pass only', async () => { + it('runs the user beforeBreadcrumb exactly once', async () => { const userBeforeBreadcrumb = jest.fn((breadcrumb: Breadcrumb) => breadcrumb); wrapClientOptions.beforeBreadcrumb = userBeforeBreadcrumb as ( breadcrumb: Breadcrumb, @@ -692,12 +702,9 @@ describe('Mobile Replay Integration', () => { setupIntegration(); const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); - expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint)).toBeNull(); + wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); await flushMicrotasks(); - const resolvedHint = mockAddBreadcrumb.mock.calls[0]?.[1]; - wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint); - expect(userBeforeBreadcrumb).toHaveBeenCalledTimes(1); }); @@ -709,7 +716,8 @@ describe('Mobile Replay Integration', () => { expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint)).toBeNull(); await flushMicrotasks(); - expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + expect(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); }); it('does not wrap beforeBreadcrumb when body capture is disabled', () => { diff --git a/packages/core/test/replay/networkBodyCapture.test.ts b/packages/core/test/replay/networkBodyCapture.test.ts new file mode 100644 index 0000000000..069767218a --- /dev/null +++ b/packages/core/test/replay/networkBodyCapture.test.ts @@ -0,0 +1,173 @@ +import type { Breadcrumb, BreadcrumbHint } from '@sentry/core'; + +import * as SentryCore from '@sentry/core'; + +import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay'; +import { enableSyncToNative } from '../../src/js/scopeSync'; +import * as environment from '../../src/js/utils/environment'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); + +import { NATIVE } from '../mockWrapper'; + +jest.mock('../../src/js/wrapper'); + +/** + * End-to-end coverage of the async response body capture: real `@sentry/core` + * breadcrumb pipeline, real scope sync patch, mocked native bridge. + * + * This is what guards the object-identity assumption the deferral relies on — + * the breadcrumb our `beforeBreadcrumb` returns must be the very object handed + * to `scope.addBreadcrumb`. + */ +describe('Mobile Replay async network body capture (end to end)', () => { + const flushMicrotasks = (): Promise => new Promise(resolve => setImmediate(resolve)); + + const getBlobXhrHint = (body = '{"ok":true}', contentType = 'application/json'): BreadcrumbHint => + ({ + startTimestamp: 1000, + endTimestamp: 1200, + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, + getResponseHeader: (key: string) => (key === 'content-type' ? contentType : null), + getAllResponseHeaders: () => `content-type: ${contentType}`, + response: new Blob([body]), + responseType: 'blob', + }, + }) as unknown as BreadcrumbHint; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + jest.spyOn(environment, 'notMobileOs').mockReturnValue(false); + installFileReader(); + + const client = new TestClient(getDefaultTestClientOptions()); + SentryCore.setCurrentClient(client); + SentryCore.getIsolationScope().clearBreadcrumbs(); + enableSyncToNative(SentryCore.getIsolationScope()); + + mobileReplayIntegration({ + networkDetailAllowUrls: ['api.example.com'], + }).setup?.(client); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete (globalThis as { FileReader?: unknown }).FileReader; + }); + + it('keeps the breadcrumb on the scope immediately and syncs to native only once the body resolved', async () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + + SentryCore.addBreadcrumb(breadcrumb, getBlobXhrHint()); + + // Synchronously: on the scope (so error events keep it), not yet on native. + const onScope = SentryCore.getIsolationScope().getLastBreadcrumb(); + expect(onScope).toBeDefined(); + expect(onScope?.data?.url).toBe('https://api.example.com/users'); + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + + await flushMicrotasks(); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '{"ok":true}' }); + expect(synced.data?.request).toMatchObject({ headers: { 'content-type': 'application/json' } }); + expect(synced.timestamp).toBe(onScope?.timestamp); + }); + + it('syncs synchronously and does not defer for a text response', async () => { + const textHint = { + startTimestamp: 1000, + endTimestamp: 1200, + xhr: { + __sentry_xhr_v3__: { method: 'GET', url: 'https://api.example.com/users', request_headers: {} }, + getResponseHeader: () => null, + getAllResponseHeaders: () => 'content-type: application/json', + response: '{"ok":true}', + responseText: '{"ok":true}', + responseType: 'text', + }, + } as unknown as BreadcrumbHint; + + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://api.example.com/users' } }, textHint); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('syncs synchronously for a genuinely binary response, keeping the unparseable marker', async () => { + SentryCore.addBreadcrumb( + { category: 'xhr', data: { url: 'https://api.example.com/users' } }, + getBlobXhrHint('\u0000\u0001', 'image/png'), + ); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '[UNPARSEABLE_BODY_TYPE]' }); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('still syncs the breadcrumb to native when the body read fails', async () => { + installFileReader({ fail: true }); + + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://api.example.com/users' } }, getBlobXhrHint()); + + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + + await flushMicrotasks(); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '[UNPARSEABLE_BODY_TYPE]' }); + }); + + it('does not sync to native for a URL that is not allow-listed', async () => { + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://other.example.org/users' } }, getBlobXhrHint()); + + // Not allow-listed, so no async read — synced right away, without details. + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toBeUndefined(); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); +}); + +function installFileReader(behavior: { fail?: boolean } = {}): void { + class MockFileReader { + public result: string | null = null; + public error: Error | null = null; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + public onabort: (() => void) | null = null; + + public readAsText(blob: Blob): void { + if (behavior.fail) { + this.error = new Error('read failed'); + queueMicrotask(() => this.onerror?.()); + return; + } + blob.text().then(text => { + this.result = text; + this.onload?.(); + }); + } + + public abort(): void { + this.onabort?.(); + } + } + (globalThis as { FileReader?: unknown }).FileReader = MockFileReader; +} diff --git a/packages/core/test/replay/networkUtils.test.ts b/packages/core/test/replay/networkUtils.test.ts index 9bf08658a6..320822d13b 100644 --- a/packages/core/test/replay/networkUtils.test.ts +++ b/packages/core/test/replay/networkUtils.test.ts @@ -1,6 +1,7 @@ import { decodeUtf8, getBodySize, + isBlobDecodeError, isTextLikeContentType, parseContentLengthHeader, readBlobAsText, @@ -74,6 +75,9 @@ describe('networkUtils', () => { ['image/svg+xml', true], ['application/x-www-form-urlencoded', true], ['APPLICATION/JSON', true], + ['application/javascript', true], + ['text/javascript; charset=utf-8', true], + ['application/ecmascript', true], ['image/png', false], ['application/octet-stream', false], ['video/mp4', false], @@ -158,10 +162,26 @@ describe('networkUtils', () => { jest.advanceTimersByTime(500); await assertion; }); + + it('rejects with a decode error when the reader yields a non-string result', async () => { + // iOS resolves with null when the bytes are not valid in the requested + // encoding, e.g. a slice that cut a multi-byte UTF-8 sequence in half. + installMockFileReader({ resolveWithNull: true }); + + await expect(readBlobAsText(new Blob(['x']), 500)).rejects.toSatisfy(isBlobDecodeError); + }); + + it('does not classify other failures as decode errors', async () => { + installMockFileReader({ failWith: new Error('read failed') }); + + await expect(readBlobAsText(new Blob(['x']), 500)).rejects.not.toSatisfy(isBlobDecodeError); + }); }); }); -function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boolean } = {}): void { +function installMockFileReader( + behavior: { failWith?: Error; neverComplete?: boolean; resolveWithNull?: boolean } = {}, +): void { class MockFileReader { public result: string | null = null; public error: Error | null = null; @@ -178,6 +198,11 @@ function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boo queueMicrotask(() => this.onerror?.()); return; } + if (behavior.resolveWithNull) { + this.result = null; + queueMicrotask(() => this.onload?.()); + return; + } blob.text().then( text => { this.result = text; diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index d8475cb557..cef9939b52 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -4,6 +4,7 @@ import type { ResolvedNetworkOptions } from '../../src/js/replay/networkUtils'; import { NETWORK_BODY_MAX_SIZE } from '../../src/js/replay/networkUtils'; import { + buildResolvedNetworkBreadcrumb, enrichXhrBreadcrumbsForMobileReplay, makeEnrichXhrBreadcrumbsForMobileReplay, REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY, @@ -456,6 +457,43 @@ describe('xhrUtils', () => { expect(resolved.body._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); }); + it('retries a truncated read with a shorter slice when the slice cut a multi-byte character', async () => { + // iOS fails to decode the whole chunk (null result) when the byte slice + // splits a multi-byte UTF-8 sequence, rather than substituting U+FFFD. + const readLengths: number[] = []; + installMockFileReader({ failToDecodeUnlessSliceEndsAt: NETWORK_BODY_MAX_SIZE - 2, readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + expect(readLengths).toEqual([NETWORK_BODY_MAX_SIZE, NETWORK_BODY_MAX_SIZE - 1, NETWORK_BODY_MAX_SIZE - 2]); + expect(resolved.body.body?.length).toBe(NETWORK_BODY_MAX_SIZE - 2); + expect(resolved.body._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('gives up after exhausting the multi-byte boundary retries', async () => { + const readLengths: number[] = []; + installMockFileReader({ failToDecodeUnlessSliceEndsAt: -1, readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + // a UTF-8 sequence is at most 4 bytes, so at most 4 attempts + expect(readLengths).toHaveLength(4); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + }); + + it('does not retry a read that failed for a reason other than decoding', async () => { + const readLengths: number[] = []; + installMockFileReader({ failWith: new Error('boom'), readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + expect(readLengths).toEqual([NETWORK_BODY_MAX_SIZE]); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + }); + it('decodes arraybuffer responses including multi-byte characters', async () => { const body = '{"name":"Пёс 🐕"}'; const { xhr } = getArrayBufferXhrHint(body); @@ -510,6 +548,54 @@ describe('xhrUtils', () => { }); }); }); + + describe('buildResolvedNetworkBreadcrumb', () => { + const getResolved = (body: string) => ({ + body: { body }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: body.length, + }); + + it('returns a copy, leaving the original breadcrumb untouched', () => { + const hint = getBlobXhrHint(); + const original: Breadcrumb = { category: 'xhr', timestamp: 42, data: { url: 'https://api.example.com/users' } }; + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions()); + // the synchronous pass marks the body as unparseable + enrich(original, hint); + const beforeResponse = original.data?.response; + + const resolved = buildResolvedNetworkBreadcrumb( + original, + hint, + getResolved('{"ok":true}'), + getCaptureAllOptions(), + ); + + expect(resolved).not.toBe(original); + expect(resolved.timestamp).toBe(42); + expect((resolved.data?.response as { body?: string }).body).toBe('{"ok":true}'); + expect(original.data?.response).toBe(beforeResponse); + }); + + it('replaces the synchronous unparseable marker instead of merging with it', () => { + const hint = getBlobXhrHint(); + const original: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions())(original, hint); + expect((original.data?.response as { body?: string }).body).toBe('[UNPARSEABLE_BODY_TYPE]'); + + const resolved = buildResolvedNetworkBreadcrumb( + original, + hint, + getResolved('{"ok":true}'), + getCaptureAllOptions(), + ); + + const response = resolved.data?.response as { body?: string; _meta?: { warnings: string[] } }; + expect(response.body).toBe('{"ok":true}'); + expect(response._meta).toBeUndefined(); + }); + }); }); function getCaptureAllOptions(): ResolvedNetworkOptions { @@ -565,7 +651,16 @@ function getArrayBufferXhrHint(body: string) { }; } -function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boolean } = {}): void { +function installMockFileReader( + behavior: { + failWith?: Error; + neverComplete?: boolean; + /** Yield a null result (iOS decode failure) unless the slice ends at this byte. */ + failToDecodeUnlessSliceEndsAt?: number; + /** Collects the byte length of every slice handed to the reader. */ + readLengths?: number[]; + } = {}, +): void { class MockFileReader { public result: string | null = null; public error: Error | null = null; @@ -574,6 +669,7 @@ function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boo public onabort: (() => void) | null = null; public readAsText(blob: Blob): void { + behavior.readLengths?.push(blob.size); if (behavior.neverComplete) { return; } @@ -582,6 +678,14 @@ function installMockFileReader(behavior: { failWith?: Error; neverComplete?: boo queueMicrotask(() => this.onerror?.()); return; } + if ( + behavior.failToDecodeUnlessSliceEndsAt !== undefined && + blob.size !== behavior.failToDecodeUnlessSliceEndsAt + ) { + this.result = null; + queueMicrotask(() => this.onload?.()); + return; + } blob.text().then( text => { this.result = text; diff --git a/packages/core/test/scopeSync.test.ts b/packages/core/test/scopeSync.test.ts index a7bd54ac4c..b679daa998 100644 --- a/packages/core/test/scopeSync.test.ts +++ b/packages/core/test/scopeSync.test.ts @@ -3,7 +3,7 @@ import type { Breadcrumb } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; -import { enableSyncToNative } from '../src/js/scopeSync'; +import { deferBreadcrumbNativeSync, enableSyncToNative, syncBreadcrumbToNative } from '../src/js/scopeSync'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); @@ -108,6 +108,56 @@ describe('ScopeSync', () => { ); }); }); + + describe('deferBreadcrumbNativeSync', () => { + it('adds the breadcrumb to the scope but skips the sync to native', () => { + const breadcrumb: Breadcrumb = { message: 'deferred' }; + + deferBreadcrumbNativeSync(breadcrumb); + scope.addBreadcrumb(breadcrumb); + + expect(scope.getLastBreadcrumb()).toEqual(expect.objectContaining({ message: 'deferred' })); + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('consumes the mark, so re-adding the same breadcrumb syncs normally', () => { + const breadcrumb: Breadcrumb = { message: 'deferred' }; + + deferBreadcrumbNativeSync(breadcrumb); + scope.addBreadcrumb(breadcrumb); + scope.addBreadcrumb(breadcrumb); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('does not affect other breadcrumbs', () => { + deferBreadcrumbNativeSync({ message: 'deferred' }); + + scope.addBreadcrumb({ message: 'other' }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'other' })); + }); + }); + + describe('syncBreadcrumbToNative', () => { + it('forwards a normalized breadcrumb to native without touching the scope', () => { + syncBreadcrumbToNative({ message: 'test', data: { foo: NaN } }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledExactlyOnceWith({ + message: 'test', + level: 'info', + data: { foo: '[NaN]' }, + }); + expect(scope.getLastBreadcrumb()).toBeUndefined(); + }); + + it('keeps an explicit level', () => { + syncBreadcrumbToNative({ message: 'test', level: 'error' }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ level: 'error' })); + }); + }); }); describe('static apis', () => { From 45dd41293c3cf20685e6cb8b7e41ec383f00454c Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 29 Jul 2026 11:55:49 +0200 Subject: [PATCH 3/3] Update CHANGELOG.md Co-authored-by: Antonis Lilis --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fc6efaa7..c1e1ea94fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,6 @@ - Session Replay network details now capture `fetch` (Blob/ArrayBuffer) response bodies ([#6473](https://github.com/getsentry/sentry-react-native/pull/6473), [#6533](https://github.com/getsentry/sentry-react-native/pull/6533)) - React Native's `fetch` is built on `XMLHttpRequest` with `responseType: 'blob'`, so fetch response bodies previously always showed `[UNPARSEABLE_BODY_TYPE]` in the Replay network tab. Text-like binary payloads (JSON, XML, JavaScript, `text/*`, form data) are now read asynchronously and inlined like text bodies — still only for URLs matching `networkDetailAllowUrls` with `networkCaptureBodies` enabled, with the same ~150 KB cap. Genuinely binary payloads (images, media) remain marked as unparseable. - - Because a binary body can only be read asynchronously while breadcrumbs are forwarded to the native SDKs synchronously, the breadcrumb still reaches the JavaScript scope immediately — error events captured while the read is in flight keep it — and only the sync to native is deferred until the body has been read. The breadcrumb keeps its original timestamp, so the replay places it correctly. Note that the breadcrumb attached to a JS error event still shows `[UNPARSEABLE_BODY_TYPE]` for these responses; the resolved body appears in the Replay network tab. - ### Changes - Expose `instrumentStateGraph` for manual LangGraph instrumentation ([#6520](https://github.com/getsentry/sentry-react-native/pull/6520))