diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc644c434..394e2b5dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixes +- Forward Session Replay network detail options to the native SDKs so network request and response bodies are displayed ([#6373](https://github.com/getsentry/sentry-react-native/pull/6373)) - The Sentry Babel transformer no longer injects `@sentry/babel-plugin-component-annotate` unless `annotateReactComponents` is explicitly enabled ([#6347](https://github.com/getsentry/sentry-react-native/pull/6347)) ### Dependencies diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt index c60776c942..ddd5db78f4 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -1,6 +1,7 @@ package io.sentry.react import android.app.Activity +import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.common.JavascriptException import io.sentry.Breadcrumb @@ -312,4 +313,95 @@ class RNSentryStartTest { assertFalse("Tombstone should be disabled by default", options.isTombstoneEnabled) } + + @Test + fun `network detail replay options are forwarded to the native replay options`() { + val mobileReplayOptions = + JavaOnlyMap.of( + "networkDetailAllowUrls", + JavaOnlyArray.of("https://api.example.com"), + "networkDetailDenyUrls", + JavaOnlyArray.of("https://api.example.com/auth"), + "networkCaptureBodies", + true, + "networkRequestHeaders", + JavaOnlyArray.of("X-My-Header"), + "networkResponseHeaders", + JavaOnlyArray.of("X-Response-Header"), + ) + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate", + 0.75, + "mobileReplayOptions", + mobileReplayOptions, + ) + val options = SentryAndroidOptions() + + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val replay = options.sessionReplay + assertEquals(listOf("https://api.example.com"), replay.networkDetailAllowUrls) + assertEquals(listOf("https://api.example.com/auth"), replay.networkDetailDenyUrls) + assertTrue(replay.isNetworkCaptureBodies) + assertTrue(replay.networkRequestHeaders.contains("X-My-Header")) + assertTrue(replay.networkResponseHeaders.contains("X-Response-Header")) + } + + @Test + fun `networkCaptureBodies can be disabled via mobileReplayOptions`() { + val mobileReplayOptions = + JavaOnlyMap.of( + "networkDetailAllowUrls", + JavaOnlyArray.of("https://api.example.com"), + "networkCaptureBodies", + false, + ) + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate", + 0.75, + "mobileReplayOptions", + mobileReplayOptions, + ) + val options = SentryAndroidOptions() + + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val replay = options.sessionReplay + assertEquals(listOf("https://api.example.com"), replay.networkDetailAllowUrls) + assertFalse(replay.isNetworkCaptureBodies) + } + + @Test + fun `RegExp-sourced network detail urls forwarded as strings are kept`() { + // The JS layer serializes RegExp patterns to their source string before + // crossing the bridge; the native side stores them as plain strings. + val mobileReplayOptions = + JavaOnlyMap.of( + "networkDetailAllowUrls", + JavaOnlyArray.of("^https://api\\.example\\.com/.*"), + ) + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate", + 0.75, + "mobileReplayOptions", + mobileReplayOptions, + ) + val options = SentryAndroidOptions() + + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + assertEquals( + listOf("^https://api\\.example\\.com/.*"), + options.sessionReplay.networkDetailAllowUrls, + ) + } } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift index c8b43348a9..7d2c99c7b3 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift @@ -1,6 +1,9 @@ import Sentry import XCTest +// File length grows as replay option coverage is added; lint runs with `--strict`. +// swiftlint:disable file_length + final class RNSentryReplayOptions: XCTestCase { func testOptionsWithoutExperimentalAreIgnored() { @@ -47,8 +50,48 @@ final class RNSentryReplayOptions: XCTestCase { assertAllDefaultReplayOptionsAreNotNil(replayOptions: sessionReplay) } + func testNetworkDetailOptionsAreForwardedToReplayOptions() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ + "networkDetailAllowUrls": ["https://api.example.com"], + "networkDetailDenyUrls": ["https://api.example.com/auth"], + "networkCaptureBodies": true, + "networkRequestHeaders": ["X-My-Header"], + "networkResponseHeaders": ["X-Response-Header"] + ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + + XCTAssertEqual(actualOptions.sessionReplay.networkDetailAllowUrls.count, 1) + XCTAssertEqual(actualOptions.sessionReplay.networkDetailDenyUrls.count, 1) + XCTAssertTrue(actualOptions.sessionReplay.networkCaptureBodies) + XCTAssertTrue(actualOptions.sessionReplay.networkRequestHeaders.contains("X-My-Header")) + XCTAssertTrue(actualOptions.sessionReplay.networkResponseHeaders.contains("X-Response-Header")) + } + + func testNetworkCaptureBodiesCanBeDisabled() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ + "networkDetailAllowUrls": ["https://api.example.com"], + "networkCaptureBodies": false + ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + + XCTAssertEqual(actualOptions.sessionReplay.networkDetailAllowUrls.count, 1) + XCTAssertFalse(actualOptions.sessionReplay.networkCaptureBodies) + } + func assertAllDefaultReplayOptionsAreNotNil(replayOptions: [String: Any]) { - XCTAssertEqual(replayOptions.count, 11) + XCTAssertEqual(replayOptions.count, 16) XCTAssertNotNil(replayOptions["sessionSampleRate"]) XCTAssertNotNil(replayOptions["errorSampleRate"]) XCTAssertNotNil(replayOptions["maskAllImages"]) @@ -60,6 +103,11 @@ final class RNSentryReplayOptions: XCTestCase { XCTAssertNotNil(replayOptions["quality"]) XCTAssertNotNil(replayOptions["includedViewClasses"]) XCTAssertNotNil(replayOptions["excludedViewClasses"]) + XCTAssertNotNil(replayOptions["networkDetailAllowUrls"]) + XCTAssertNotNil(replayOptions["networkDetailDenyUrls"]) + XCTAssertNotNil(replayOptions["networkCaptureBodies"]) + XCTAssertNotNil(replayOptions["networkRequestHeaders"]) + XCTAssertNotNil(replayOptions["networkResponseHeaders"]) } func testSessionSampleRate() { diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index 4f792521ac..4a4d9b451a 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -26,6 +26,8 @@ import io.sentry.react.replay.RNSentryReplayUnmask; import java.net.URI; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -446,12 +448,52 @@ private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptio } } + // Forward network detail options so the native SDK emits the rrweb options event + // that tells the frontend to render captured request/response details. Network + // detail capture itself happens in the JS layer for React Native. + if (rnMobileReplayOptions.hasKey("networkDetailAllowUrls")) { + androidReplayOptions.setNetworkDetailAllowUrls( + toStringList(rnMobileReplayOptions.getArray("networkDetailAllowUrls"))); + } + if (rnMobileReplayOptions.hasKey("networkDetailDenyUrls")) { + androidReplayOptions.setNetworkDetailDenyUrls( + toStringList(rnMobileReplayOptions.getArray("networkDetailDenyUrls"))); + } + if (rnMobileReplayOptions.hasKey("networkCaptureBodies")) { + androidReplayOptions.setNetworkCaptureBodies( + rnMobileReplayOptions.getBoolean("networkCaptureBodies")); + } + if (rnMobileReplayOptions.hasKey("networkRequestHeaders")) { + androidReplayOptions.setNetworkRequestHeaders( + toStringList(rnMobileReplayOptions.getArray("networkRequestHeaders"))); + } + if (rnMobileReplayOptions.hasKey("networkResponseHeaders")) { + androidReplayOptions.setNetworkResponseHeaders( + toStringList(rnMobileReplayOptions.getArray("networkResponseHeaders"))); + } + androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); return androidReplayOptions; } + private static List toStringList(@Nullable ReadableArray array) { + final List result = new ArrayList<>(); + if (array == null) { + return result; + } + for (int i = 0; i < array.size(); i++) { + if (array.getType(i) == ReadableType.String) { + final String value = array.getString(i); + if (value != null) { + result.add(value); + } + } + } + return result; + } + private static void setEventOriginTag(SentryEvent event) { // We hardcode native-java as only java events are processed by the Android SDK. SdkVersion sdk = event.getSdk(); diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm index 520aca6b9e..2c3273687e 100644 --- a/packages/core/ios/RNSentryReplay.mm +++ b/packages/core/ios/RNSentryReplay.mm @@ -40,6 +40,14 @@ + (void)updateOptions:(NSMutableDictionary *)options @"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions], @"includedViewClasses" : includedViewClasses ?: [NSNull null], @"excludedViewClasses" : excludedViewClasses ?: [NSNull null], + // Forwarded so the native SDK emits the rrweb options event that tells the + // frontend to render captured request/response details. Network detail + // capture itself happens in the JS layer for React Native. + @"networkDetailAllowUrls" : replayOptions[@"networkDetailAllowUrls"] ?: [NSNull null], + @"networkDetailDenyUrls" : replayOptions[@"networkDetailDenyUrls"] ?: [NSNull null], + @"networkCaptureBodies" : replayOptions[@"networkCaptureBodies"] ?: [NSNull null], + @"networkRequestHeaders" : replayOptions[@"networkRequestHeaders"] ?: [NSNull null], + @"networkResponseHeaders" : replayOptions[@"networkResponseHeaders"] ?: [NSNull null], @"sdkInfo" : @ { @"name" : REACT_NATIVE_SDK_NAME, @"version" : REACT_NATIVE_SDK_PACKAGE_VERSION } } diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 894fab9bf3..4c6169bcf6 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -28,7 +28,7 @@ import { getDevServer } from './integrations/debugsymbolicatorutils'; import { defaultSdkInfo } from './integrations/sdkinfo'; import { getDefaultSidecarUrl } from './integrations/spotlight'; import { defaultNativeLogHandler, setupNativeLogListener } from './NativeLogListener'; -import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; +import { MOBILE_REPLAY_INTEGRATION_NAME, serializeNetworkDetailUrlsForNative } from './replay/mobilereplay'; import { createUserFeedbackEnvelope, items } from './utils/envelope'; import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs'; import { mergeOutcomes } from './utils/outcome'; @@ -232,15 +232,25 @@ export class ReactNativeClient extends Client { this._removeNativeLogListener = setupNativeLogListener(logHandler); } + const mobileReplay = + this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] && + 'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] + ? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType) + : undefined; + NATIVE.initNativeSdk({ ...this._options, defaultSidecarUrl: getDefaultSidecarUrl(), devServerUrl: getDevServer()?.url || '', - mobileReplayOptions: - this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] && - 'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] - ? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType).options - : undefined, + mobileReplayOptions: mobileReplay + ? { + ...mobileReplay.options, + // RegExp patterns can't cross the bridge; forward string sources so the + // native SDK can emit the rrweb options event that drives frontend rendering. + networkDetailAllowUrls: serializeNetworkDetailUrlsForNative(mobileReplay.options.networkDetailAllowUrls), + networkDetailDenyUrls: serializeNetworkDetailUrlsForNative(mobileReplay.options.networkDetailDenyUrls), + } + : undefined, profilingOptions: this._options._experiments?.profilingOptions ?? this._options._experiments?.androidProfilingOptions, }) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 27e048e4de..228f82b8f1 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -166,6 +166,11 @@ export interface MobileReplayOptions { * Currently only XHR requests are supported (this covers `axios` and similar * libraries). Fetch body capture will be added in a follow-up. * + * 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 + * cross the native bridge). The native side uses these forwarded values only to + * signal the Sentry frontend that captured details should be rendered. + * * @default [] */ networkDetailAllowUrls?: (string | RegExp)[]; @@ -249,6 +254,23 @@ type MobileReplayIntegration = Integration & { getReplayId: () => string | null; }; +/** + * Network detail allow/deny lists accept `RegExp` in JS, but the native bridge + * can only serialize strings (a `RegExp` becomes `{}` when crossing the bridge). + * + * Convert `RegExp` entries to their `source` string so the native SDK can + * populate its `SentryReplayOptions`, which is what emits the rrweb options + * event that tells the Sentry frontend to render captured request/response + * details. The JS-side matching in `xhrUtils` keeps using the original + * `RegExp` values, so this normalization only affects native signaling. + */ +export function serializeNetworkDetailUrlsForNative(urls: (string | RegExp)[] | undefined): string[] { + if (!urls) { + return []; + } + return urls.map(url => (typeof url === 'string' ? url : url.source)).filter(url => url.length > 0); +} + /** * The Mobile Replay Integration, let's you adjust the default mobile replay options. * To be passed to `Sentry.init` with `replaysOnErrorSampleRate` or `replaysSessionSampleRate`. diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 5ffb648520..01500390a8 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -2,7 +2,7 @@ import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint } fro import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay'; +import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; @@ -747,3 +747,34 @@ describe('Mobile Replay Integration', () => { }); }); }); + +describe('serializeNetworkDetailUrlsForNative', () => { + it('returns an empty array when urls are undefined', () => { + expect(serializeNetworkDetailUrlsForNative(undefined)).toEqual([]); + }); + + it('passes through string patterns unchanged', () => { + expect(serializeNetworkDetailUrlsForNative(['https://api.example.com', 'cdn.example.com'])).toEqual([ + 'https://api.example.com', + 'cdn.example.com', + ]); + }); + + it('converts RegExp patterns to their source string', () => { + expect(serializeNetworkDetailUrlsForNative([/^https:\/\/api\./, /\/auth\//])).toEqual([ + '^https:\\/\\/api\\.', + '\\/auth\\/', + ]); + }); + + it('handles mixed string and RegExp entries', () => { + expect(serializeNetworkDetailUrlsForNative(['api.example.com', /^https:\/\/cdn\./])).toEqual([ + 'api.example.com', + '^https:\\/\\/cdn\\.', + ]); + }); + + it('drops empty string entries', () => { + expect(serializeNetworkDetailUrlsForNative(['', 'api.example.com'])).toEqual(['api.example.com']); + }); +}); diff --git a/samples/react-native/e2e/tests/captureSpaceflightNewsScreenTransaction/captureSpaceflightNewsScreenTransaction.test.ts b/samples/react-native/e2e/tests/captureSpaceflightNewsScreenTransaction/captureSpaceflightNewsScreenTransaction.test.ts index 90808c1cfd..4d0b3559fb 100644 --- a/samples/react-native/e2e/tests/captureSpaceflightNewsScreenTransaction/captureSpaceflightNewsScreenTransaction.test.ts +++ b/samples/react-native/e2e/tests/captureSpaceflightNewsScreenTransaction/captureSpaceflightNewsScreenTransaction.test.ts @@ -111,7 +111,10 @@ describe('Capture Spaceflight News Screen Transaction', () => { breadcrumbs: expect.arrayContaining([ expect.objectContaining({ category: 'xhr', - data: { + // `objectContaining` so the assertion tolerates the optional + // network detail fields (`request`/`response`) added when the URL + // is allow-listed via `networkDetailAllowUrls`. + data: expect.objectContaining({ end_timestamp: expect.any(Number), method: 'GET', response_body_size: expect.any(Number), @@ -120,7 +123,7 @@ describe('Capture Spaceflight News Screen Transaction', () => { url: expect.stringContaining( 'api.spaceflightnewsapi.net/v4/articles', ), - }, + }), level: 'info', timestamp: expect.any(Number), type: 'http', diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json index 58425c35d5..ea858566a8 100644 --- a/samples/react-native/sentry.options.json +++ b/samples/react-native/sentry.options.json @@ -14,5 +14,8 @@ "profilesSampleRate": 1, "replaysSessionSampleRate": 1, "replaysOnErrorSampleRate": 1, - "spotlight": true + "spotlight": true, + "mobileReplayOptions": { + "networkDetailAllowUrls": ["https://api.spaceflightnewsapi.net"] + } } diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index b2808a4914..82be49ff0c 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -93,6 +93,10 @@ Sentry.init({ maskAllText: true, enableViewRendererV2: true, screenshotStrategy: 'canvas', // if you have strict PII requirements + // Capture request/response headers and bodies in the Replay network tab. + // Exercised by the Spaceflight News screen, which fetches from this host. + networkDetailAllowUrls: ['https://api.spaceflightnewsapi.net'], + networkCaptureBodies: true, }), Sentry.appStartIntegration({ standalone: false, diff --git a/samples/react-native/src/tabs/PlaygroundTab.tsx b/samples/react-native/src/tabs/PlaygroundTab.tsx index fdc1f1ad17..774ec692b7 100644 --- a/samples/react-native/src/tabs/PlaygroundTab.tsx +++ b/samples/react-native/src/tabs/PlaygroundTab.tsx @@ -85,6 +85,26 @@ export default function getPlaygroundTab() { }} /> +