Skip to content

Commit a216cb9

Browse files
antonisclaude
andauthored
fix(replay): Forward Session Replay network detail options to native SDKs (#6373)
* fix(replay): Forward Session Replay network detail options to native SDKs Network detail capture for Mobile Session Replay is implemented in the JS layer (xhrUtils enriches breadcrumbs), but the rrweb options event that tells the Sentry frontend to render request/response bodies and headers is emitted by the native SDKs from their SentryReplayOptions. RN never forwarded the network detail config to native, so SentryReplayOptions.networkDetailAllowUrls stayed empty, networkDetailHasUrls was false, and the frontend never rendered the captured details. Forward networkDetailAllowUrls, networkDetailDenyUrls, networkCaptureBodies, networkRequestHeaders and networkResponseHeaders from mobileReplayOptions into the native SentryReplayOptions on both platforms. RegExp allow/deny patterns can't cross the native bridge, so they are forwarded as their string source; JS-side matching in xhrUtils keeps using the original RegExp values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changelog): Add PR number to entry Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(replay): Enable network detail capture in the RN sample Allow-list the Spaceflight News API host and enable networkCaptureBodies so the sample exercises request/response detail rendering in the Replay network tab via the existing News screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update changelog * Adds sample test button * Fix E2E test --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce7b368 commit a216cb9

12 files changed

Lines changed: 295 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
### Fixes
3232

33+
- 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))
3334
- 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))
3435

3536
### Dependencies

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.sentry.react
22

33
import android.app.Activity
4+
import com.facebook.react.bridge.JavaOnlyArray
45
import com.facebook.react.bridge.JavaOnlyMap
56
import com.facebook.react.common.JavascriptException
67
import io.sentry.Breadcrumb
@@ -312,4 +313,95 @@ class RNSentryStartTest {
312313

313314
assertFalse("Tombstone should be disabled by default", options.isTombstoneEnabled)
314315
}
316+
317+
@Test
318+
fun `network detail replay options are forwarded to the native replay options`() {
319+
val mobileReplayOptions =
320+
JavaOnlyMap.of(
321+
"networkDetailAllowUrls",
322+
JavaOnlyArray.of("https://api.example.com"),
323+
"networkDetailDenyUrls",
324+
JavaOnlyArray.of("https://api.example.com/auth"),
325+
"networkCaptureBodies",
326+
true,
327+
"networkRequestHeaders",
328+
JavaOnlyArray.of("X-My-Header"),
329+
"networkResponseHeaders",
330+
JavaOnlyArray.of("X-Response-Header"),
331+
)
332+
val rnOptions =
333+
JavaOnlyMap.of(
334+
"dsn",
335+
"https://abc@def.ingest.sentry.io/1234567",
336+
"replaysOnErrorSampleRate",
337+
0.75,
338+
"mobileReplayOptions",
339+
mobileReplayOptions,
340+
)
341+
val options = SentryAndroidOptions()
342+
343+
RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger)
344+
345+
val replay = options.sessionReplay
346+
assertEquals(listOf("https://api.example.com"), replay.networkDetailAllowUrls)
347+
assertEquals(listOf("https://api.example.com/auth"), replay.networkDetailDenyUrls)
348+
assertTrue(replay.isNetworkCaptureBodies)
349+
assertTrue(replay.networkRequestHeaders.contains("X-My-Header"))
350+
assertTrue(replay.networkResponseHeaders.contains("X-Response-Header"))
351+
}
352+
353+
@Test
354+
fun `networkCaptureBodies can be disabled via mobileReplayOptions`() {
355+
val mobileReplayOptions =
356+
JavaOnlyMap.of(
357+
"networkDetailAllowUrls",
358+
JavaOnlyArray.of("https://api.example.com"),
359+
"networkCaptureBodies",
360+
false,
361+
)
362+
val rnOptions =
363+
JavaOnlyMap.of(
364+
"dsn",
365+
"https://abc@def.ingest.sentry.io/1234567",
366+
"replaysOnErrorSampleRate",
367+
0.75,
368+
"mobileReplayOptions",
369+
mobileReplayOptions,
370+
)
371+
val options = SentryAndroidOptions()
372+
373+
RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger)
374+
375+
val replay = options.sessionReplay
376+
assertEquals(listOf("https://api.example.com"), replay.networkDetailAllowUrls)
377+
assertFalse(replay.isNetworkCaptureBodies)
378+
}
379+
380+
@Test
381+
fun `RegExp-sourced network detail urls forwarded as strings are kept`() {
382+
// The JS layer serializes RegExp patterns to their source string before
383+
// crossing the bridge; the native side stores them as plain strings.
384+
val mobileReplayOptions =
385+
JavaOnlyMap.of(
386+
"networkDetailAllowUrls",
387+
JavaOnlyArray.of("^https://api\\.example\\.com/.*"),
388+
)
389+
val rnOptions =
390+
JavaOnlyMap.of(
391+
"dsn",
392+
"https://abc@def.ingest.sentry.io/1234567",
393+
"replaysOnErrorSampleRate",
394+
0.75,
395+
"mobileReplayOptions",
396+
mobileReplayOptions,
397+
)
398+
val options = SentryAndroidOptions()
399+
400+
RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger)
401+
402+
assertEquals(
403+
listOf("^https://api\\.example\\.com/.*"),
404+
options.sessionReplay.networkDetailAllowUrls,
405+
)
406+
}
315407
}

packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import Sentry
22
import XCTest
33

4+
// File length grows as replay option coverage is added; lint runs with `--strict`.
5+
// swiftlint:disable file_length
6+
47
final class RNSentryReplayOptions: XCTestCase {
58

69
func testOptionsWithoutExperimentalAreIgnored() {
@@ -47,8 +50,48 @@ final class RNSentryReplayOptions: XCTestCase {
4750
assertAllDefaultReplayOptionsAreNotNil(replayOptions: sessionReplay)
4851
}
4952

53+
func testNetworkDetailOptionsAreForwardedToReplayOptions() {
54+
let optionsDict = ([
55+
"dsn": "https://abc@def.ingest.sentry.io/1234567",
56+
"replaysOnErrorSampleRate": 0.75,
57+
"mobileReplayOptions": [
58+
"networkDetailAllowUrls": ["https://api.example.com"],
59+
"networkDetailDenyUrls": ["https://api.example.com/auth"],
60+
"networkCaptureBodies": true,
61+
"networkRequestHeaders": ["X-My-Header"],
62+
"networkResponseHeaders": ["X-Response-Header"]
63+
]
64+
] as NSDictionary).mutableCopy() as! NSMutableDictionary
65+
66+
RNSentryReplay.updateOptions(optionsDict)
67+
let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any])
68+
69+
XCTAssertEqual(actualOptions.sessionReplay.networkDetailAllowUrls.count, 1)
70+
XCTAssertEqual(actualOptions.sessionReplay.networkDetailDenyUrls.count, 1)
71+
XCTAssertTrue(actualOptions.sessionReplay.networkCaptureBodies)
72+
XCTAssertTrue(actualOptions.sessionReplay.networkRequestHeaders.contains("X-My-Header"))
73+
XCTAssertTrue(actualOptions.sessionReplay.networkResponseHeaders.contains("X-Response-Header"))
74+
}
75+
76+
func testNetworkCaptureBodiesCanBeDisabled() {
77+
let optionsDict = ([
78+
"dsn": "https://abc@def.ingest.sentry.io/1234567",
79+
"replaysOnErrorSampleRate": 0.75,
80+
"mobileReplayOptions": [
81+
"networkDetailAllowUrls": ["https://api.example.com"],
82+
"networkCaptureBodies": false
83+
]
84+
] as NSDictionary).mutableCopy() as! NSMutableDictionary
85+
86+
RNSentryReplay.updateOptions(optionsDict)
87+
let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any])
88+
89+
XCTAssertEqual(actualOptions.sessionReplay.networkDetailAllowUrls.count, 1)
90+
XCTAssertFalse(actualOptions.sessionReplay.networkCaptureBodies)
91+
}
92+
5093
func assertAllDefaultReplayOptionsAreNotNil(replayOptions: [String: Any]) {
51-
XCTAssertEqual(replayOptions.count, 11)
94+
XCTAssertEqual(replayOptions.count, 16)
5295
XCTAssertNotNil(replayOptions["sessionSampleRate"])
5396
XCTAssertNotNil(replayOptions["errorSampleRate"])
5497
XCTAssertNotNil(replayOptions["maskAllImages"])
@@ -60,6 +103,11 @@ final class RNSentryReplayOptions: XCTestCase {
60103
XCTAssertNotNil(replayOptions["quality"])
61104
XCTAssertNotNil(replayOptions["includedViewClasses"])
62105
XCTAssertNotNil(replayOptions["excludedViewClasses"])
106+
XCTAssertNotNil(replayOptions["networkDetailAllowUrls"])
107+
XCTAssertNotNil(replayOptions["networkDetailDenyUrls"])
108+
XCTAssertNotNil(replayOptions["networkCaptureBodies"])
109+
XCTAssertNotNil(replayOptions["networkRequestHeaders"])
110+
XCTAssertNotNil(replayOptions["networkResponseHeaders"])
63111
}
64112

65113
func testSessionSampleRate() {

packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import io.sentry.react.replay.RNSentryReplayUnmask;
2727
import java.net.URI;
2828
import java.net.URISyntaxException;
29+
import java.util.ArrayList;
30+
import java.util.List;
2931
import org.jetbrains.annotations.NotNull;
3032
import org.jetbrains.annotations.Nullable;
3133

@@ -446,12 +448,52 @@ private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptio
446448
}
447449
}
448450

