From 901cde754c5bd5ac9c602d66b7189836c4f1e58c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 12:13:49 +0200 Subject: [PATCH 01/14] Record network request/response bodies in Session Replay --- CHANGELOG.md | 1 + .../RNSentryReplayBreadcrumbConverter.java | 6 + .../ios/RNSentryReplayBreadcrumbConverter.m | 6 + packages/core/src/js/replay/mobilereplay.ts | 76 +++++- packages/core/src/js/replay/networkUtils.ts | 163 +++++++++++++ packages/core/src/js/replay/xhrUtils.ts | 129 +++++++++- packages/core/test/replay/xhrUtils.test.ts | 230 +++++++++++++++++- 7 files changed, 604 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37292bbb0..e9d150d662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) +- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#XXXX](https://github.com/getsentry/sentry-react-native/issues/XXXX)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index 9755c27617..3e570fab35 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -175,6 +175,12 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if (breadcrumb.getData("response_body_size") instanceof Double) { data.put("responseBodySize", breadcrumb.getData("response_body_size")); } + if (breadcrumb.getData("request") instanceof Map) { + data.put("request", breadcrumb.getData("request")); + } + if (breadcrumb.getData("response") instanceof Map) { + data.put("response", breadcrumb.getData("response")); + } final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent(); rrWebSpanEvent.setOp("resource.http"); diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index 6bc5366481..eb7081efb6 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -179,6 +179,12 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; } + if ([breadcrumb.data[@"request"] isKindOfClass:[NSDictionary class]]) { + data[@"request"] = breadcrumb.data[@"request"]; + } + if ([breadcrumb.data[@"response"] isKindOfClass:[NSDictionary class]]) { + data[@"response"] = breadcrumb.data[@"response"]; + } return [SentrySessionReplayHybridSDK createNetworkBreadcrumbWithTimestamp:[NSDate diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 51b23e2a12..f1f7a1df59 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -2,11 +2,13 @@ import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint, Inte import { debug } from '@sentry/core'; +import type { ResolvedNetworkOptions } from './networkUtils'; + import { isHardCrash } from '../misc'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { NATIVE } from '../wrapper'; -import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; +import { makeEnrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay'; @@ -148,6 +150,64 @@ export interface MobileReplayOptions { * @returns `false` to skip capturing a replay for this error, `true` or `undefined` to proceed with sampling */ beforeErrorSampling?: (event: Event, hint: EventHint) => boolean; + + /** + * List of URLs (string match or RegExp) for which request and response details + * (headers and, when `networkCaptureBodies` is true, bodies) are captured and + * surfaced in the Replay network tab. + * + * String matches use substring matching; RegExp must match via `.test(url)`. + * Bodies are only captured for URLs that match `networkDetailAllowUrls` and + * do not match `networkDetailDenyUrls`. + * + * Authorization-like headers (`authorization`, `cookie`, `set-cookie`, + * `x-api-key`, `x-auth-token`, `proxy-authorization`) are always stripped. + * + * Currently only XHR requests are supported (this covers `axios` and similar + * libraries). Fetch body capture will be added in a follow-up. + * + * @default [] + */ + networkDetailAllowUrls?: (string | RegExp)[]; + + /** + * URLs (string match or RegExp) to exclude from network detail capture even + * if they match `networkDetailAllowUrls`. Use this to prevent capturing + * details for known-sensitive endpoints. + * + * @default [] + */ + networkDetailDenyUrls?: (string | RegExp)[]; + + /** + * If request and response bodies should be captured for URLs matched by + * `networkDetailAllowUrls`. When `false`, only headers are captured. + * + * Bodies are truncated at ~150 KB; truncated payloads include a + * `MAX_BODY_SIZE_EXCEEDED` warning. + * + * @default true + */ + networkCaptureBodies?: boolean; + + /** + * Additional request headers (case-insensitive names) to capture for matched + * URLs in addition to the defaults (`content-type`, `content-length`, `accept`). + * + * Note: only headers explicitly set on the `XMLHttpRequest` via + * `setRequestHeader` are observable; browser-managed headers are not. + * + * @default [] + */ + networkRequestHeaders?: string[]; + + /** + * Additional response headers (case-insensitive names) to capture for matched + * URLs in addition to the defaults (`content-type`, `content-length`, `accept`). + * + * @default [] + */ + networkResponseHeaders?: string[]; } const defaultOptions: MobileReplayOptions = { @@ -158,6 +218,11 @@ const defaultOptions: MobileReplayOptions = { enableViewRendererV2: true, enableFastViewRendering: false, screenshotStrategy: 'pixelCopy', + networkDetailAllowUrls: [], + networkDetailDenyUrls: [], + networkCaptureBodies: true, + networkRequestHeaders: [], + networkResponseHeaders: [], }; function mergeOptions(initOptions: Partial): MobileReplayOptions { @@ -318,7 +383,14 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } }); - client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay); + const networkOptions: ResolvedNetworkOptions = { + allowUrls: options.networkDetailAllowUrls ?? [], + denyUrls: options.networkDetailDenyUrls ?? [], + captureBodies: options.networkCaptureBodies ?? true, + requestHeaders: options.networkRequestHeaders ?? [], + responseHeaders: options.networkResponseHeaders ?? [], + }; + client.on('beforeAddBreadcrumb', makeEnrichXhrBreadcrumbsForMobileReplay(networkOptions)); // Wrap beforeSend to run processEvent after user's beforeSend const clientOptions = client.getOptions(); diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 68493f25d0..be227781f7 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -54,3 +54,166 @@ function _serializeFormData(formData: FormData): string { // @ts-expect-error passing FormData to URLSearchParams won't correctly serialize `File` entries, which is fine for this use-case. See https://github.com/microsoft/TypeScript/issues/30584 return new URLSearchParams(formData).toString(); } + +export const NETWORK_BODY_MAX_SIZE = 150_000; + +export const DEFAULT_NETWORK_HEADERS = ['content-type', 'content-length', 'accept']; + +const DENY_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +]); + +export interface ResolvedNetworkOptions { + allowUrls: (string | RegExp)[]; + denyUrls: (string | RegExp)[]; + captureBodies: boolean; + requestHeaders: string[]; + responseHeaders: string[]; +} + +/** Check if a URL matches any pattern in the list. Strings use substring match; RegExp uses .test(). */ +function _urlMatches(url: string, patterns: (string | RegExp)[]): boolean { + for (const pattern of patterns) { + if (typeof pattern === 'string') { + if (url.indexOf(pattern) !== -1) { + return true; + } + } else if (pattern instanceof RegExp) { + if (pattern.test(url)) { + return true; + } + } + } + return false; +} + +/** + * Whether to capture full network details (headers, bodies) for a given URL, + * based on the allow/deny URL lists. + */ +export function shouldCaptureNetworkDetails(url: string | undefined, options: ResolvedNetworkOptions): boolean { + if (!url || options.allowUrls.length === 0) { + return false; + } + if (!_urlMatches(url, options.allowUrls)) { + return false; + } + if (options.denyUrls.length > 0 && _urlMatches(url, options.denyUrls)) { + return false; + } + return true; +} + +export interface NetworkBody { + body?: string; + /** Warnings about the captured body, e.g. truncation or unserializable type. */ + _meta?: { warnings: string[] }; +} + +/** + * Serialize a request/response body to a string, truncated to NETWORK_BODY_MAX_SIZE. + * Returns undefined if the body is empty/missing; returns a meta warning if unserializable + * or truncated. + */ +export function getBodyString(body: unknown): NetworkBody | undefined { + if (body == null) { + return undefined; + } + + try { + let bodyStr: string | undefined; + + if (typeof body === 'string') { + bodyStr = body; + } else if (body instanceof URLSearchParams) { + bodyStr = body.toString(); + } else if (body instanceof FormData) { + bodyStr = _serializeFormData(body); + } else if (typeof body === 'object') { + // Last-ditch attempt: try to JSON-stringify plain objects (e.g. xhr.response with responseType='json') + try { + bodyStr = JSON.stringify(body); + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } + } + + if (bodyStr === undefined) { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } + + if (bodyStr.length > NETWORK_BODY_MAX_SIZE) { + return { + body: bodyStr.slice(0, NETWORK_BODY_MAX_SIZE), + _meta: { warnings: ['MAX_BODY_SIZE_EXCEEDED'] }, + }; + } + + return { body: bodyStr }; + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } +} + +/** + * Filter a headers map down to the set explicitly captured (defaults + user-supplied) + * and strip authorization-like headers. Header name comparison is case-insensitive; + * the returned keys are lowercased. + */ +export function filterHeaders( + headers: Record | undefined, + extraAllowed: string[], +): Record | undefined { + if (!headers) { + return undefined; + } + const allowed = new Set([...DEFAULT_NETWORK_HEADERS, ...extraAllowed.map(h => h.toLowerCase())]); + const out: Record = {}; + let count = 0; + for (const rawName of Object.keys(headers)) { + const name = rawName.toLowerCase(); + if (DENY_HEADERS.has(name)) { + continue; + } + if (!allowed.has(name)) { + continue; + } + const value = headers[rawName]; + if (value == null) { + continue; + } + out[name] = value; + count += 1; + } + return count > 0 ? out : undefined; +} + +/** Parse the raw string returned by XMLHttpRequest.getAllResponseHeaders() into a record. */ +export function parseAllResponseHeaders(raw: string | null | undefined): Record { + if (!raw) { + return {}; + } + const result: Record = {}; + // Headers are CRLF-delimited; some implementations use LF only. + const lines = raw.split(/\r?\n/); + for (const line of lines) { + if (!line) { + continue; + } + const idx = line.indexOf(':'); + if (idx === -1) { + continue; + } + const name = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (name) { + result[name] = value; + } + } + return result; +} diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 40daca2372..567aa08571 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -2,14 +2,55 @@ import type { Breadcrumb, BreadcrumbHint, SentryWrappedXMLHttpRequest, XhrBreadc import { dropUndefinedKeys } from '@sentry/core'; -import type { RequestBody } from './networkUtils'; +import type { NetworkBody, RequestBody, ResolvedNetworkOptions } from './networkUtils'; -import { getBodySize, parseContentLengthHeader } from './networkUtils'; +import { + filterHeaders, + getBodySize, + getBodyString, + parseAllResponseHeaders, + parseContentLengthHeader, + shouldCaptureNetworkDetails, +} from './networkUtils'; + +interface NetworkBreadcrumbSide { + body?: string; + headers?: Record; + _meta?: { warnings: string[] }; +} + +const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = { + allowUrls: [], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], +}; + +/** + * Build a `beforeAddBreadcrumb` handler that enriches XHR breadcrumbs with + * network details (sizes always; headers/bodies for URLs matching the allow + * list and not the deny list) for the Mobile Replay network tab. + */ +export function makeEnrichXhrBreadcrumbsForMobileReplay( + networkOptions: ResolvedNetworkOptions, +): (breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined) => void { + return (breadcrumb, hint) => enrichXhrBreadcrumb(breadcrumb, hint, networkOptions); +} /** * Enrich an XHR breadcrumb with additional data for Mobile Replay network tab. + * Preserves the legacy behaviour: sizes only, no headers/bodies. */ export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined): void { + enrichXhrBreadcrumb(breadcrumb, hint, DEFAULT_NETWORK_OPTIONS); +} + +function enrichXhrBreadcrumb( + breadcrumb: Breadcrumb, + hint: BreadcrumbHint | undefined, + networkOptions: ResolvedNetworkOptions, +): void { if (breadcrumb.category !== 'xhr' || !hint) { return; } @@ -27,15 +68,99 @@ export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) : _getBodySize(xhr.response, xhr.responseType); + let request: NetworkBreadcrumbSide | undefined; + let response: NetworkBreadcrumbSide | undefined; + + const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : undefined; + + if (shouldCaptureNetworkDetails(url, networkOptions)) { + request = _buildRequestDetails(input, xhr, networkOptions); + response = _buildResponseDetails(xhr, networkOptions); + } + breadcrumb.data = dropUndefinedKeys({ start_timestamp: startTimestamp, end_timestamp: endTimestamp, request_body_size: reqSize, response_body_size: resSize, ...breadcrumb.data, + request, + response, }); } +function _buildRequestDetails( + input: RequestBody | undefined, + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, + networkOptions: ResolvedNetworkOptions, +): NetworkBreadcrumbSide | undefined { + const sentryXhr = xhr.__sentry_xhr_v3__; + const headers = filterHeaders(sentryXhr?.request_headers, networkOptions.requestHeaders); + + let body: NetworkBody | undefined; + if (networkOptions.captureBodies) { + body = getBodyString(input); + } + + return _toBreadcrumbSide(headers, body); +} + +function _buildResponseDetails( + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, + networkOptions: ResolvedNetworkOptions, +): NetworkBreadcrumbSide | undefined { + let rawHeaders: string | null = null; + try { + rawHeaders = xhr.getAllResponseHeaders(); + } catch { + // ignore — some environments may throw before the request is complete + } + const headers = filterHeaders(parseAllResponseHeaders(rawHeaders), networkOptions.responseHeaders); + + let body: NetworkBody | undefined; + if (networkOptions.captureBodies) { + body = _getResponseBodyString(xhr); + } + + return _toBreadcrumbSide(headers, body); +} + +function _toBreadcrumbSide( + headers: Record | undefined, + body: NetworkBody | undefined, +): NetworkBreadcrumbSide | undefined { + const side: NetworkBreadcrumbSide = {}; + if (headers) { + side.headers = headers; + } + if (body?.body !== undefined) { + side.body = body.body; + } + if (body?._meta) { + side._meta = body._meta; + } + return Object.keys(side).length > 0 ? side : undefined; +} + +function _getResponseBodyString(xhr: XMLHttpRequest): NetworkBody | undefined { + try { + if (xhr.responseType === '' || xhr.responseType === 'text') { + // responseText only exists for text/empty responseType + return getBodyString(xhr.responseText); + } + if (xhr.responseType === 'json') { + return getBodyString(xhr.response); + } + if (xhr.response == null) { + return undefined; + } + // For 'blob' / 'arraybuffer' / 'document' we don't attempt to inline binary data. + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } +} + type XhrHint = XhrBreadcrumbHint & { xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; input?: RequestBody; diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 477e8bc661..81fcbd1d07 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -1,6 +1,10 @@ import type { Breadcrumb } from '@sentry/core'; -import { enrichXhrBreadcrumbsForMobileReplay } from '../../src/js/replay/xhrUtils'; +import { NETWORK_BODY_MAX_SIZE } from '../../src/js/replay/networkUtils'; +import { + enrichXhrBreadcrumbsForMobileReplay, + makeEnrichXhrBreadcrumbsForMobileReplay, +} from '../../src/js/replay/xhrUtils'; describe('xhrUtils', () => { describe('enrichXhrBreadcrumbsForMobileReplay', () => { @@ -67,23 +71,243 @@ describe('xhrUtils', () => { }), ); }); + + it('does not capture bodies or headers when allow list is empty', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + }); + + describe('makeEnrichXhrBreadcrumbsForMobileReplay', () => { + it('captures request and response when url matches allow list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toEqual({ + body: 'test-input', + headers: { 'content-type': 'application/json' }, + }); + expect(breadcrumb.data?.response).toEqual( + expect.objectContaining({ + body: '{"ok":true}', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + }), + ); + }); + + it('skips capture when URL does not match allow list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.other.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('skips capture when URL matches deny list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: ['/users'], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('honours RegExp patterns in allow/deny lists', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: [/^https:\/\/api\.example\.com\//], + denyUrls: [/\/secret/], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const allowed: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(allowed, getValidXhrHint()); + expect(allowed.data?.request).toBeDefined(); + + const denied: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/secret/key' } }; + enrich(denied, getValidXhrHint()); + expect(denied.data?.request).toBeUndefined(); + }); + + it('omits bodies when networkCaptureBodies is false but keeps headers', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toEqual({ headers: { 'content-type': 'application/json' } }); + expect(breadcrumb.data?.response).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ 'content-type': 'application/json' }), + }), + ); + expect((breadcrumb.data?.response as { body?: string }).body).toBeUndefined(); + }); + + it('truncates bodies that exceed the size cap', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const bigBody = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100); + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, { ...getValidXhrHint(), input: bigBody }); + + const request = breadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(request.body?.length).toBe(NETWORK_BODY_MAX_SIZE); + expect(request._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('strips deny-listed sensitive headers regardless of configuration', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: ['authorization', 'cookie'], + responseHeaders: ['set-cookie'], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getXhrHintWithSensitiveHeaders()); + + const requestHeaders = (breadcrumb.data?.request as { headers?: Record }).headers ?? {}; + const responseHeaders = (breadcrumb.data?.response as { headers?: Record }).headers ?? {}; + expect(requestHeaders.authorization).toBeUndefined(); + expect(requestHeaders.cookie).toBeUndefined(); + expect(responseHeaders['set-cookie']).toBeUndefined(); + expect(requestHeaders['content-type']).toBe('application/json'); + }); + + it('captures additional opt-in headers but not arbitrary headers', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: ['x-trace-id'], + responseHeaders: ['x-request-id'], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getXhrHintWithCustomHeaders()); + + const requestHeaders = (breadcrumb.data?.request as { headers?: Record }).headers ?? {}; + const responseHeaders = (breadcrumb.data?.response as { headers?: Record }).headers ?? {}; + expect(requestHeaders['x-trace-id']).toBe('trace-123'); + expect(requestHeaders['x-unrelated']).toBeUndefined(); + expect(responseHeaders['x-request-id']).toBe('req-456'); + expect(responseHeaders['x-rate-limit']).toBeUndefined(); + }); }); }); function getValidXhrHint() { + const responseHeadersRaw = 'content-type: application/json\r\ncontent-length: 13'; return { startTimestamp: 1, endTimestamp: 2, input: 'test-input', // 10 bytes xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, getResponseHeader: (key: string) => { if (key === 'content-length') { return '13'; } throw new Error('Invalid key'); }, - response: 'test-response', // 13 bytes - responseType: 'json', + getAllResponseHeaders: () => responseHeadersRaw, + response: { ok: true }, // serialized 'test-response' is 13 bytes + responseText: '{"ok":true}', + responseType: 'json' as const, + }, + }; +} + +function getXhrHintWithSensitiveHeaders() { + const responseHeadersRaw = 'content-type: application/json\r\nset-cookie: session=abc\r\ncontent-length: 13'; + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { + 'content-type': 'application/json', + Authorization: 'Bearer secret', + Cookie: 'session=abc', + }, + }, + getResponseHeader: (_key: string) => null, + getAllResponseHeaders: () => responseHeadersRaw, + response: 'test-response', + responseText: 'test-response', + responseType: 'text' as const, + }, + }; +} + +function getXhrHintWithCustomHeaders() { + const responseHeadersRaw = 'content-type: application/json\r\nx-request-id: req-456\r\nx-rate-limit: 100'; + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { + 'content-type': 'application/json', + 'X-Trace-Id': 'trace-123', + 'X-Unrelated': 'whatever', + }, + }, + getResponseHeader: (_key: string) => null, + getAllResponseHeaders: () => responseHeadersRaw, + response: 'test-response', + responseText: 'test-response', + responseType: 'text' as const, }, }; } From ce9aa11243d9a74644577ba3b6bed1902c984ebc Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 13:12:50 +0200 Subject: [PATCH 02/14] Changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d150d662..8fd44395fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) -- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#XXXX](https://github.com/getsentry/sentry-react-native/issues/XXXX)) +- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/issues/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies From ef0e8cac838ee1eda962ebd71eb77d64605c678b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 13:20:58 +0200 Subject: [PATCH 03/14] fix(replay): Skip Blob/ArrayBuffer bodies instead of serializing to "{}" `getBodyString` fell through to the `typeof === 'object'` branch for Blob and ArrayBuffer, producing `"{}"` via `JSON.stringify` with no warning. Guard both types explicitly and return `UNPARSEABLE_BODY_TYPE`, matching `getBodySize`'s handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/js/replay/networkUtils.ts | 3 +++ packages/core/test/replay/xhrUtils.test.ts | 22 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index be227781f7..8d4700c4ce 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -134,6 +134,9 @@ export function getBodyString(body: unknown): NetworkBody | undefined { bodyStr = body.toString(); } else if (body instanceof FormData) { bodyStr = _serializeFormData(body); + } else if (body instanceof Blob || body instanceof ArrayBuffer) { + // Binary payloads can't be safely inlined as text; record the type but skip the body. + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; } else if (typeof body === 'object') { // Last-ditch attempt: try to JSON-stringify plain objects (e.g. xhr.response with responseType='json') try { diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 81fcbd1d07..40893e1aeb 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -176,6 +176,28 @@ describe('xhrUtils', () => { expect((breadcrumb.data?.response as { body?: string }).body).toBeUndefined(); }); + it('marks Blob and ArrayBuffer request bodies as unparseable instead of stringifying to {}', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const blobBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(blobBreadcrumb, { ...getValidXhrHint(), input: new Blob(['binary']) }); + const blobRequest = blobBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(blobRequest.body).toBeUndefined(); + expect(blobRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + + const bufferBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(bufferBreadcrumb, { ...getValidXhrHint(), input: new ArrayBuffer(16) }); + const bufferRequest = bufferBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(bufferRequest.body).toBeUndefined(); + expect(bufferRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + }); + it('truncates bodies that exceed the size cap', () => { const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ allowUrls: ['api.example.com'], From 3c4bd5550078abcc09d1c4f335ea5571dadd8333 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 13:22:36 +0200 Subject: [PATCH 04/14] Changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd44395fc..085586d246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) -- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/issues/6288)) +- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies From 848f040a74ea56b82f294b9dc500fd7debf16b85 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 13:36:17 +0200 Subject: [PATCH 05/14] fix(replay): Make URL pattern matching stateless and ignore empty strings `_urlMatches` had two latent footguns: - `RegExp.test()` on a `/g` (or `/y`) pattern advances `lastIndex`, so a pattern shared across calls would match intermittently. Reset `lastIndex` before each test. - `''.indexOf('') !== -1` is true, so an empty string in `networkDetailAllowUrls` would silently enable body capture for every request. Treat empty patterns as no-ops. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/js/replay/networkUtils.ts | 11 +++++-- packages/core/test/replay/xhrUtils.test.ts | 33 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 8d4700c4ce..4613de63d0 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -76,14 +76,21 @@ export interface ResolvedNetworkOptions { responseHeaders: string[]; } -/** Check if a URL matches any pattern in the list. Strings use substring match; RegExp uses .test(). */ +/** + * Check if a URL matches any pattern in the list. Strings use substring match + * (empty strings are ignored to avoid matching everything by accident); + * RegExp uses `.test()` with `lastIndex` reset so global-flag patterns are + * stateless across calls. + */ function _urlMatches(url: string, patterns: (string | RegExp)[]): boolean { for (const pattern of patterns) { if (typeof pattern === 'string') { - if (url.indexOf(pattern) !== -1) { + if (pattern.length > 0 && url.indexOf(pattern) !== -1) { return true; } } else if (pattern instanceof RegExp) { + // Reset lastIndex to make /g and /y patterns idempotent across calls. + pattern.lastIndex = 0; if (pattern.test(url)) { return true; } diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 40893e1aeb..19acf01b87 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -137,6 +137,39 @@ describe('xhrUtils', () => { expect(breadcrumb.data?.response).toBeUndefined(); }); + it('ignores empty-string allow patterns instead of matching every URL', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['', 'api.other.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('handles global-flag RegExp patterns idempotently across calls', () => { + const globalPattern = /api\.example\.com/g; + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: [globalPattern], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + for (let i = 0; i < 3; i += 1) { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data?.request).toBeDefined(); + } + }); + it('honours RegExp patterns in allow/deny lists', () => { const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ allowUrls: [/^https:\/\/api\.example\.com\//], From 8cbca621dc3207b7643f8d64c6c8f38af2d496f6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 14:44:13 +0200 Subject: [PATCH 06/14] fix(replay): Strip _meta from network breadcrumb dicts in native converters The JS-internal `_meta.warnings` field (e.g. `MAX_BODY_SIZE_EXCEEDED`) was being forwarded verbatim onto the rrweb span event. Filter it out in the iOS / Android converters so only documented `body`/`headers` keys reach the native SDK. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RNSentryReplayBreadcrumbConverter.java | 25 ++++++++++++++++--- .../ios/RNSentryReplayBreadcrumbConverter.m | 22 +++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index 3e570fab35..c7fb78adee 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -175,11 +175,13 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if (breadcrumb.getData("response_body_size") instanceof Double) { data.put("responseBodySize", breadcrumb.getData("response_body_size")); } - if (breadcrumb.getData("request") instanceof Map) { - data.put("request", breadcrumb.getData("request")); + final Map requestSide = sanitizeNetworkSide(breadcrumb.getData("request")); + if (!requestSide.isEmpty()) { + data.put("request", requestSide); } - if (breadcrumb.getData("response") instanceof Map) { - data.put("response", breadcrumb.getData("response")); + final Map responseSide = sanitizeNetworkSide(breadcrumb.getData("response")); + if (!responseSide.isEmpty()) { + data.put("response", responseSide); } final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent(); @@ -191,6 +193,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc return rrWebSpanEvent; } + /** + * Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` warnings field + * so it does not leak into the native rrweb span event. Returns an empty map when the input is + * not a Map or has no remaining fields. + */ + private @NotNull Map sanitizeNetworkSide(final @Nullable Object raw) { + if (!(raw instanceof Map)) { + return new HashMap<>(); + } + final Map source = (Map) raw; + final Map out = new HashMap<>(source); + out.remove("_meta"); + return out; + } + private void setRRWebEventDefaultsFrom( final @NotNull RRWebBreadcrumbEvent rrWebBreadcrumb, final @NotNull Breadcrumb breadcrumb) { rrWebBreadcrumb.setLevel(breadcrumb.getLevel()); diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index eb7081efb6..d6d04b171d 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -179,11 +179,13 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; } - if ([breadcrumb.data[@"request"] isKindOfClass:[NSDictionary class]]) { - data[@"request"] = breadcrumb.data[@"request"]; + NSDictionary *requestSide = [self sanitizeNetworkSide:breadcrumb.data[@"request"]]; + if (requestSide != nil) { + data[@"request"] = requestSide; } - if ([breadcrumb.data[@"response"] isKindOfClass:[NSDictionary class]]) { - data[@"response"] = breadcrumb.data[@"response"]; + NSDictionary *responseSide = [self sanitizeNetworkSide:breadcrumb.data[@"response"]]; + if (responseSide != nil) { + data[@"response"] = responseSide; } return [SentrySessionReplayHybridSDK @@ -200,6 +202,18 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path data:data]; } +// Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` +// warnings field so it does not leak into the native rrweb span event. +- (NSDictionary *_Nullable)sanitizeNetworkSide:(id _Nullable)raw +{ + if (![raw isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSMutableDictionary *out = [(NSDictionary *)raw mutableCopy]; + [out removeObjectForKey:@"_meta"]; + return out.count > 0 ? [out copy] : nil; +} + @end #endif From 2bf53133238d0bbeefd15f16a0e229d608b42034 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 14:48:28 +0200 Subject: [PATCH 07/14] fix(replay): Default networkCaptureBodies to false Adding a URL to `networkDetailAllowUrls` now captures headers only by default; users opt in explicitly to body capture with `networkCaptureBodies: true`. This avoids surfacing sensitive request / response payloads in the Replay network tab on the strength of a single allow-list entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- packages/core/src/js/replay/mobilereplay.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085586d246..4891d52f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) -- Record XHR request/response bodies and headers in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` (and optional `networkDetailDenyUrls`, `networkCaptureBodies`, `networkRequestHeaders`, `networkResponseHeaders`). Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) +- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index f1f7a1df59..2d465523b6 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -181,12 +181,14 @@ export interface MobileReplayOptions { /** * If request and response bodies should be captured for URLs matched by - * `networkDetailAllowUrls`. When `false`, only headers are captured. + * `networkDetailAllowUrls`. When `false` (the default), only headers are + * captured for allow-listed URLs — opt in explicitly to record bodies, since + * they can contain sensitive payloads. * * Bodies are truncated at ~150 KB; truncated payloads include a * `MAX_BODY_SIZE_EXCEEDED` warning. * - * @default true + * @default false */ networkCaptureBodies?: boolean; @@ -220,7 +222,7 @@ const defaultOptions: MobileReplayOptions = { screenshotStrategy: 'pixelCopy', networkDetailAllowUrls: [], networkDetailDenyUrls: [], - networkCaptureBodies: true, + networkCaptureBodies: false, networkRequestHeaders: [], networkResponseHeaders: [], }; @@ -386,7 +388,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau const networkOptions: ResolvedNetworkOptions = { allowUrls: options.networkDetailAllowUrls ?? [], denyUrls: options.networkDetailDenyUrls ?? [], - captureBodies: options.networkCaptureBodies ?? true, + captureBodies: options.networkCaptureBodies ?? false, requestHeaders: options.networkRequestHeaders ?? [], responseHeaders: options.networkResponseHeaders ?? [], }; From cbc0573752af108e478bb04569f120c34f7bfd56 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 15 Jun 2026 14:51:16 +0200 Subject: [PATCH 08/14] fix(replay): Treat typed arrays as unparseable bodies `Uint8Array` and other `ArrayBuffer` views slipped past the `Blob`/`ArrayBuffer` guard and were JSON-stringified into the captured body as misleading data. Extend the check with `ArrayBuffer.isView` so all binary payloads return `UNPARSEABLE_BODY_TYPE` consistently. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/js/replay/networkUtils.ts | 5 +++-- packages/core/test/replay/xhrUtils.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 4613de63d0..b2b7c0c51e 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -141,8 +141,9 @@ export function getBodyString(body: unknown): NetworkBody | undefined { bodyStr = body.toString(); } else if (body instanceof FormData) { bodyStr = _serializeFormData(body); - } else if (body instanceof Blob || body instanceof ArrayBuffer) { - // Binary payloads can't be safely inlined as text; record the type but skip the body. + } else if (body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + // Binary payloads (Blob, ArrayBuffer, typed arrays like Uint8Array) + // can't be safely inlined as text; record the type but skip the body. return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; } else if (typeof body === 'object') { // Last-ditch attempt: try to JSON-stringify plain objects (e.g. xhr.response with responseType='json') diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 19acf01b87..9eef9e60a4 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -209,7 +209,7 @@ describe('xhrUtils', () => { expect((breadcrumb.data?.response as { body?: string }).body).toBeUndefined(); }); - it('marks Blob and ArrayBuffer request bodies as unparseable instead of stringifying to {}', () => { + it('marks binary request bodies (Blob, ArrayBuffer, typed arrays) as unparseable instead of stringifying to {}', () => { const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ allowUrls: ['api.example.com'], denyUrls: [], @@ -229,6 +229,18 @@ describe('xhrUtils', () => { const bufferRequest = bufferBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; expect(bufferRequest.body).toBeUndefined(); expect(bufferRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + + const typedArrayBreadcrumb: Breadcrumb = { + category: 'xhr', + data: { url: 'https://api.example.com/users' }, + }; + enrich(typedArrayBreadcrumb, { ...getValidXhrHint(), input: new Uint8Array([1, 2, 3]) }); + const typedArrayRequest = typedArrayBreadcrumb.data?.request as { + body?: string; + _meta?: { warnings: string[] }; + }; + expect(typedArrayRequest.body).toBeUndefined(); + expect(typedArrayRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); }); it('truncates bodies that exceed the size cap', () => { From 96bff82951c1f645aa5ca52325b5a5883549deaa Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 10:35:29 +0200 Subject: [PATCH 09/14] fix(replay): Preserve unparseable-body warnings in Replay network sides Two follow-ups from PR review: - Default `captureBodies` to `false` in `DEFAULT_NETWORK_OPTIONS` so the legacy `enrichXhrBreadcrumbsForMobileReplay` path stays consistent with the public `networkCaptureBodies` default in `mobilereplay.ts`. It had no functional effect today (the default `allowUrls: []` short-circuits in `shouldCaptureNetworkDetails`) but was a latent footgun. - The native breadcrumb converters strip the JS-internal `_meta` field before forwarding request/response sides to the native rrweb span, and they drop the whole side when nothing else remains. That meant warnings like `UNPARSEABLE_BODY_TYPE` for binary bodies (Blob, ArrayBuffer, typed arrays) never reached Session Replay. Materialize a placeholder body string from the warnings whenever `_meta` is present but no concrete body was captured, so the signal survives the native strip and surfaces in the Replay UI. --- packages/core/src/js/replay/xhrUtils.ts | 10 +++++++++- packages/core/test/replay/xhrUtils.test.ts | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 567aa08571..593197e6e7 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -22,7 +22,7 @@ interface NetworkBreadcrumbSide { const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = { allowUrls: [], denyUrls: [], - captureBodies: true, + captureBodies: false, requestHeaders: [], responseHeaders: [], }; @@ -138,6 +138,14 @@ function _toBreadcrumbSide( } if (body?._meta) { side._meta = body._meta; + // Native converters strip `_meta` before forwarding the side to the rrweb + // span (the native replay SDKs don't know about it). Materialize a + // placeholder `body` whenever we have a warning but no concrete body so + // the signal (e.g. UNPARSEABLE_BODY_TYPE) still surfaces in Session + // Replay instead of being silently dropped natively. + if (side.body === undefined) { + side.body = `[${body._meta.warnings.join(', ')}]`; + } } return Object.keys(side).length > 0 ? side : undefined; } diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 9eef9e60a4..b22fe45b38 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -209,7 +209,7 @@ describe('xhrUtils', () => { expect((breadcrumb.data?.response as { body?: string }).body).toBeUndefined(); }); - it('marks binary request bodies (Blob, ArrayBuffer, typed arrays) as unparseable instead of stringifying to {}', () => { + it('marks binary request bodies (Blob, ArrayBuffer, typed arrays) as unparseable with a placeholder body so the side survives the native _meta strip', () => { const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ allowUrls: ['api.example.com'], denyUrls: [], @@ -221,13 +221,13 @@ describe('xhrUtils', () => { const blobBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; enrich(blobBreadcrumb, { ...getValidXhrHint(), input: new Blob(['binary']) }); const blobRequest = blobBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; - expect(blobRequest.body).toBeUndefined(); + expect(blobRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); expect(blobRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); const bufferBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; enrich(bufferBreadcrumb, { ...getValidXhrHint(), input: new ArrayBuffer(16) }); const bufferRequest = bufferBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; - expect(bufferRequest.body).toBeUndefined(); + expect(bufferRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); expect(bufferRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); const typedArrayBreadcrumb: Breadcrumb = { @@ -239,7 +239,7 @@ describe('xhrUtils', () => { body?: string; _meta?: { warnings: string[] }; }; - expect(typedArrayRequest.body).toBeUndefined(); + expect(typedArrayRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); expect(typedArrayRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); }); From 0a1e41e8d345659824c6d53f0047f2ece6b97b3c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 11:11:05 +0200 Subject: [PATCH 10/14] test(replay): Cover network breadcrumb _meta stripping in native converters Add basic Android (Kotlin) and iOS (Swift) tests for the recently introduced `sanitizeNetworkSide` logic in `RNSentryReplayBreadcrumbConverter`: - Happy path: `body` and `headers` are forwarded to the native rrweb span and the JS-internal `_meta` field is stripped from both `request` and `response` sides. - Empty-after-strip: a side that contains only `_meta` (or a non-dict value) is omitted from the span entirely. These mirror each other across platforms and only cover the basic scenarios \u2014 just enough to lock down the contract of the native sanitization helper. --- .../RNSentryReplayBreadcrumbConverterTest.kt | 66 +++++++++++++++++++ ...SentryReplayBreadcrumbConverterTests.swift | 66 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index 71b2f315c8..d40ccbcf10 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -4,8 +4,11 @@ import io.sentry.Breadcrumb import io.sentry.SentryLevel import io.sentry.react.RNSentryReplayBreadcrumbConverter import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebSpanEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -247,6 +250,69 @@ class RNSentryReplayBreadcrumbConverterTest { assertEquals("label5(element5, file5) > label4(file4) > label3(element3) > label2", actual) } + @Test + fun convertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + testBreadcrumb.setData("method", "POST") + testBreadcrumb.setData("start_timestamp", 1_000.0) + testBreadcrumb.setData("end_timestamp", 2_000.0) + testBreadcrumb.setData( + "request", + mapOf( + "body" to "{\"hello\":\"world\"}", + "headers" to mapOf("content-type" to "application/json"), + "_meta" to mapOf("warnings" to listOf("MAX_BODY_SIZE_EXCEEDED")), + ), + ) + testBreadcrumb.setData( + "response", + mapOf( + "body" to "[UNPARSEABLE_BODY_TYPE]", + "_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE")), + ), + ) + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + val data = actual.data!! + + @Suppress("UNCHECKED_CAST") + val request = data["request"] as Map + assertEquals("{\"hello\":\"world\"}", request["body"]) + assertEquals(mapOf("content-type" to "application/json"), request["headers"]) + assertNull("_meta must be stripped before forwarding to native rrweb", request["_meta"]) + + @Suppress("UNCHECKED_CAST") + val response = data["response"] as Map + assertEquals("[UNPARSEABLE_BODY_TYPE]", response["body"]) + assertNull(response["_meta"]) + } + + @Test + fun convertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + testBreadcrumb.setData("start_timestamp", 1_000.0) + testBreadcrumb.setData("end_timestamp", 2_000.0) + // Request side contains only `_meta` — once stripped, nothing remains. + testBreadcrumb.setData( + "request", + mapOf("_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE"))), + ) + // Response side is not a map (or missing) — should also be dropped. + testBreadcrumb.setData("response", "not-a-map") + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + val data = actual.data!! + + assertTrue("empty-after-strip request side must be omitted", !data.containsKey("request")) + assertTrue("non-map response side must be omitted", !data.containsKey("response")) + } + private fun assertRRWebBreadcrumbDefaults(actual: RRWebBreadcrumbEvent) { assertEquals("default", actual.breadcrumbType) assertEquals(actual.breadcrumbTimestamp * 1000, actual.timestamp.toDouble(), 0.05) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift index 17906e1d67..3970c17085 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -232,6 +232,72 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { XCTAssertEqual(actual, "label5(element5, file5) > label4(file4) > label3(element3) > label2") } + func testConvertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.category = "xhr" + testBreadcrumb.data = [ + "url": "https://api.example.com/users", + "method": "POST", + "start_timestamp": NSNumber(value: 1_000.0), + "end_timestamp": NSNumber(value: 2_000.0), + "request": [ + "body": "{\"hello\":\"world\"}", + "headers": ["content-type": "application/json"], + "_meta": ["warnings": ["MAX_BODY_SIZE_EXCEEDED"]] + ], + "response": [ + "body": "[UNPARSEABLE_BODY_TYPE]", + "_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]] + ] + ] + + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNotNil(actual) + let event = actual!.serialize() + let eventData = event["data"] as! [String: Any?] + let payload = eventData["payload"] as! [String: Any?] + let data = payload["data"] as! [String: Any?] + + let request = data["request"] as! [String: Any] + XCTAssertEqual("{\"hello\":\"world\"}", request["body"] as! String) + XCTAssertEqual(["content-type": "application/json"], request["headers"] as! [String: String]) + XCTAssertNil(request["_meta"], "_meta must be stripped before forwarding to native rrweb") + + let response = data["response"] as! [String: Any] + XCTAssertEqual("[UNPARSEABLE_BODY_TYPE]", response["body"] as! String) + XCTAssertNil(response["_meta"]) + } + + func testConvertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.category = "xhr" + testBreadcrumb.data = [ + "url": "https://api.example.com/users", + "start_timestamp": NSNumber(value: 1_000.0), + "end_timestamp": NSNumber(value: 2_000.0), + // Request side contains only `_meta` — once stripped, nothing remains. + "request": [ + "_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]] + ], + // Response side is not a dict — should also be dropped. + "response": "not-a-dict" + ] + + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNotNil(actual) + let event = actual!.serialize() + let eventData = event["data"] as! [String: Any?] + let payload = eventData["payload"] as! [String: Any?] + let data = payload["data"] as! [String: Any?] + + XCTAssertNil(data["request"] ?? nil, "empty-after-strip request side must be omitted") + XCTAssertNil(data["response"] ?? nil, "non-dict response side must be omitted") + } + private func assertRRWebBreadcrumbDefaults(actual: [String: Any?]) { let data = actual["data"] as! [String: Any?] let payload = data["payload"] as! [String: Any?] From bf67f722e29122aade15897d58b82637ef0402ec Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 11:25:21 +0200 Subject: [PATCH 11/14] fix(replay,android): Avoid ClassCastException on non-Double timestamps `convertNetworkBreadcrumb` was guarding `start_timestamp` and `end_timestamp` with `instanceof Number` but then casting directly to `Double`. The RN bridge can surface numeric breadcrumb data as `Long` or `Integer` (both pass `instanceof Number`), which would throw a `ClassCastException` at runtime and crash the replay span conversion. Replace the direct cast with `Number.doubleValue()` so any numeric subtype is accepted safely. Added a regression test that exercises `Long` and `Integer` timestamps. --- .../RNSentryReplayBreadcrumbConverterTest.kt | 16 ++++++++++++++++ .../react/RNSentryReplayBreadcrumbConverter.java | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index d40ccbcf10..de7f2fe47f 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -290,6 +290,22 @@ class RNSentryReplayBreadcrumbConverterTest { assertNull(response["_meta"]) } + @Test + fun convertNetworkBreadcrumbAcceptsNonDoubleNumberTimestamps() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + // RN bridge may surface timestamps as Long/Integer rather than Double; + // the converter must not throw ClassCastException. + testBreadcrumb.setData("start_timestamp", 1_000L) + testBreadcrumb.setData("end_timestamp", 2_000) + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + assertEquals(1.0, actual.startTimestamp, 0.001) + assertEquals(2.0, actual.endTimestamp, 0.001) + } + @Test fun convertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() { val converter = RNSentryReplayBreadcrumbConverter() diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index c7fb78adee..d169653883 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -144,13 +144,16 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc @TestOnly public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + // Use Number.doubleValue() rather than a direct (Double) cast: the RN bridge can + // surface timestamps as Long/Integer, which pass `instanceof Number` but would + // throw `ClassCastException` on a direct cast to Double. final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number - ? (Double) breadcrumb.getData("start_timestamp") + ? ((Number) breadcrumb.getData("start_timestamp")).doubleValue() : null; final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number - ? (Double) breadcrumb.getData("end_timestamp") + ? ((Number) breadcrumb.getData("end_timestamp")).doubleValue() : null; final String url = breadcrumb.getData("url") instanceof String ? (String) breadcrumb.getData("url") : null; From 4379dc52af1ad5f1095b624ab654a7e241aa4d35 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 11:25:27 +0200 Subject: [PATCH 12/14] fix(replay): Serialize primitive JSON response bodies instead of dropping them `getBodyString` previously fell through to the `UNPARSEABLE_BODY_TYPE` branch for `number` and `boolean` values, so when an XHR with `responseType: 'json'` received a JSON primitive (e.g. `42` or `true`) the body was dropped with a warning even though it is trivially serializable. Stringify primitives via `String(body)` so they are recorded as-is. --- packages/core/src/js/replay/networkUtils.ts | 3 +++ packages/core/test/replay/xhrUtils.test.ts | 26 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index b2b7c0c51e..469bb4b125 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -137,6 +137,9 @@ export function getBodyString(body: unknown): NetworkBody | undefined { if (typeof body === 'string') { bodyStr = body; + } else if (typeof body === 'number' || typeof body === 'boolean') { + // JSON primitives (e.g. `xhr.response` with responseType='json' returning `42` or `true`) + bodyStr = String(body); } else if (body instanceof URLSearchParams) { bodyStr = body.toString(); } else if (body instanceof FormData) { diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index b22fe45b38..ff577d99d4 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -243,6 +243,32 @@ describe('xhrUtils', () => { expect(typedArrayRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); }); + it('stringifies primitive JSON responses (number, boolean) instead of marking them unparseable', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const numberBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const numberHint = getValidXhrHint(); + numberHint.xhr.response = 42 as unknown as { ok: boolean }; + enrich(numberBreadcrumb, numberHint); + const numberResponse = numberBreadcrumb.data?.response as { body?: string; _meta?: unknown }; + expect(numberResponse.body).toBe('42'); + expect(numberResponse._meta).toBeUndefined(); + + const boolBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const boolHint = getValidXhrHint(); + boolHint.xhr.response = true as unknown as { ok: boolean }; + enrich(boolBreadcrumb, boolHint); + const boolResponse = boolBreadcrumb.data?.response as { body?: string; _meta?: unknown }; + expect(boolResponse.body).toBe('true'); + expect(boolResponse._meta).toBeUndefined(); + }); + it('truncates bodies that exceed the size cap', () => { const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ allowUrls: ['api.example.com'], From 1b3879e28ff77309db32c180bb50e7290dd2a072 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 11:35:50 +0200 Subject: [PATCH 13/14] fix(replay,android): Accept Long/Integer for status_code and body sizes Follow-up to the timestamp fix: the same `instanceof Double` pattern was used for `status_code`, `request_body_size` and `response_body_size` in `convertNetworkBreadcrumb`. Those don't crash (the cast is guarded), but they silently drop the field when the RN bridge surfaces the value as `Long` or `Integer` instead of `Double`. Switch all three to `instanceof Number` + `Number.intValue()`/ `doubleValue()` for consistency with the timestamp handling, and extend the existing regression test to assert on these fields too. --- .../RNSentryReplayBreadcrumbConverterTest.kt | 14 +++++++++++--- .../react/RNSentryReplayBreadcrumbConverter.java | 15 ++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index de7f2fe47f..2e4e52c6bb 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -291,19 +291,27 @@ class RNSentryReplayBreadcrumbConverterTest { } @Test - fun convertNetworkBreadcrumbAcceptsNonDoubleNumberTimestamps() { + fun convertNetworkBreadcrumbAcceptsNonDoubleNumberFields() { val converter = RNSentryReplayBreadcrumbConverter() val testBreadcrumb = Breadcrumb() testBreadcrumb.category = "xhr" testBreadcrumb.setData("url", "https://api.example.com/users") - // RN bridge may surface timestamps as Long/Integer rather than Double; - // the converter must not throw ClassCastException. + // RN bridge may surface numeric breadcrumb data as Long/Integer rather than + // Double; the converter must accept all Number subtypes without crashing or + // silently dropping the field. testBreadcrumb.setData("start_timestamp", 1_000L) testBreadcrumb.setData("end_timestamp", 2_000) + testBreadcrumb.setData("status_code", 201L) + testBreadcrumb.setData("request_body_size", 42) + testBreadcrumb.setData("response_body_size", 100L) val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent assertEquals(1.0, actual.startTimestamp, 0.001) assertEquals(2.0, actual.endTimestamp, 0.001) + val data = actual.data!! + assertEquals(201, data["statusCode"]) + assertEquals(42.0, data["requestBodySize"]) + assertEquals(100.0, data["responseBodySize"]) } @Test diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index d169653883..b92f986f1d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -166,17 +166,18 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if (breadcrumb.getData("method") instanceof String) { data.put("method", breadcrumb.getData("method")); } - if (breadcrumb.getData("status_code") instanceof Double) { - final Double statusCode = (Double) breadcrumb.getData("status_code"); + // Accept any Number subtype (Double/Long/Integer) — the RN bridge does not guarantee Double. + if (breadcrumb.getData("status_code") instanceof Number) { + final int statusCode = ((Number) breadcrumb.getData("status_code")).intValue(); if (statusCode > 0) { - data.put("statusCode", statusCode.intValue()); + data.put("statusCode", statusCode); } } - if (breadcrumb.getData("request_body_size") instanceof Double) { - data.put("requestBodySize", breadcrumb.getData("request_body_size")); + if (breadcrumb.getData("request_body_size") instanceof Number) { + data.put("requestBodySize", ((Number) breadcrumb.getData("request_body_size")).doubleValue()); } - if (breadcrumb.getData("response_body_size") instanceof Double) { - data.put("responseBodySize", breadcrumb.getData("response_body_size")); + if (breadcrumb.getData("response_body_size") instanceof Number) { + data.put("responseBodySize", ((Number) breadcrumb.getData("response_body_size")).doubleValue()); } final Map requestSide = sanitizeNetworkSide(breadcrumb.getData("request")); if (!requestSide.isEmpty()) { From 070eadab9cf5a5b9cc7b008ba322e5fc3f6a63d5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 16 Jun 2026 12:05:24 +0200 Subject: [PATCH 14/14] docs(changelog): Link to Session Replay network details documentation Add a link to the new `Network Details` section in the Sentry React Native Session Replay docs from the changelog entry for #6288 so users can jump straight to the configuration reference. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4891d52f19..e569bc673c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) -- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) +- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies