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 @@ -23,6 +23,8 @@
replay?.flush(); // flush the buffered replay to Sentry
```

- Add per-class Session Replay masking via `maskedViewClasses` / `unmaskedViewClasses` on `mobileReplayIntegration` ([#6725](https://github.com/getsentry/sentry-react-native/pull/6725))

### Fixes

- Background root spans (app-start, expo-updates) no longer overwrite the native propagation context of an active navigation trace ([#6720](https://github.com/getsentry/sentry-react-native/pull/6720))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,13 @@ final class RNSentryReplayOptions: XCTestCase {
}

func assertAllDefaultReplayOptionsAreNotNil(replayOptions: [String: Any]) {
XCTAssertEqual(replayOptions.count, 16)
XCTAssertEqual(replayOptions.count, 17)
XCTAssertNotNil(replayOptions["sessionSampleRate"])
XCTAssertNotNil(replayOptions["errorSampleRate"])
XCTAssertNotNil(replayOptions["maskAllImages"])
XCTAssertNotNil(replayOptions["maskAllText"])
XCTAssertNotNil(replayOptions["maskedViewClasses"])
XCTAssertNotNil(replayOptions["unmaskedViewClasses"])
XCTAssertNotNil(replayOptions["sdkInfo"])
XCTAssertNotNil(replayOptions["enableViewRendererV2"])
XCTAssertNotNil(replayOptions["enableFastViewRendering"])
Expand Down Expand Up @@ -221,6 +222,52 @@ final class RNSentryReplayOptions: XCTestCase {
XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0)
}

func testUserMaskedViewClasses() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"replaysOnErrorSampleRate": 0.75,
"mobileReplayOptions": [ "maskedViewClasses": ["UILabel"] ]
] as NSDictionary).mutableCopy() as! NSMutableDictionary

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any])

assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "UILabel")
}

func testUserMaskedViewClassesAppendedOnTopOfMaskAllDefaults() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"replaysOnErrorSampleRate": 0.75,
"mobileReplayOptions": [
"maskAllImages": true,
"maskedViewClasses": ["UILabel"]
]
] as NSDictionary).mutableCopy() as! NSMutableDictionary

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any])

assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTImageView")
assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "UILabel")
}

func testUnmaskedViewClasses() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"replaysOnErrorSampleRate": 0.75,
"mobileReplayOptions": [ "unmaskedViewClasses": ["UIView"] ]
] as NSDictionary).mutableCopy() as! NSMutableDictionary

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any])

assertContainsClass(classArray: actualOptions.sessionReplay.unmaskedViewClasses, stringClass: "UIView")
}

func testEnableViewRendererV2Default() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,9 @@ private static boolean isReplayEnabled(SentryReplayOptions replayOptions) {
|| replayOptions.getOnErrorSampleRate() != null;
}

private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) {
// Package-private for unit testing (see RNSentryReplayOptionsTest), matching the other
// testable static helpers in this class.
static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) {
final SdkVersion replaySdkVersion =
new SdkVersion(
RNSentryVersion.REACT_NATIVE_SDK_NAME,
Expand Down Expand Up @@ -449,6 +451,25 @@ private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptio
androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg
}

if (rnMobileReplayOptions.hasKey("maskedViewClasses")) {
@Nullable
final ReadableArray maskedClasses = rnMobileReplayOptions.getArray("maskedViewClasses");
if (maskedClasses != null) {
for (int i = 0; i < maskedClasses.size(); i++) {
androidReplayOptions.addMaskViewClass(maskedClasses.getString(i));
}
}
}
if (rnMobileReplayOptions.hasKey("unmaskedViewClasses")) {
@Nullable
final ReadableArray unmaskedClasses = rnMobileReplayOptions.getArray("unmaskedViewClasses");
if (unmaskedClasses != null) {
for (int i = 0; i < unmaskedClasses.size(); i++) {
androidReplayOptions.addUnmaskViewClass(unmaskedClasses.getString(i));
}
}
}

if (rnMobileReplayOptions.hasKey("captureSurfaceViews")) {
androidReplayOptions.setCaptureSurfaceViews(
rnMobileReplayOptions.getBoolean("captureSurfaceViews"));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.sentry.react;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import io.sentry.SentryReplayOptions;
import org.junit.Test;

/**
* Coverage for the per-class Session Replay masking wiring in {@link
* RNSentryStart#getReplayOptions} ({@code maskedViewClasses} / {@code unmaskedViewClasses}
* forwarded from {@code mobileReplayOptions} into {@link SentryReplayOptions#addMaskViewClass} /
* {@link SentryReplayOptions#addUnmaskViewClass}).
*/
public class RNSentryReplayOptionsTest {

private static final String MASKED_CLASS = "com.example.MaskedView";
private static final String UNMASKED_CLASS = "com.example.UnmaskedView";

private static ReadableArray singletonArray(String value) {
final ReadableArray array = mock(ReadableArray.class);
when(array.size()).thenReturn(1);
when(array.getString(0)).thenReturn(value);
return array;
}

private static ReadableMap rnOptionsWith(ReadableMap mobileReplayOptions) {
final ReadableMap rnOptions = mock(ReadableMap.class);
when(rnOptions.hasKey("replaysOnErrorSampleRate")).thenReturn(true);
when(rnOptions.getDouble("replaysOnErrorSampleRate")).thenReturn(0.75);
when(rnOptions.hasKey("mobileReplayOptions")).thenReturn(true);
when(rnOptions.getMap("mobileReplayOptions")).thenReturn(mobileReplayOptions);
return rnOptions;
}

@Test
public void addsUserMaskedViewClasses() {
final ReadableArray maskedClasses = singletonArray(MASKED_CLASS);
final ReadableMap mobileReplayOptions = mock(ReadableMap.class);
when(mobileReplayOptions.hasKey("maskedViewClasses")).thenReturn(true);
when(mobileReplayOptions.getArray("maskedViewClasses")).thenReturn(maskedClasses);

final SentryReplayOptions replayOptions =
RNSentryStart.getReplayOptions(rnOptionsWith(mobileReplayOptions));

assertTrue(replayOptions.getMaskViewClasses().contains(MASKED_CLASS));
}

@Test
public void addsUserUnmaskedViewClasses() {
final ReadableArray unmaskedClasses = singletonArray(UNMASKED_CLASS);
final ReadableMap mobileReplayOptions = mock(ReadableMap.class);
when(mobileReplayOptions.hasKey("unmaskedViewClasses")).thenReturn(true);
when(mobileReplayOptions.getArray("unmaskedViewClasses")).thenReturn(unmaskedClasses);

final SentryReplayOptions replayOptions =
RNSentryStart.getReplayOptions(rnOptionsWith(mobileReplayOptions));

assertTrue(replayOptions.getUnmaskViewClasses().contains(UNMASKED_CLASS));
}

@Test
public void doesNotAddUserClassesWhenNotProvided() {
final ReadableMap mobileReplayOptions = mock(ReadableMap.class);

final SentryReplayOptions replayOptions =
RNSentryStart.getReplayOptions(rnOptionsWith(mobileReplayOptions));

assertFalse(replayOptions.getMaskViewClasses().contains(MASKED_CLASS));
assertFalse(replayOptions.getUnmaskViewClasses().contains(UNMASKED_CLASS));
}
}
13 changes: 13 additions & 0 deletions packages/core/ios/RNSentryReplay.mm
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ + (void)updateOptions:(NSMutableDictionary *)options
@"enableViewRendererV2" : replayOptions[@"enableViewRendererV2"] ?: [NSNull null],
@"enableFastViewRendering" : replayOptions[@"enableFastViewRendering"] ?: [NSNull null],
@"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions],
@"unmaskedViewClasses" : replayOptions[@"unmaskedViewClasses"] ?: [NSNull null],
@"includedViewClasses" : includedViewClasses ?: [NSNull null],
@"excludedViewClasses" : excludedViewClasses ?: [NSNull null],
// Forwarded so the native SDK emits the rrweb options event that tells the
Expand Down Expand Up @@ -73,6 +74,18 @@ + (NSArray *_Nonnull)getReplayRNRedactClasses:(NSDictionary *_Nullable)replayOpt
[classesToRedact addObject:@"RCTParagraphComponentView"];
}

// Append user-supplied classes so per-class masking applies on top of the
// maskAll* defaults. Class names are resolved via NSClassFromString by the
// native SDK's SentryReplayOptions(dictionary:) initializer.
id userMaskedViewClasses = replayOptions[@"maskedViewClasses"];
if ([userMaskedViewClasses isKindOfClass:[NSArray class]]) {
for (id className in userMaskedViewClasses) {
if ([className isKindOfClass:[NSString class]]) {
[classesToRedact addObject:className];
}
}
}

return classesToRedact;
}

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,30 @@ export interface MobileReplayOptions {
*/
excludedViewClasses?: string[];

/**
* Array of native view class names to mask during Session Replay.
*
* Views that are instances of these classes (or subclasses) are redacted, in addition to the
* classes implied by `maskAllText`/`maskAllImages`/`maskAllVectors`. Class names are the native
* class names (e.g. `'RCTTextView'` on iOS, `'android.widget.TextView'` on Android), not React
* component names.
*
* @default undefined
*/
maskedViewClasses?: string[];

/**
* Array of native view class names to unmask during Session Replay.
*
* Views that are instances of these classes (or subclasses) are not redacted, taking precedence
* over the classes implied by `maskAllText`/`maskAllImages`/`maskAllVectors`. Class names are the
* native class names (e.g. `'RCTTextView'` on iOS, `'android.widget.TextView'` on Android), not
* React component names.
*
* @default undefined
*/
unmaskedViewClasses?: string[];

/**
* Sets the screenshot strategy used by the Session Replay integration on Android.
*
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/replay/mobilereplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,28 @@ describe('Mobile Replay Integration', () => {
});
});

describe('per-class masking options', () => {
// `client._initNativeSdk` spreads `integration.options` into the native
// `mobileReplayOptions` payload, so preserving these arrays on `options` is
// what carries them across the bridge to the native per-class masking APIs.
it('carries maskedViewClasses and unmaskedViewClasses onto integration options', () => {
const integration = mobileReplayIntegration({
maskedViewClasses: ['RCTTextView', 'android.widget.TextView'],
unmaskedViewClasses: ['RCTImageView'],
});

expect(integration.options.maskedViewClasses).toEqual(['RCTTextView', 'android.widget.TextView']);
expect(integration.options.unmaskedViewClasses).toEqual(['RCTImageView']);
});

it('leaves both class lists undefined when not provided', () => {
const integration = mobileReplayIntegration();

expect(integration.options.maskedViewClasses).toBeUndefined();
expect(integration.options.unmaskedViewClasses).toBeUndefined();
});
});

describe('beforeBreadcrumb wrapping (async binary response bodies)', () => {
let mockDeferBreadcrumbNativeSync: jest.SpiedFunction<typeof scopeSync.deferBreadcrumbNativeSync>;
let mockSyncBreadcrumbToNative: jest.SpiedFunction<typeof scopeSync.syncBreadcrumbToNative>;
Expand Down
Loading