451+
// Forward network detail options so the native SDK emits the rrweb options event
452+
// that tells the frontend to render captured request/response details. Network
453+
// detail capture itself happens in the JS layer for React Native.
454+
if (rnMobileReplayOptions.hasKey("networkDetailAllowUrls")) {
455+
androidReplayOptions.setNetworkDetailAllowUrls(
456+
toStringList(rnMobileReplayOptions.getArray("networkDetailAllowUrls")));
457+
}
458+
if (rnMobileReplayOptions.hasKey("networkDetailDenyUrls")) {
459+
androidReplayOptions.setNetworkDetailDenyUrls(
460+
toStringList(rnMobileReplayOptions.getArray("networkDetailDenyUrls")));
461+
}
462+
if (rnMobileReplayOptions.hasKey("networkCaptureBodies")) {
463+
androidReplayOptions.setNetworkCaptureBodies(
464+
rnMobileReplayOptions.getBoolean("networkCaptureBodies"));
465+
}
466+
if (rnMobileReplayOptions.hasKey("networkRequestHeaders")) {
467+
androidReplayOptions.setNetworkRequestHeaders(
468+
toStringList(rnMobileReplayOptions.getArray("networkRequestHeaders")));
469+
}
470+
if (rnMobileReplayOptions.hasKey("networkResponseHeaders")) {
471+
androidReplayOptions.setNetworkResponseHeaders(
472+
toStringList(rnMobileReplayOptions.getArray("networkResponseHeaders")));
473+
}
474+
449475
androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName());
450476
androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName());
451477

452478
return androidReplayOptions;
453479
}
454480

481+
private static List<String> toStringList(@Nullable ReadableArray array) {
482+
final List<String> result = new ArrayList<>();
483+
if (array == null) {
484+
return result;
485+
}
486+
for (int i = 0; i < array.size(); i++) {
487+
if (array.getType(i) == ReadableType.String) {
488+
final String value = array.getString(i);
489+
if (value != null) {
490+
result.add(value);
491+
}
492+
}
493+
}
494+
return result;
495+
}
496+
455497
private static void setEventOriginTag(SentryEvent event) {
456498
// We hardcode native-java as only java events are processed by the Android SDK.
457499
SdkVersion sdk = event.getSdk();

packages/core/ios/RNSentryReplay.mm

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ + (void)updateOptions:(NSMutableDictionary *)options
4040
@"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions],
4141
@"includedViewClasses" : includedViewClasses ?: [NSNull null],
4242
@"excludedViewClasses" : excludedViewClasses ?: [NSNull null],
43+
// Forwarded so the native SDK emits the rrweb options event that tells the
44+
// frontend to render captured request/response details. Network detail
45+
// capture itself happens in the JS layer for React Native.
46+
@"networkDetailAllowUrls" : replayOptions[@"networkDetailAllowUrls"] ?: [NSNull null],
47+
@"networkDetailDenyUrls" : replayOptions[@"networkDetailDenyUrls"] ?: [NSNull null],
48+
@"networkCaptureBodies" : replayOptions[@"networkCaptureBodies"] ?: [NSNull null],
49+
@"networkRequestHeaders" : replayOptions[@"networkRequestHeaders"] ?: [NSNull null],
50+
@"networkResponseHeaders" : replayOptions[@"networkResponseHeaders"] ?: [NSNull null],
4351
@"sdkInfo" :
4452
@ { @"name" : REACT_NATIVE_SDK_NAME, @"version" : REACT_NATIVE_SDK_PACKAGE_VERSION }
4553
}

packages/core/src/js/client.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { getDevServer } from './integrations/debugsymbolicatorutils';
2828
import { defaultSdkInfo } from './integrations/sdkinfo';
2929
import { getDefaultSidecarUrl } from './integrations/spotlight';
3030
import { defaultNativeLogHandler, setupNativeLogListener } from './NativeLogListener';
31-
import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
31+
import { MOBILE_REPLAY_INTEGRATION_NAME, serializeNetworkDetailUrlsForNative } from './replay/mobilereplay';
3232
import { createUserFeedbackEnvelope, items } from './utils/envelope';
3333
import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs';
3434
import { mergeOutcomes } from './utils/outcome';
@@ -232,15 +232,25 @@ export class ReactNativeClient extends Client<ReactNativeClientOptions> {
232232
this._removeNativeLogListener = setupNativeLogListener(logHandler);
233233
}
234234

