diff --git a/CHANGELOG.md b/CHANGELOG.md index e060f87e11..16fb014cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module...{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`. +- Session Replay network details now capture `fetch` (Blob/ArrayBuffer) response bodies ([#6473](https://github.com/getsentry/sentry-react-native/pull/6473), [#6533](https://github.com/getsentry/sentry-react-native/pull/6533)) + ### Fixes - Fix duplicate navigation transaction on Expo Router `withAnchor` navigations ([#6439](https://github.com/getsentry/sentry-react-native/pull/6439)) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 9064d8304c..d280f88532 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -1,15 +1,31 @@ -import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint, Integration, Metric } from '@sentry/core'; +import type { + Breadcrumb, + BreadcrumbHint, + Client, + DynamicSamplingContext, + ErrorEvent, + Event, + EventHint, + Integration, + Metric, +} from '@sentry/core'; import { debug } from '@sentry/core'; import type { ResolvedNetworkOptions } from './networkUtils'; import { isHardCrash } from '../misc'; +import { deferBreadcrumbNativeSync, syncBreadcrumbToNative } from '../scopeSync'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { registerFeatureMarker } from '../utils/featureMarkers'; import { NATIVE } from '../wrapper'; -import { makeEnrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; +import { + buildResolvedNetworkBreadcrumb, + makeEnrichXhrBreadcrumbsForMobileReplay, + resolveXhrResponseBody, + shouldCaptureResponseBodyAsync, +} from './xhrUtils'; const MOBILE_REPLAY_NETWORK_DETAILS_INTEGRATION_NAME = 'MobileReplayNetworkDetails'; const MOBILE_REPLAY_NETWORK_BODIES_INTEGRATION_NAME = 'MobileReplayNetworkBodies'; @@ -167,8 +183,13 @@ export interface MobileReplayOptions { * Authorization-like headers (`authorization`, `cookie`, `set-cookie`, * `x-api-key`, `x-auth-token`, `proxy-authorization`) are always stripped. * - * Currently only XHR requests are supported (this covers `axios` and similar - * libraries). Fetch body capture will be added in a follow-up. + * Both `XMLHttpRequest` (this covers `axios` and similar libraries) and + * `fetch` are supported — React Native's `fetch` is a polyfill built on + * `XMLHttpRequest`. Response bodies returned as `Blob`/`ArrayBuffer` (which is + * every `fetch` response) are read asynchronously and inlined when the + * `content-type` is text-like (JSON, XML, `text/*`, form data); genuinely + * binary payloads stay marked as unparseable. Request bodies passed as + * `Blob`/`ArrayBuffer` are not inlined yet. * * Note: `RegExp` patterns are matched in JavaScript for request enrichment, but * only their string source is forwarded to the native SDKs (a `RegExp` can't @@ -433,6 +454,41 @@ 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, while breadcrumbs are forwarded to the + // native SDKs synchronously and cannot be updated afterwards. + // + // The breadcrumb is therefore still returned here, so it lands on the + // JavaScript scope immediately and error events captured while the read is in + // flight keep it. Only the sync to native — which is what feeds the Replay + // network tab — is deferred until the body has been read, and then performed + // once with the resolved body. The breadcrumb keeps its original timestamp, + // so the replay places it correctly regardless of the delay. + if (networkOptions.captureBodies && networkOptions.allowUrls.length > 0) { + const originalBeforeBreadcrumb = clientOptions.beforeBreadcrumb; + clientOptions.beforeBreadcrumb = (breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null => { + const result = originalBeforeBreadcrumb ? originalBeforeBreadcrumb(breadcrumb, hint) : breadcrumb; + if (result === null || !shouldCaptureResponseBodyAsync(result, hint, networkOptions)) { + return result; + } + + deferBreadcrumbNativeSync(result); + const xhr = hint.xhr; + resolveXhrResponseBody(xhr) + .then(resolved => { + syncBreadcrumbToNative(buildResolvedNetworkBreadcrumb(result, hint, resolved, networkOptions)); + }) + .then(undefined, (error: unknown) => { + debug.error( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to sync network breadcrumb with resolved response body to native`, + error, + ); + }); + return result; + }; + } 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..ee1047c5f5 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,162 @@ 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, JavaScript, `text/*`). Genuinely binary payloads + * (images, media, octet-stream) are excluded so they stay marked as unparseable. + */ +export function isTextLikeContentType(contentType: string | null | undefined): boolean { + if (!contentType) { + return false; + } + const normalized = contentType.toLowerCase(); + return ( + normalized.startsWith('text/') || + normalized.includes('json') || + normalized.includes('xml') || + normalized.includes('javascript') || + normalized.includes('ecmascript') || + normalized.includes('x-www-form-urlencoded') + ); +} + +/** + * Rejection message used when a blob read completed but the engine could not + * decode the bytes in the requested encoding. Distinguished from other failures + * because it is the one case worth retrying with a different byte range. + */ +export const BLOB_DECODE_FAILED = 'SentryBlobDecodeFailed'; + +/** Whether an error is the decode failure signalled by `readBlobAsText`. */ +export function isBlobDecodeError(error: unknown): boolean { + return error instanceof Error && error.message === BLOB_DECODE_FAILED; +} + +/** The maximum number of bytes a single UTF-8 encoded code point can occupy. */ +export const MAX_UTF8_SEQUENCE_BYTES = 4; + +/** + * Read a Blob as UTF-8 text via FileReader (React Native's Blob has no `text()`). + * Rejects on read error, abort or after `timeoutMs`. + */ +export function readBlobAsText(blob: Blob, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + const timeout = setTimeout(() => { + // Reject before aborting: on engines that implement the full FileReader + // state machine, `abort()` dispatches `onabort` synchronously. On React + // Native it dispatches nothing at all (the reader never enters LOADING, + // see Libraries/Blob/FileReader.js) and does not cancel the native read, + // it only discards the result — so rejecting here is what ends the wait. + reject(new Error(`Timed out reading response body after ${timeoutMs}ms`)); + try { + reader.abort(); + } catch { + // ignore — already rejected + } + }, timeoutMs); + reader.onload = () => { + clearTimeout(timeout); + const result = reader.result; + if (typeof result === 'string') { + resolve(result); + } else { + // iOS decodes via `-[NSString initWithData:encoding:]`, which yields nil + // (surfacing here as a null result) when the bytes are not valid in the + // requested encoding — e.g. a byte slice that cut a multi-byte UTF-8 + // sequence in half. + reject(new Error(BLOB_DECODE_FAILED)); + } + }; + reader.onerror = () => { + 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 and + * JSC expose `TextEncoder` but no `TextDecoder`, so on React Native the manual + * decoder is the path actually taken. Invalid sequences decode to U+FFFD. + */ +export function decodeUtf8(bytes: Uint8Array): string { + const TextDecoderConstructor = (globalThis as { TextDecoder?: new () => TextDecoderLike }).TextDecoder; + 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..81a805024f 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -5,11 +5,18 @@ import { dropUndefinedKeys } from '@sentry/core'; import type { NetworkBody, RequestBody, ResolvedNetworkOptions } from './networkUtils'; import { + decodeUtf8, filterHeaders, getBodySize, getBodyString, + isBlobDecodeError, + isTextLikeContentType, + MAX_UTF8_SEQUENCE_BYTES, + NETWORK_BODY_MAX_SIZE, + NETWORK_BODY_READ_TIMEOUT_MS, parseAllResponseHeaders, parseContentLengthHeader, + readBlobAsText, shouldCaptureNetworkDetails, } from './networkUtils'; @@ -19,6 +26,28 @@ interface NetworkBreadcrumbSide { _meta?: { warnings: string[] }; } +/** + * Hint key carrying the result of `resolveXhrResponseBody` for a breadcrumb + * whose binary (Blob / ArrayBuffer) response body was read asynchronously. When + * present, enrichment reads the body and all request / response metadata from + * this snapshot instead of the live `xhr`, which may have been reused or cleared + * by the time the read settles. + */ +export const REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY = '__mobile_replay_resolved_response_body__'; + +/** + * An asynchronously resolved response body plus the request / response + * metadata snapshotted synchronously at the time the read was started. + */ +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 +92,12 @@ function enrichXhrBreadcrumb( const now = Date.now(); const { startTimestamp = now, endTimestamp = now, input, xhr } = xhrHint; + // An asynchronously resolved snapshot taken while the xhr was still current — + // read from it, never from the (possibly reused) live xhr. + const resolved = (hint as ResolvedBodyCarrier)[REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]; + const reqSize = getBodySize(input); - 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 +105,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 +126,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 +142,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 +218,152 @@ 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 — nothing left to read asynchronously + 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 enriched + * 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 }; +} + +/** + * Build a copy of an already-enriched xhr breadcrumb with its network details + * rebuilt from an asynchronously resolved response body. + * + * The original breadcrumb is left untouched: it is already on the JavaScript + * scope (and possibly attached to events), and the normalized copy the scope + * holds is a different object anyway. The returned breadcrumb is meant to be + * forwarded to the native SDKs, which is what feeds the Replay network tab. + */ +export function buildResolvedNetworkBreadcrumb( + breadcrumb: Breadcrumb, + hint: BreadcrumbHint, + resolved: ResolvedXhrResponse, + networkOptions: ResolvedNetworkOptions, +): Breadcrumb { + const data = { ...breadcrumb.data }; + // Drop the details built from the synchronous pass so they are rebuilt from + // the snapshot rather than merged with it. + delete data.request; + delete data.response; + + const enriched: Breadcrumb = { ...breadcrumb, data }; + enrichXhrBreadcrumb(enriched, { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: resolved }, networkOptions); + return enriched; +} + +async function _readBinaryResponseBody(xhr: XMLHttpRequest): Promise { + try { + if (xhr.responseType === 'blob') { + const blob = xhr.response as Blob; + const truncated = blob.size > NETWORK_BODY_MAX_SIZE; + if (!truncated) { + return _toCappedBody(await readBlobAsText(blob, NETWORK_BODY_READ_TIMEOUT_MS), false); + } + // Slice before reading so a huge payload is never fully read into memory. + return _toCappedBody(await _readCappedBlobAsText(blob), true); + } + 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'] } }; +} + +/** + * Read the first `NETWORK_BODY_MAX_SIZE` bytes of an oversized blob as text. + * + * A byte slice can cut a multi-byte UTF-8 sequence in half, and iOS then fails to + * decode the whole chunk rather than substituting a replacement character. Retry + * with a shorter slice in that case: a UTF-8 sequence spans at most + * `MAX_UTF8_SEQUENCE_BYTES` bytes, so dropping up to that many trailing bytes is + * guaranteed to land on a character boundary. Only decode failures are retried — + * timeouts and read errors propagate immediately. + */ +async function _readCappedBlobAsText(blob: Blob): Promise { + let lastError: unknown; + for (let dropped = 0; dropped < MAX_UTF8_SEQUENCE_BYTES; dropped++) { + try { + return await readBlobAsText(blob.slice(0, NETWORK_BODY_MAX_SIZE - dropped), NETWORK_BODY_READ_TIMEOUT_MS); + } catch (error) { + lastError = error; + if (!isBlobDecodeError(error)) { + throw error; + } + } + } + throw lastError; +} + +function _toCappedBody(text: string, truncated: boolean): NetworkBody { + // The byte cap above already keeps `text` at or below the char cap + // (UTF-8 is at least one byte per char), so only the warning is left to add. + 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/src/js/scopeSync.ts b/packages/core/src/js/scopeSync.ts index 2d3eac35c3..3dd55af434 100644 --- a/packages/core/src/js/scopeSync.ts +++ b/packages/core/src/js/scopeSync.ts @@ -13,6 +13,49 @@ import { NATIVE } from './wrapper'; */ const syncedToNativeMap = new WeakMap(); +/** + * Breadcrumbs whose sync to the native SDKs is owned by whoever marked them, + * rather than by the scope patch below. + */ +const deferredNativeSyncBreadcrumbs = new WeakSet(); + +/** + * Normalize a breadcrumb the same way it is normalized before being added to the + * scope, so the native SDKs always receive the same shape. + */ +function normalizeBreadcrumbForNative(breadcrumb: Breadcrumb): Breadcrumb { + return { + ...breadcrumb, + level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, + data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, + }; +} + +/** + * Mark a breadcrumb so that adding it to a synced scope does **not** forward it + * to the native SDKs. The caller becomes responsible for calling + * `syncBreadcrumbToNative` once its data is complete. + * + * This exists for breadcrumbs that carry data which can only be resolved + * asynchronously (see the Mobile Replay network body capture): the breadcrumb + * still reaches the JavaScript scope synchronously — so error events captured in + * the meantime keep it — while the native SDKs, which cannot update a breadcrumb + * after the fact, receive it once and already enriched. + * + * The mark is consumed by the first synced scope the breadcrumb is added to. + */ +export function deferBreadcrumbNativeSync(breadcrumb: Breadcrumb): void { + deferredNativeSyncBreadcrumbs.add(breadcrumb); +} + +/** + * Forward a breadcrumb to the native SDKs without adding it to a scope. Used to + * complete a `deferBreadcrumbNativeSync` handover. + */ +export function syncBreadcrumbToNative(breadcrumb: Breadcrumb): void { + NATIVE.addBreadcrumb(normalizeBreadcrumbForNative(breadcrumb)); +} + /** * Hooks into the scope set methods and sync new data added to the given scope with the native SDKs. */ @@ -53,14 +96,18 @@ export function enableSyncToNative(scope: Scope): void { }); fillTyped(scope, 'addBreadcrumb', original => (breadcrumb, maxBreadcrumbs): Scope => { - const mergedBreadcrumb: Breadcrumb = { - ...breadcrumb, - level: breadcrumb.level || DEFAULT_BREADCRUMB_LEVEL, - data: breadcrumb.data ? convertToNormalizedObject(breadcrumb.data) : undefined, - }; + // Consume the mark before normalizing — the normalized copy is a different object. + const nativeSyncDeferred = deferredNativeSyncBreadcrumbs.delete(breadcrumb); + const mergedBreadcrumb = normalizeBreadcrumbForNative(breadcrumb); original.call(scope, mergedBreadcrumb, maxBreadcrumbs); + if (nativeSyncDeferred) { + // Whoever deferred the sync forwards this breadcrumb to native itself, + // once the data it is waiting for has resolved. + return scope; + } + const finalBreadcrumb = scope.getLastBreadcrumb(); if (finalBreadcrumb) { NATIVE.addBreadcrumb(finalBreadcrumb); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 80236b5a0d..f7421d29ee 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1,8 +1,18 @@ -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 { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; +import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; +import * as scopeSync from '../../src/js/scopeSync'; import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; @@ -578,6 +588,150 @@ describe('Mobile Replay Integration', () => { }); }); + describe('beforeBreadcrumb wrapping (async binary response bodies)', () => { + let mockDeferBreadcrumbNativeSync: jest.SpiedFunction; + let mockSyncBreadcrumbToNative: jest.SpiedFunction; + 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(() => { + mockDeferBreadcrumbNativeSync = jest.spyOn(scopeSync, 'deferBreadcrumbNativeSync').mockImplementation(() => {}); + mockSyncBreadcrumbToNative = jest.spyOn(scopeSync, 'syncBreadcrumbToNative').mockImplementation(() => {}); + wrapClientOptions = {}; + wrapClient = { + on: jest.fn(), + getOptions: jest.fn(() => wrapClientOptions), + getIntegrationByName: jest.fn().mockReturnValue(undefined), + addIntegration: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('keeps the breadcrumb on the scope and defers only the native sync', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + // Arrange/Act: the breadcrumb must not be dropped — error events captured + // while the body read is in flight need to keep it. + const result = wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); + + expect(result).toBe(breadcrumb); + expect(mockDeferBreadcrumbNativeSync).toHaveBeenCalledWith(breadcrumb); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); + + await flushMicrotasks(); + + expect(mockSyncBreadcrumbToNative).toHaveBeenCalledTimes(1); + }); + + it('syncs a copy carrying the resolved body, leaving the scope breadcrumb untouched', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); + await flushMicrotasks(); + + const synced = mockSyncBreadcrumbToNative.mock.calls[0]?.[0] as Breadcrumb; + expect(synced).not.toBe(breadcrumb); + expect(synced.timestamp).toBe(123); + expect((synced.data?.response as { body?: string }).body).toBe('{"ok":true}'); + expect(synced.data?.response_body_size).toBe(11); + // The scope copy keeps whatever the synchronous enrichment produced. + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('passes through breadcrumbs that do not need an async body read', async () => { + setupIntegration(); + const breadcrumb: Breadcrumb = { category: 'console', message: 'hello' }; + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, {})).toBe(breadcrumb); + + await flushMicrotasks(); + expect(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); + }); + + it('does not defer again for a hint that already carries a resolved body', async () => { + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + const resolvedHint = { ...hint, [REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY]: { body: { body: '{"ok":true}' } } }; + + expect(wrapClientOptions.beforeBreadcrumb?.(breadcrumb, resolvedHint)).toBe(breadcrumb); + + await flushMicrotasks(); + expect(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).not.toHaveBeenCalled(); + }); + + it('runs the user beforeBreadcrumb exactly once', async () => { + const userBeforeBreadcrumb = jest.fn((breadcrumb: Breadcrumb) => breadcrumb); + wrapClientOptions.beforeBreadcrumb = userBeforeBreadcrumb as ( + breadcrumb: Breadcrumb, + hint?: BreadcrumbHint, + ) => Breadcrumb | null; + setupIntegration(); + const { breadcrumb, hint } = getBinaryXhrBreadcrumbAndHint(); + + wrapClientOptions.beforeBreadcrumb?.(breadcrumb, hint); + await flushMicrotasks(); + + 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(mockDeferBreadcrumbNativeSync).not.toHaveBeenCalled(); + expect(mockSyncBreadcrumbToNative).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/networkBodyCapture.test.ts b/packages/core/test/replay/networkBodyCapture.test.ts new file mode 100644 index 0000000000..069767218a --- /dev/null +++ b/packages/core/test/replay/networkBodyCapture.test.ts @@ -0,0 +1,173 @@ +import type { Breadcrumb, BreadcrumbHint } from '@sentry/core'; + +import * as SentryCore from '@sentry/core'; + +import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay'; +import { enableSyncToNative } from '../../src/js/scopeSync'; +import * as environment from '../../src/js/utils/environment'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); + +import { NATIVE } from '../mockWrapper'; + +jest.mock('../../src/js/wrapper'); + +/** + * End-to-end coverage of the async response body capture: real `@sentry/core` + * breadcrumb pipeline, real scope sync patch, mocked native bridge. + * + * This is what guards the object-identity assumption the deferral relies on — + * the breadcrumb our `beforeBreadcrumb` returns must be the very object handed + * to `scope.addBreadcrumb`. + */ +describe('Mobile Replay async network body capture (end to end)', () => { + const flushMicrotasks = (): Promise => new Promise(resolve => setImmediate(resolve)); + + const getBlobXhrHint = (body = '{"ok":true}', contentType = 'application/json'): BreadcrumbHint => + ({ + startTimestamp: 1000, + endTimestamp: 1200, + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, + getResponseHeader: (key: string) => (key === 'content-type' ? contentType : null), + getAllResponseHeaders: () => `content-type: ${contentType}`, + response: new Blob([body]), + responseType: 'blob', + }, + }) as unknown as BreadcrumbHint; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + jest.spyOn(environment, 'notMobileOs').mockReturnValue(false); + installFileReader(); + + const client = new TestClient(getDefaultTestClientOptions()); + SentryCore.setCurrentClient(client); + SentryCore.getIsolationScope().clearBreadcrumbs(); + enableSyncToNative(SentryCore.getIsolationScope()); + + mobileReplayIntegration({ + networkDetailAllowUrls: ['api.example.com'], + }).setup?.(client); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete (globalThis as { FileReader?: unknown }).FileReader; + }); + + it('keeps the breadcrumb on the scope immediately and syncs to native only once the body resolved', async () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + + SentryCore.addBreadcrumb(breadcrumb, getBlobXhrHint()); + + // Synchronously: on the scope (so error events keep it), not yet on native. + const onScope = SentryCore.getIsolationScope().getLastBreadcrumb(); + expect(onScope).toBeDefined(); + expect(onScope?.data?.url).toBe('https://api.example.com/users'); + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + + await flushMicrotasks(); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '{"ok":true}' }); + expect(synced.data?.request).toMatchObject({ headers: { 'content-type': 'application/json' } }); + expect(synced.timestamp).toBe(onScope?.timestamp); + }); + + it('syncs synchronously and does not defer for a text response', async () => { + const textHint = { + startTimestamp: 1000, + endTimestamp: 1200, + xhr: { + __sentry_xhr_v3__: { method: 'GET', url: 'https://api.example.com/users', request_headers: {} }, + getResponseHeader: () => null, + getAllResponseHeaders: () => 'content-type: application/json', + response: '{"ok":true}', + responseText: '{"ok":true}', + responseType: 'text', + }, + } as unknown as BreadcrumbHint; + + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://api.example.com/users' } }, textHint); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('syncs synchronously for a genuinely binary response, keeping the unparseable marker', async () => { + SentryCore.addBreadcrumb( + { category: 'xhr', data: { url: 'https://api.example.com/users' } }, + getBlobXhrHint('\u0000\u0001', 'image/png'), + ); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '[UNPARSEABLE_BODY_TYPE]' }); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('still syncs the breadcrumb to native when the body read fails', async () => { + installFileReader({ fail: true }); + + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://api.example.com/users' } }, getBlobXhrHint()); + + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + + await flushMicrotasks(); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toMatchObject({ body: '[UNPARSEABLE_BODY_TYPE]' }); + }); + + it('does not sync to native for a URL that is not allow-listed', async () => { + SentryCore.addBreadcrumb({ category: 'xhr', data: { url: 'https://other.example.org/users' } }, getBlobXhrHint()); + + // Not allow-listed, so no async read — synced right away, without details. + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + const synced = (NATIVE.addBreadcrumb as jest.Mock).mock.calls[0][0] as Breadcrumb; + expect(synced.data?.response).toBeUndefined(); + + await flushMicrotasks(); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); +}); + +function installFileReader(behavior: { fail?: boolean } = {}): void { + class MockFileReader { + public result: string | null = null; + public error: Error | null = null; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + public onabort: (() => void) | null = null; + + public readAsText(blob: Blob): void { + if (behavior.fail) { + this.error = new Error('read failed'); + queueMicrotask(() => this.onerror?.()); + return; + } + blob.text().then(text => { + this.result = text; + this.onload?.(); + }); + } + + public abort(): void { + this.onabort?.(); + } + } + (globalThis as { FileReader?: unknown }).FileReader = MockFileReader; +} diff --git a/packages/core/test/replay/networkUtils.test.ts b/packages/core/test/replay/networkUtils.test.ts index 9bacfc9ce1..320822d13b 100644 --- a/packages/core/test/replay/networkUtils.test.ts +++ b/packages/core/test/replay/networkUtils.test.ts @@ -1,4 +1,11 @@ -import { getBodySize, parseContentLengthHeader } from '../../src/js/replay/networkUtils'; +import { + decodeUtf8, + getBodySize, + isBlobDecodeError, + isTextLikeContentType, + parseContentLengthHeader, + readBlobAsText, +} from '../../src/js/replay/networkUtils'; describe('networkUtils', () => { describe('parseContentLengthHeader()', () => { @@ -56,4 +63,163 @@ 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], + ['application/javascript', true], + ['text/javascript; charset=utf-8', true], + ['application/ecmascript', 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; + }); + + it('rejects with a decode error when the reader yields a non-string result', async () => { + // iOS resolves with null when the bytes are not valid in the requested + // encoding, e.g. a slice that cut a multi-byte UTF-8 sequence in half. + installMockFileReader({ resolveWithNull: true }); + + await expect(readBlobAsText(new Blob(['x']), 500)).rejects.toSatisfy(isBlobDecodeError); + }); + + it('does not classify other failures as decode errors', async () => { + installMockFileReader({ failWith: new Error('read failed') }); + + await expect(readBlobAsText(new Blob(['x']), 500)).rejects.not.toSatisfy(isBlobDecodeError); + }); + }); }); + +function installMockFileReader( + behavior: { failWith?: Error; neverComplete?: boolean; resolveWithNull?: 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; + } + if (behavior.resolveWithNull) { + this.result = null; + queueMicrotask(() => this.onload?.()); + 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..cef9939b52 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -1,9 +1,15 @@ 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 { + buildResolvedNetworkBreadcrumb, enrichXhrBreadcrumbsForMobileReplay, makeEnrichXhrBreadcrumbsForMobileReplay, + REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY, + resolveXhrResponseBody, + shouldCaptureResponseBodyAsync, } from '../../src/js/replay/xhrUtils'; describe('xhrUtils', () => { @@ -326,9 +332,379 @@ 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('retries a truncated read with a shorter slice when the slice cut a multi-byte character', async () => { + // iOS fails to decode the whole chunk (null result) when the byte slice + // splits a multi-byte UTF-8 sequence, rather than substituting U+FFFD. + const readLengths: number[] = []; + installMockFileReader({ failToDecodeUnlessSliceEndsAt: NETWORK_BODY_MAX_SIZE - 2, readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + expect(readLengths).toEqual([NETWORK_BODY_MAX_SIZE, NETWORK_BODY_MAX_SIZE - 1, NETWORK_BODY_MAX_SIZE - 2]); + expect(resolved.body.body?.length).toBe(NETWORK_BODY_MAX_SIZE - 2); + expect(resolved.body._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('gives up after exhausting the multi-byte boundary retries', async () => { + const readLengths: number[] = []; + installMockFileReader({ failToDecodeUnlessSliceEndsAt: -1, readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + // a UTF-8 sequence is at most 4 bytes, so at most 4 attempts + expect(readLengths).toHaveLength(4); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + }); + + it('does not retry a read that failed for a reason other than decoding', async () => { + const readLengths: number[] = []; + installMockFileReader({ failWith: new Error('boom'), readLengths }); + const { xhr } = getBlobXhrHint({ body: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100) }); + + const resolved = await resolveXhrResponseBody(xhr as unknown as XMLHttpRequest); + + expect(readLengths).toEqual([NETWORK_BODY_MAX_SIZE]); + expect(resolved.body).toEqual({ _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }); + }); + + it('decodes arraybuffer responses including multi-byte characters', async () => { + const body = '{"name":"Пёс 🐕"}'; + const { xhr } = getArrayBufferXhrHint(body); + 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, + }); + }); + }); + + describe('buildResolvedNetworkBreadcrumb', () => { + const getResolved = (body: string) => ({ + body: { body }, + requestHeaders: { 'content-type': 'application/json' }, + rawResponseHeaders: 'content-type: application/json', + responseBodySize: body.length, + }); + + it('returns a copy, leaving the original breadcrumb untouched', () => { + const hint = getBlobXhrHint(); + const original: Breadcrumb = { category: 'xhr', timestamp: 42, data: { url: 'https://api.example.com/users' } }; + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions()); + // the synchronous pass marks the body as unparseable + enrich(original, hint); + const beforeResponse = original.data?.response; + + const resolved = buildResolvedNetworkBreadcrumb( + original, + hint, + getResolved('{"ok":true}'), + getCaptureAllOptions(), + ); + + expect(resolved).not.toBe(original); + expect(resolved.timestamp).toBe(42); + expect((resolved.data?.response as { body?: string }).body).toBe('{"ok":true}'); + expect(original.data?.response).toBe(beforeResponse); + }); + + it('replaces the synchronous unparseable marker instead of merging with it', () => { + const hint = getBlobXhrHint(); + const original: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + makeEnrichXhrBreadcrumbsForMobileReplay(getCaptureAllOptions())(original, hint); + expect((original.data?.response as { body?: string }).body).toBe('[UNPARSEABLE_BODY_TYPE]'); + + const resolved = buildResolvedNetworkBreadcrumb( + original, + hint, + getResolved('{"ok":true}'), + getCaptureAllOptions(), + ); + + const response = resolved.data?.response as { body?: string; _meta?: { warnings: string[] } }; + expect(response.body).toBe('{"ok":true}'); + expect(response._meta).toBeUndefined(); + }); }); }); +function getCaptureAllOptions(): ResolvedNetworkOptions { + 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; + /** Yield a null result (iOS decode failure) unless the slice ends at this byte. */ + failToDecodeUnlessSliceEndsAt?: number; + /** Collects the byte length of every slice handed to the reader. */ + readLengths?: number[]; + } = {}, +): void { + class MockFileReader { + public result: string | null = null; + public error: Error | null = null; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + public onabort: (() => void) | null = null; + + public readAsText(blob: Blob): void { + behavior.readLengths?.push(blob.size); + if (behavior.neverComplete) { + return; + } + if (behavior.failWith) { + this.error = behavior.failWith; + queueMicrotask(() => this.onerror?.()); + return; + } + if ( + behavior.failToDecodeUnlessSliceEndsAt !== undefined && + blob.size !== behavior.failToDecodeUnlessSliceEndsAt + ) { + this.result = null; + queueMicrotask(() => this.onload?.()); + return; + } + blob.text().then( + text => { + this.result = text; + 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 { diff --git a/packages/core/test/scopeSync.test.ts b/packages/core/test/scopeSync.test.ts index a7bd54ac4c..b679daa998 100644 --- a/packages/core/test/scopeSync.test.ts +++ b/packages/core/test/scopeSync.test.ts @@ -3,7 +3,7 @@ import type { Breadcrumb } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; -import { enableSyncToNative } from '../src/js/scopeSync'; +import { deferBreadcrumbNativeSync, enableSyncToNative, syncBreadcrumbToNative } from '../src/js/scopeSync'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); @@ -108,6 +108,56 @@ describe('ScopeSync', () => { ); }); }); + + describe('deferBreadcrumbNativeSync', () => { + it('adds the breadcrumb to the scope but skips the sync to native', () => { + const breadcrumb: Breadcrumb = { message: 'deferred' }; + + deferBreadcrumbNativeSync(breadcrumb); + scope.addBreadcrumb(breadcrumb); + + expect(scope.getLastBreadcrumb()).toEqual(expect.objectContaining({ message: 'deferred' })); + expect(NATIVE.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('consumes the mark, so re-adding the same breadcrumb syncs normally', () => { + const breadcrumb: Breadcrumb = { message: 'deferred' }; + + deferBreadcrumbNativeSync(breadcrumb); + scope.addBreadcrumb(breadcrumb); + scope.addBreadcrumb(breadcrumb); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('does not affect other breadcrumbs', () => { + deferBreadcrumbNativeSync({ message: 'deferred' }); + + scope.addBreadcrumb({ message: 'other' }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledTimes(1); + expect(NATIVE.addBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'other' })); + }); + }); + + describe('syncBreadcrumbToNative', () => { + it('forwards a normalized breadcrumb to native without touching the scope', () => { + syncBreadcrumbToNative({ message: 'test', data: { foo: NaN } }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledExactlyOnceWith({ + message: 'test', + level: 'info', + data: { foo: '[NaN]' }, + }); + expect(scope.getLastBreadcrumb()).toBeUndefined(); + }); + + it('keeps an explicit level', () => { + syncBreadcrumbToNative({ message: 'test', level: 'error' }); + + expect(NATIVE.addBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ level: 'error' })); + }); + }); }); describe('static apis', () => {