From 66f029af18bb6e73754e36cc1b925c818809198d Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Sun, 19 Jul 2026 12:04:56 +0300 Subject: [PATCH 1/4] 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 --- packages/core/src/js/replay/mobilereplay.ts | 52 ++++- packages/core/src/js/replay/networkUtils.ts | 129 +++++++++++ packages/core/src/js/replay/xhrUtils.ts | 98 +++++++- .../core/test/replay/mobilereplay.test.ts | 143 +++++++++++- .../core/test/replay/networkUtils.test.ts | 127 ++++++++++- packages/core/test/replay/xhrUtils.test.ts | 215 ++++++++++++++++++ 6 files changed, 756 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 9064d8304c..c724d1ba69 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,35 @@ 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 body resolved on the hint. + 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(resolvedBody => { + addBreadcrumb(result, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolvedBody }); + }) + .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..45d482e4cd 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,132 @@ 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]!; + 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; + } + + if (i + extraBytes >= bytes.length) { + // truncated sequence at the end of the buffer + out += '�'; + break; + } + + let valid = true; + for (let j = 1; j <= extraBytes; j++) { + const continuation = bytes[i + j]!; + if ((continuation & 0xc0) !== 0x80) { + valid = false; + break; + } + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + + if (!valid || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + out += '�'; + i += 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..25fada5009 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,15 @@ interface NetworkBreadcrumbSide { _meta?: { warnings: string[] }; } +/** + * Hint key carrying a response body that was read asynchronously (Blob / + * ArrayBuffer responses) before the breadcrumb was re-added. When present, + * enrichment uses it instead of reading `xhr.response` synchronously. + */ +export const REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY = '__mobile_replay_resolved_response_body__'; + +type ResolvedBodyCarrier = { [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]?: NetworkBody }; + const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = { allowUrls: [], denyUrls: [], @@ -75,7 +89,11 @@ function enrichXhrBreadcrumb( if (shouldCaptureNetworkDetails(url, networkOptions)) { request = _buildRequestDetails(input, xhr, networkOptions); - response = _buildResponseDetails(xhr, networkOptions); + response = _buildResponseDetails( + xhr, + networkOptions, + (hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY], + ); } breadcrumb.data = dropUndefinedKeys({ @@ -108,6 +126,7 @@ function _buildRequestDetails( function _buildResponseDetails( xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, networkOptions: ResolvedNetworkOptions, + resolvedBody: NetworkBody | undefined, ): NetworkBreadcrumbSide | undefined { let rawHeaders: string | null = null; try { @@ -119,7 +138,7 @@ function _buildResponseDetails( let body: NetworkBody | undefined; if (networkOptions.captureBodies) { - body = _getResponseBodyString(xhr); + body = resolvedBody ?? _getResponseBodyString(xhr); } return _toBreadcrumbSide(headers, body); @@ -174,6 +193,81 @@ 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). Resolves to an + * UNPARSEABLE_BODY_TYPE warning on read failure or timeout — never rejects. + */ +export async function resolveXhrResponseBody(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..93ee4a9457 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,133 @@ 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: '{"ok":true}' }, + }), + ); + }); + + 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: '{"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..e61d122704 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,123 @@ 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�'); + }); + }); + }); + + 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..9d60d62e70 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,219 @@ 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: '{"resolved":true}' }, + }; + enrich(breadcrumb, hint); + + expect((breadcrumb.data?.response as { body?: string }).body).toBe('{"resolved":true}'); + }); + }); + + 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: '{"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', async () => { + installMockFileReader(); + const { xhr } = getBlobXhrHint(); + await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ + body: '{"ok":true}', + }); + }); + + 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?.length).toBe(NETWORK_BODY_MAX_SIZE); + expect(resolved._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('decodes arraybuffer responses including multi-byte characters', async () => { + const { xhr } = getArrayBufferXhrHint('{"name":"Пёс 🐕"}'); + await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ + body: '{"name":"Пёс 🐕"}', + }); + }); + + it('resolves to an unparseable marker when the read fails', async () => { + installMockFileReader({ failWith: new Error('boom') }); + const { xhr } = getBlobXhrHint(); + await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ + _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] }, + }); + }); + + it('resolves 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(); + await expect(promise).resolves.toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + } finally { + jest.useRealTimers(); + } + }); }); }); +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 5aee932fdb787324abba87fd67158b3e2b7c8b70 Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Sun, 19 Jul 2026 12:19:43 +0300 Subject: [PATCH 2/4] changelog Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e708fddb..eda2cc9b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- 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. + ## 8.19.0 ### Features From 7c638d34254ed4ca7972ef21509574da7efc1f41 Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Mon, 20 Jul 2026 02:11:05 +0300 Subject: [PATCH 3/4] ref(replay): snapshot xhr metadata before the async body read; fix utf-8 decoder dropping bytes after an interrupted sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveXhrResponseBody now snapshots request headers, raw response headers and the response body size synchronously, and the re-added breadcrumb is enriched only from that snapshot — the live xhr may have been reused or cleared during the async gap (Bugbot: stale XHR data). - the manual UTF-8 fallback decoder no longer discards bytes following a truncated/interrupted multi-byte sequence; the consumed prefix decodes to a single U+FFFD (maximal subpart), matching TextDecoder. - drop non-null assertions flagged by oxlint no-unnecessary-type-assertion. Co-Authored-By: Claude Fable 5 --- packages/core/src/js/replay/mobilereplay.ts | 7 +- packages/core/src/js/replay/networkUtils.ts | 26 +++--- packages/core/src/js/replay/xhrUtils.ts | 90 ++++++++++++++----- .../core/test/replay/mobilereplay.test.ts | 9 +- .../core/test/replay/networkUtils.test.ts | 9 ++ packages/core/test/replay/xhrUtils.test.ts | 85 +++++++++++++++--- 6 files changed, 171 insertions(+), 55 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index c724d1ba69..c1ce99372c 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -454,7 +454,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // 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 body resolved on the hint. + // 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 => { @@ -468,8 +469,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } const xhr = hint.xhr; resolveXhrResponseBody(xhr) - .then(resolvedBody => { - addBreadcrumb(result, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolvedBody }); + .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); diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 45d482e4cd..6c242ea7ac 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -253,7 +253,7 @@ export function decodeUtf8(bytes: Uint8Array): string { let out = ''; let i = 0; while (i < bytes.length) { - const byte = bytes[i]!; + const byte = bytes[i] ?? 0; let codePoint: number; let extraBytes: number; if (byte < 0x80) { @@ -274,23 +274,25 @@ export function decodeUtf8(bytes: Uint8Array): string { continue; } - if (i + extraBytes >= bytes.length) { - // truncated sequence at the end of the buffer - out += '�'; - break; - } - - let valid = true; - for (let j = 1; j <= extraBytes; j++) { - const continuation = bytes[i + j]!; + let consumed = 0; + while (consumed < extraBytes && i + 1 + consumed < bytes.length) { + const continuation = bytes[i + 1 + consumed] ?? 0; if ((continuation & 0xc0) !== 0x80) { - valid = false; 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 (!valid || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { out += '�'; i += 1; continue; diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 25fada5009..2ff34d76c9 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -25,13 +25,26 @@ interface NetworkBreadcrumbSide { } /** - * Hint key carrying a response body that was read asynchronously (Blob / - * ArrayBuffer responses) before the breadcrumb was re-added. When present, - * enrichment uses it instead of reading `xhr.response` synchronously. + * 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__'; -type ResolvedBodyCarrier = { [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]?: NetworkBody }; +/** + * 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: [], @@ -77,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; @@ -88,12 +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, + request = _buildRequestDetails( + input, + resolved ? resolved.requestHeaders : xhr.__sentry_xhr_v3__?.request_headers, networkOptions, - (hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY], ); + response = _buildResponseDetails(xhr, networkOptions, resolved); } breadcrumb.data = dropUndefinedKeys({ @@ -109,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) { @@ -126,24 +140,33 @@ function _buildRequestDetails( function _buildResponseDetails( xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, networkOptions: ResolvedNetworkOptions, - resolvedBody: NetworkBody | undefined, + 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 = resolvedBody ?? _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, @@ -233,10 +256,29 @@ export function shouldCaptureResponseBodyAsync( /** * Read the body of a binary (`blob` / `arraybuffer`) XHR response and serialize - * it like a text body (size cap + truncation warning). Resolves to an + * 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): Promise { +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; diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 93ee4a9457..c597317885 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -652,7 +652,12 @@ describe('Mobile Replay Integration', () => { expect(mockAddBreadcrumb).toHaveBeenCalledWith( breadcrumb, expect.objectContaining({ - [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: '{"ok":true}' }, + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { + body: { body: '{"ok":true}' }, + requestHeaders: {}, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 11, + }, }), ); }); @@ -670,7 +675,7 @@ describe('Mobile Replay Integration', () => { 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: '{"ok":true}' } }; + const resolvedHint = { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } } }; expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint)).toBe(breadcrumb); diff --git a/packages/core/test/replay/networkUtils.test.ts b/packages/core/test/replay/networkUtils.test.ts index e61d122704..5e13097ac3 100644 --- a/packages/core/test/replay/networkUtils.test.ts +++ b/packages/core/test/replay/networkUtils.test.ts @@ -114,6 +114,15 @@ describe('networkUtils', () => { 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'); }); }); }); diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 9d60d62e70..d8475cb557 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -338,12 +338,40 @@ describe('xhrUtils', () => { const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; const hint = { ...getBlobXhrHint(), - [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: '{"resolved":true}' }, + [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', () => { @@ -397,7 +425,7 @@ describe('xhrUtils', () => { const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; const hint = { ...getBlobXhrHint(), - [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: '{"ok":true}' }, + [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } }, }; expect(shouldCaptureResponseBodyAsync(breadcrumb, hint, getCaptureAllOptions())).toBe(false); }); @@ -408,11 +436,14 @@ describe('xhrUtils', () => { delete (globalThis as { FileReader?: unknown }).FileReader; }); - it('reads a JSON blob response into a text body', async () => { + 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: '{"ok":true}', + body: { body: '{"ok":true}' }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: 11, }); }); @@ -421,37 +452,63 @@ describe('xhrUtils', () => { 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?.length).toBe(NETWORK_BODY_MAX_SIZE); - expect(resolved._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + 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 { xhr } = getArrayBufferXhrHint('{"name":"Пёс 🐕"}'); + const body = '{"name":"Пёс 🐕"}'; + const { xhr } = getArrayBufferXhrHint(body); await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ - body: '{"name":"Пёс 🐕"}', + body: { body }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: new TextEncoder().encode(body).byteLength, }); }); - it('resolves to an unparseable marker when the read fails', async () => { + it('resolves the body to an unparseable marker when the read fails', async () => { installMockFileReader({ failWith: new Error('boom') }); const { xhr } = getBlobXhrHint(); - await expect(resolveXhrResponseBody(xhr as unknown as XMLHttpRequest)).resolves.toEqual({ - _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] }, - }); + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); }); - it('resolves to an unparseable marker on timeout', async () => { + 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(); - await expect(promise).resolves.toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + 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, + }); + }); }); }); From e479547552d05eaa83512b3074688efcbe2aa1aa Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Mon, 20 Jul 2026 02:28:07 +0300 Subject: [PATCH 4/4] fix(replay): consume the whole utf-8 sequence when it encodes an invalid code point A structurally complete sequence that decodes to a surrogate or a code point above U+10FFFF previously advanced by one byte, so its continuation bytes were re-decoded as stray bytes and produced extra U+FFFDs. Co-Authored-By: Claude Fable 5 --- packages/core/src/js/replay/networkUtils.ts | 4 +++- packages/core/test/replay/networkUtils.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 6c242ea7ac..9468216466 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -293,8 +293,10 @@ export function decodeUtf8(bytes: Uint8Array): string { } 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 += 1; + i += extraBytes + 1; continue; } diff --git a/packages/core/test/replay/networkUtils.test.ts b/packages/core/test/replay/networkUtils.test.ts index 5e13097ac3..9bf08658a6 100644 --- a/packages/core/test/replay/networkUtils.test.ts +++ b/packages/core/test/replay/networkUtils.test.ts @@ -124,6 +124,13 @@ describe('networkUtils', () => { // 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'); + }); }); });