Skip to content
New issue

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

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

Already on GitHub? Sign in 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://[email protected]/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://[email protected]/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://[email protected]/1234567",
"_experiments": [
"replaysOnErrorSampleRate": 0.75,
"replaysSessionSampleRate": 0.75
]
"replaysOnErrorSampleRate": 0.75,
bruno-garcia marked this conversation as resolved.
Show resolved Hide resolved
"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://[email protected]/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://[email protected]/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://[email protected]/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://[email protected]/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://[email protected]/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://[email protected]/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://[email protected]/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