Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.<method>.{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))
Expand Down
64 changes: 60 additions & 4 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
});
Comment thread
alwx marked this conversation as resolved.
return result;
};
}
const originalBeforeSend = clientOptions.beforeSend;
clientOptions.beforeSend = async (event: ErrorEvent, hint: EventHint): Promise<ErrorEvent | null> => {
let result: ErrorEvent | null = event;
Expand Down
159 changes: 159 additions & 0 deletions packages/core/src/js/replay/networkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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<string> {
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;
Expand Down
Loading
Loading