235+
const mobileReplay =
236+
this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] &&
237+
'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME]
238+
? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType<typeof mobileReplayIntegration>)
239+
: undefined;
240+
235241
NATIVE.initNativeSdk({
236242
...this._options,
237243
defaultSidecarUrl: getDefaultSidecarUrl(),
238244
devServerUrl: getDevServer()?.url || '',
239-
mobileReplayOptions:
240-
this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] &&
241-
'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME]
242-
? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType<typeof mobileReplayIntegration>).options
243-
: undefined,
245+
mobileReplayOptions: mobileReplay
246+
? {
247+
...mobileReplay.options,
248+
// RegExp patterns can't cross the bridge; forward string sources so the
249+
// native SDK can emit the rrweb options event that drives frontend rendering.
250+
networkDetailAllowUrls: serializeNetworkDetailUrlsForNative(mobileReplay.options.networkDetailAllowUrls),
251+
networkDetailDenyUrls: serializeNetworkDetailUrlsForNative(mobileReplay.options.networkDetailDenyUrls),
252+
}
253+
: undefined,
244254
profilingOptions:
245255
this._options._experiments?.profilingOptions ?? this._options._experiments?.androidProfilingOptions,
246256
})

packages/core/src/js/replay/mobilereplay.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ export interface MobileReplayOptions {
166166
* Currently only XHR requests are supported (this covers `axios` and similar
167167
* libraries). Fetch body capture will be added in a follow-up.
168168
*
169+
* Note: `RegExp` patterns are matched in JavaScript for request enrichment, but
170+
* only their string source is forwarded to the native SDKs (a `RegExp` can't
171+
* cross the native bridge). The native side uses these forwarded values only to
172+
* signal the Sentry frontend that captured details should be rendered.
173+
*
169174
* @default []
170175
*/
171176
networkDetailAllowUrls?: (string | RegExp)[];
@@ -249,6 +254,23 @@ type MobileReplayIntegration = Integration & {
249254
getReplayId: () => string | null;
250255
};
251256

257+
/**
258+
* Network detail allow/deny lists accept `RegExp` in JS, but the native bridge
259+
* can only serialize strings (a `RegExp` becomes `{}` when crossing the bridge).
260+
*
261+
* Convert `RegExp` entries to their `source` string so the native SDK can
262+
* populate its `SentryReplayOptions`, which is what emits the rrweb options
263+
* event that tells the Sentry frontend to render captured request/response
264+
* details. The JS-side matching in `xhrUtils` keeps using the original
265+
* `RegExp` values, so this normalization only affects native signaling.
266+
*/
267+
export function serializeNetworkDetailUrlsForNative(urls: (string | RegExp)[] | undefined): string[] {
268+
if (!urls) {
269+
return [];
270+
}
271+
return urls.map(url => (typeof url === 'string' ? url : url.source)).filter(url => url.length > 0);
272+
}
273+
252274
/**
253275
* The Mobile Replay Integration, let's you adjust the default mobile replay options.
254276
* To be passed to `Sentry.init` with `replaysOnErrorSampleRate` or `replaysSessionSampleRate`.

packages/core/test/replay/mobilereplay.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint } fro
22

33
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
44

5-
import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay';
5+
import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay';
66
import * as environment from '../../src/js/utils/environment';
77
import { NATIVE } from '../../src/js/wrapper';
88

@@ -747,3 +747,34 @@ describe('Mobile Replay Integration', () => {
747747
});
748748
});
749749
});
750+
751+
describe('serializeNetworkDetailUrlsForNative', () => {
752+
it('returns an empty array when urls are undefined', () => {
753+
expect(serializeNetworkDetailUrlsForNative(undefined)).toEqual([]);
754+
});
755+
756+
it('passes through string patterns unchanged', () => {
757+
expect(serializeNetworkDetailUrlsForNative(['https://api.example.com', 'cdn.example.com'])).toEqual([
758+
'https://api.example.com',
759+
'cdn.example.com',
760+
]);
761+
});
762+
763+
it('converts RegExp patterns to their source string', () => {
764+
expect(serializeNetworkDetailUrlsForNative([/^https:\/\/api\./, /\/auth\//])).toEqual([
765+
'^https:\\/\\/api\\.',
766+
'\\/auth\\/',
767+
]);
768+
});
769+
770+
it('handles mixed string and RegExp entries', () => {
771+
expect(serializeNetworkDetailUrlsForNative(['api.example.com', /^https:\/\/cdn\./])).toEqual([
772+
'api.example.com',
773+
'^https:\\/\\/cdn\\.',
774+
]);
775+
});
776+
777+
it('drops empty string entries', () => {
778+
expect(serializeNetworkDetailUrlsForNative(['', 'api.example.com'])).toEqual(['api.example.com']);
779+
});
780+
});

0 commit comments

Comments
 (0)