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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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"])
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> toStringList(@Nullable ReadableArray array) {
final List<String> 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();
Expand Down
8 changes: 8 additions & 0 deletions packages/core/ios/RNSentryReplay.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -232,15 +232,25 @@ export class ReactNativeClient extends Client<ReactNativeClientOptions> {
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<typeof mobileReplayIntegration>)
: 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<typeof mobileReplayIntegration>).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,
})
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[];
Expand Down Expand Up @@ -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`.
Expand Down
33 changes: 32 additions & 1 deletion packages/core/test/replay/mobilereplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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']);
});
});
Loading
Loading