Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: Session Replay is GA #4384

Merged
merged 12 commits into from
Jan 3, 2025
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@

### Features

- Mobile Session Replay is now generally available and ready for production use ([#4384](https://github.com/getsentry/sentry-react-native/pull/4384))

To learn about privacy, custom masking or performance overhead visit [the documentation](https://docs.sentry.io/platforms/react-native/session-replay/).

```js
import * as Sentry from '@sentry/react-native';

Sentry.init({
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.mobileReplayIntegration({
maskAllImages: true,
maskAllVectors: true,
maskAllText: true,
}),
],
});
```

- Adds new `captureFeedback` and deprecates the `captureUserFeedback` API ([#4320](https://github.com/getsentry/sentry-react-native/pull/4320))

```jsx
Expand Down Expand Up @@ -47,6 +67,7 @@
### Changes

- Falsy values of `options.environment` (empty string, undefined...) default to `production`
- Deprecated `_experiments.replaysSessionSampleRate` and `_experiments.replaysOnErrorSampleRate` use `replaysSessionSampleRate` and `replaysOnErrorSampleRate` ([#4384](https://github.com/getsentry/sentry-react-native/pull/4384))

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,39 @@ final class RNSentryReplayOptions: XCTestCase {
XCTAssertEqual(optionsDict.count, 0)
}

func testExperimentalOptionsWithoutReplaySampleRatesAreRemoved() {
let optionsDict = (["_experiments": [:]] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

XCTAssertEqual(optionsDict.count, 0)
}

func testReplayOptionsDictContainsAllOptionsKeysWhenSessionSampleRateUsed() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [
"replaysSessionSampleRate": 0.75
]
"replaysSessionSampleRate": 0.75
] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

let experimental = optionsDict["experimental"] as! [String: Any]
let sessionReplay = experimental["sessionReplay"] as! [String: Any]
let sessionReplay = optionsDict["sessionReplay"] as! [String: Any]

assertAllDefaultReplayOptionsAreNotNil(replayOptions: sessionReplay)
}

func testReplayOptionsDictContainsAllOptionsKeysWhenErrorSampleRateUsed() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [
"replaysOnErrorSampleRate": 0.75
]
"replaysOnErrorSampleRate": 0.75
] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

let experimental = optionsDict["experimental"] as! [String: Any]
let sessionReplay = experimental["sessionReplay"] as! [String: Any]
let sessionReplay = optionsDict["sessionReplay"] as! [String: Any]

assertAllDefaultReplayOptionsAreNotNil(replayOptions: sessionReplay)
}

func testReplayOptionsDictContainsAllOptionsKeysWhenErrorAndSessionSampleRatesUsed() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [
"replaysOnErrorSampleRate": 0.75,
"replaysSessionSampleRate": 0.75
]
"replaysOnErrorSampleRate": 0.75,
"replaysSessionSampleRate": 0.75
] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

let experimental = optionsDict["experimental"] as! [String: Any]
let sessionReplay = experimental["sessionReplay"] as! [String: Any]
let sessionReplay = optionsDict["sessionReplay"] as! [String: Any]

assertAllDefaultReplayOptionsAreNotNil(replayOptions: sessionReplay)
}
Expand All @@ -75,38 +59,37 @@ final class RNSentryReplayOptions: XCTestCase {
func testSessionSampleRate() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [ "replaysSessionSampleRate": 0.75 ]
"replaysSessionSampleRate": 0.75
] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])
XCTAssertEqual(actualOptions.experimental.sessionReplay.sessionSampleRate, 0.75)
XCTAssertEqual(actualOptions.sessionReplay.sessionSampleRate, 0.75)
}

func testOnErrorSampleRate() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [ "replaysOnErrorSampleRate": 0.75 ]
"replaysOnErrorSampleRate": 0.75
] as NSDictionary).mutableCopy() as! NSMutableDictionary
RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])
XCTAssertEqual(actualOptions.experimental.sessionReplay.onErrorSampleRate, 0.75)
XCTAssertEqual(actualOptions.sessionReplay.onErrorSampleRate, 0.75)
}

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

RNSentryReplay.updateOptions(optionsDict)

XCTAssertEqual(optionsDict.count, 3)

let experimental = optionsDict["experimental"] as! [String: Any]
let sessionReplay = experimental["sessionReplay"] as! [String: Any]
let sessionReplay = optionsDict["sessionReplay"] as! [String: Any]

let maskedViewClasses = sessionReplay["maskedViewClasses"] as! [String]
XCTAssertTrue(maskedViewClasses.contains("RNSVGSvgView"))
Expand All @@ -115,47 +98,47 @@ final class RNSentryReplayOptions: XCTestCase {
func testMaskAllImages() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [ "replaysOnErrorSampleRate": 0.75 ],
"replaysOnErrorSampleRate": 0.75,
"mobileReplayOptions": [ "maskAllImages": true ]
] as NSDictionary).mutableCopy() as! NSMutableDictionary

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])

XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllImages, true)
assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTImageView")
XCTAssertEqual(actualOptions.sessionReplay.maskAllImages, true)
assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTImageView")
}

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

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])

XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllImages, false)
XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 0)
XCTAssertEqual(actualOptions.sessionReplay.maskAllImages, false)
XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0)
}

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

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])

XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllText, true)
assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTTextView")
assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTParagraphComponentView")
XCTAssertEqual(actualOptions.sessionReplay.maskAllText, true)
assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTTextView")
assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTParagraphComponentView")
}

func assertContainsClass(classArray: [AnyClass], stringClass: String) {
Expand All @@ -169,16 +152,16 @@ final class RNSentryReplayOptions: XCTestCase {
func testMaskAllTextFalse() {
let optionsDict = ([
"dsn": "https://abc@def.ingest.sentry.io/1234567",
"_experiments": [ "replaysOnErrorSampleRate": 0.75 ],
"replaysOnErrorSampleRate": 0.75,
"mobileReplayOptions": [ "maskAllText": false ]
] as NSDictionary).mutableCopy() as! NSMutableDictionary

RNSentryReplay.updateOptions(optionsDict)

let actualOptions = try! Options(dict: optionsDict as! [String: Any])

XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllText, false)
XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 0)
XCTAssertEqual(actualOptions.sessionReplay.maskAllText, false)
XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,10 @@ protected void getSentryAndroidOptions(
options.setSpotlightConnectionUrl(rnOptions.getString("spotlight"));
}
}
if (rnOptions.hasKey("_experiments")) {
options.getExperimental().setSessionReplay(getReplayOptions(rnOptions));

SentryReplayOptions replayOptions = getReplayOptions(rnOptions);
options.setSessionReplay(replayOptions);
if (isReplayEnabled(replayOptions)) {
options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter());
}

Expand Down Expand Up @@ -329,26 +331,26 @@ protected void getSentryAndroidOptions(
}
}

private boolean isReplayEnabled(SentryReplayOptions replayOptions) {
return replayOptions.getSessionSampleRate() != null
|| replayOptions.getOnErrorSampleRate() != null;
}

private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) {
@NotNull final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(false);

@Nullable final ReadableMap rnExperimentsOptions = rnOptions.getMap("_experiments");
if (rnExperimentsOptions == null) {
return androidReplayOptions;
}

if (!(rnExperimentsOptions.hasKey("replaysSessionSampleRate")
|| rnExperimentsOptions.hasKey("replaysOnErrorSampleRate"))) {
if (!(rnOptions.hasKey("replaysSessionSampleRate")
|| rnOptions.hasKey("replaysOnErrorSampleRate"))) {
return androidReplayOptions;
}

androidReplayOptions.setSessionSampleRate(
rnExperimentsOptions.hasKey("replaysSessionSampleRate")
? rnExperimentsOptions.getDouble("replaysSessionSampleRate")
rnOptions.hasKey("replaysSessionSampleRate")
? rnOptions.getDouble("replaysSessionSampleRate")
: null);
androidReplayOptions.setOnErrorSampleRate(
rnExperimentsOptions.hasKey("replaysOnErrorSampleRate")
? rnExperimentsOptions.getDouble("replaysOnErrorSampleRate")
rnOptions.hasKey("replaysOnErrorSampleRate")
? rnOptions.getDouble("replaysOnErrorSampleRate")
: null);

if (!rnOptions.hasKey("mobileReplayOptions")) {
Expand Down
25 changes: 8 additions & 17 deletions packages/core/ios/RNSentryReplay.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,8 @@ @implementation RNSentryReplay {

+ (void)updateOptions:(NSMutableDictionary *)options
{
NSDictionary *experiments = options[@"_experiments"];
[options removeObjectForKey:@"_experiments"];
if (experiments == nil) {
NSLog(@"Session replay disabled via configuration");
return;
}

if (experiments[@"replaysSessionSampleRate"] == nil
&& experiments[@"replaysOnErrorSampleRate"] == nil) {
if (options[@"replaysSessionSampleRate"] == nil
&& options[@"replaysOnErrorSampleRate"] == nil) {
NSLog(@"Session replay disabled via configuration");
return;
}
Expand All @@ -29,15 +22,13 @@ + (void)updateOptions:(NSMutableDictionary *)options
NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{};

[options setValue:@{
@"sessionReplay" : @ {
@"sessionSampleRate" : experiments[@"replaysSessionSampleRate"] ?: [NSNull null],
@"errorSampleRate" : experiments[@"replaysOnErrorSampleRate"] ?: [NSNull null],
@"maskAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null],
@"maskAllText" : replayOptions[@"maskAllText"] ?: [NSNull null],
@"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions],
}
@"sessionSampleRate" : options[@"replaysSessionSampleRate"] ?: [NSNull null],
@"errorSampleRate" : options[@"replaysOnErrorSampleRate"] ?: [NSNull null],
@"maskAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null],
@"maskAllText" : replayOptions[@"maskAllText"] ?: [NSNull null],
@"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions],
}
forKey:@"experimental"];
forKey:@"sessionReplay"];
}

+ (NSArray *_Nonnull)getReplayRNRedactClasses:(NSDictionary *_Nullable)replayOptions
Expand Down
29 changes: 16 additions & 13 deletions packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/* eslint-disable complexity */
import type { BrowserOptions } from '@sentry/react';
import type { Integration } from '@sentry/types';

import type { ReactNativeClientOptions } from '../options';
import { reactNativeTracingIntegration } from '../tracing';
import { isExpoGo, isWeb, notWeb } from '../utils/environment';
import { isExpoGo, notWeb } from '../utils/environment';
import {
appStartIntegration,
breadcrumbsIntegration,
Expand Down Expand Up @@ -127,18 +126,22 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
integrations.push(spotlightIntegration({ sidecarUrl }));
}

if (
const hasReplayOptions =
typeof options.replaysOnErrorSampleRate === 'number' || typeof options.replaysSessionSampleRate === 'number';
const hasExperimentsReplayOptions =
(options._experiments && typeof options._experiments.replaysOnErrorSampleRate === 'number') ||
(options._experiments && typeof options._experiments.replaysSessionSampleRate === 'number')
) {
if (isWeb()) {
// We can't create and add browserReplayIntegration as it overrides the users supplied one
// The browser replay integration works differently than the rest of default integrations
(options as BrowserOptions).replaysOnErrorSampleRate = options._experiments.replaysOnErrorSampleRate;
(options as BrowserOptions).replaysSessionSampleRate = options._experiments.replaysSessionSampleRate;
} else {
integrations.push(mobileReplayIntegration());
}
(options._experiments && typeof options._experiments.replaysSessionSampleRate === 'number');

if (!hasReplayOptions && hasExperimentsReplayOptions) {
// Remove in the next major version (v7)
options.replaysOnErrorSampleRate = options._experiments.replaysOnErrorSampleRate;
options.replaysSessionSampleRate = options._experiments.replaysSessionSampleRate;
}

if ((hasReplayOptions || hasExperimentsReplayOptions) && notWeb()) {
// We can't create and add browserReplayIntegration as it overrides the users supplied one
// The browser replay integration works differently than the rest of default integrations
integrations.push(mobileReplayIntegration());
}

if (__DEV__ && notWeb()) {
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,22 +221,36 @@ export interface BaseReactNativeOptions {
*/
profilesSampleRate?: number;

/**
* The sample rate for session-long replays.
* 1.0 will record all sessions and 0 will record none.
*/
replaysSessionSampleRate?: number;

/**
* The sample rate for sessions that has had an error occur.
* This is independent of `sessionSampleRate`.
* 1.0 will record all sessions and 0 will record none.
*/
replaysOnErrorSampleRate?: number;

/**
* Options which are in beta, or otherwise not guaranteed to be stable.
*/
_experiments?: {
[key: string]: unknown;

/**
* The sample rate for session-long replays.
* 1.0 will record all sessions and 0 will record none.
* @deprecated Use `replaysSessionSampleRate` in the options root instead.
*
* This will be removed in the next major version.
*/
replaysSessionSampleRate?: number;

/**
* The sample rate for sessions that has had an error occur.
* This is independent of `sessionSampleRate`.
* 1.0 will record all sessions and 0 will record none.
* @deprecated Use `replaysOnErrorSampleRate` in the options root instead.
*
* This will be removed in the next major version.
*/
replaysOnErrorSampleRate?: number;
};
Expand Down
Loading
Loading