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 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 {