diff --git a/CHANGELOG.md b/CHANGELOG.md index 177f2c5c0..6030ae5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## Unreleased +## 5.23.0-alpha.1 + +### Fixes + +- Pass `replaysSessionSampleRate` option to Android ([#3714](https://github.com/getsentry/sentry-react-native/pull/3714)) + +Access to Mobile Replay is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) ### Features @@ -69,6 +75,47 @@ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8250) - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.24.0...8.25.0) +## 5.23.0-alpha.0 + +### Features + +- Mobile Session Replay Alpha ([#3714](https://github.com/getsentry/sentry-react-native/pull/3714)) + + To enable Replay for React Native on mobile and web add the following options. + + ```js + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + }); + ``` + + To change the default Mobile Replay options add the `mobileReplayIntegration`. + + ```js + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + integration: [ + Sentry.mobileReplayIntegration({ + maskAllText: true, + maskAllImages: true, + }), + ], + }); + ``` + + Access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) + +### Dependencies + +- Bump Cocoa SDK to [8.25.0-alpha.0](https://github.com/getsentry/sentry-cocoa/releases/tag/8.25.0-alpha.0) +- Bump Android SDK to [7.9.0-alpha.1](https://github.com/getsentry/sentry-java/releases/tag/7.9.0-alpha.1) + ## 5.22.0 ### Features diff --git a/RNSentry.podspec b/RNSentry.podspec index b8ed7705b..3560f8ef8 100644 --- a/RNSentry.podspec +++ b/RNSentry.podspec @@ -33,7 +33,7 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' s.dependency 'React-Core' - s.dependency 'Sentry/HybridSDK', '8.25.2' + s.dependency 'Sentry/HybridSDK', '8.25.0-alpha.0' s.source_files = 'ios/**/*.{h,m,mm}' s.public_header_files = 'ios/RNSentry.h' diff --git a/android/build.gradle b/android/build.gradle index 704f34f91..e0b809155 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -54,5 +54,5 @@ android { dependencies { implementation 'com.facebook.react:react-native:+' - api 'io.sentry:sentry-android:7.8.0' + api 'io.sentry:sentry-android:7.9.0-alpha.1' } diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 4c69337e2..7bc43cfbb 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -61,6 +61,7 @@ import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.SentryReplayOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; @@ -79,6 +80,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; @@ -252,7 +254,9 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { if (rnOptions.hasKey("enableNdk")) { options.setEnableNdk(rnOptions.getBoolean("enableNdk")); } - + if (rnOptions.hasKey("_experiments")) { + options.getExperimental().setSessionReplay(getReplayOptions(rnOptions)); + } options.setBeforeSend((event, hint) -> { // React native internally throws a JavascriptException // Since we catch it before that, we don't want to send this one @@ -293,6 +297,37 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { promise.resolve(true); } + private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { + @NotNull final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(); + + @Nullable final ReadableMap rnExperimentsOptions = rnOptions.getMap("_experiments"); + if (rnExperimentsOptions == null) { + return androidReplayOptions; + } + + if (!(rnExperimentsOptions.hasKey("replaysSessionSampleRate") || rnExperimentsOptions.hasKey("replaysOnErrorSampleRate"))) { + return androidReplayOptions; + } + + androidReplayOptions.setSessionSampleRate(rnExperimentsOptions.hasKey("replaysSessionSampleRate") + ? rnExperimentsOptions.getDouble("replaysSessionSampleRate") : null); + androidReplayOptions.setErrorSampleRate(rnExperimentsOptions.hasKey("replaysOnErrorSampleRate") + ? rnExperimentsOptions.getDouble("replaysOnErrorSampleRate") : null); + + if (!rnOptions.hasKey("mobileReplayOptions")) { + return androidReplayOptions; + } + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + if (rnMobileReplayOptions == null) { + return androidReplayOptions; + } + + androidReplayOptions.setRedactAllText(!rnMobileReplayOptions.hasKey("maskAllText") || rnMobileReplayOptions.getBoolean("maskAllText")); + androidReplayOptions.setRedactAllImages(!rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); + + return androidReplayOptions; + } + public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -410,6 +445,24 @@ public void fetchNativeFrames(Promise promise) { } } + public void captureReplay(boolean isHardCrash, Promise promise) { + Sentry.getCurrentHub().getOptions().getReplayController().sendReplay(isHardCrash, null, null); + promise.resolve(getCurrentReplayId()); + } + + public @Nullable String getCurrentReplayId() { + final @Nullable IScope scope = InternalSentrySdk.getCurrentScope(); + if (scope == null) { + return null; + } + + final @NotNull SentryId id = scope.getReplayId(); + if (id == SentryId.EMPTY_ID) { + return null; + } + return id.toString(); + } + public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT); diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 78dfa4fa5..3d585b6b1 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -158,4 +158,14 @@ public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android return null; } + + @Override + public void captureReplay(boolean isHardCrash, Promise promise) { + this.impl.captureReplay(isHardCrash, promise); + } + + @Override + public String getCurrentReplayId() { + return this.impl.getCurrentReplayId(); + } } diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 1a11e8571..33fa7283b 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -158,4 +158,14 @@ public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android return null; } + + @ReactMethod + public void captureReplay(boolean isHardCrash, Promise promise) { + this.impl.captureReplay(isHardCrash, promise); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public String getCurrentReplayId() { + return this.impl.getCurrentReplayId(); + } } diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index 0e122e1e6..79da19826 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -116,7 +116,6 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) // Because we sent it already before the app crashed. if (nil != event.exceptions.firstObject.type && [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location != NSNotFound) { - NSLog(@"Unhandled JS Exception"); return nil; } @@ -135,6 +134,28 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) [mutableOptions removeObjectForKey:@"tracesSampler"]; [mutableOptions removeObjectForKey:@"enableTracing"]; + if ([mutableOptions valueForKey:@"_experiments"] != nil) { + NSDictionary *experiments = mutableOptions[@"_experiments"]; + if (experiments[@"replaysSessionSampleRate"] != nil || experiments[@"replaysOnErrorSampleRate"] != nil) { + [mutableOptions setValue:@{ + @"sessionReplay": @{ + @"sessionSampleRate": experiments[@"replaysSessionSampleRate"] ?: [NSNull null], + @"errorSampleRate": experiments[@"replaysOnErrorSampleRate"] ?: [NSNull null], + @"redactAllImages": mutableOptions[@"mobileReplayOptions"] != nil && + mutableOptions[@"mobileReplayOptions"][@"maskAllImages"] != nil + ? mutableOptions[@"mobileReplayOptions"][@"maskAllImages"] + : [NSNull null], + @"redactAllText": mutableOptions[@"mobileReplayOptions"] != nil && + mutableOptions[@"mobileReplayOptions"][@"maskAllText"] != nil + ? mutableOptions[@"mobileReplayOptions"][@"maskAllText"] + : [NSNull null], + } + } forKey:@"experimental"]; + [self addReplayRNRedactClasses: mutableOptions[@"mobileReplayOptions"]]; + } + [mutableOptions removeObjectForKey:@"_experiments"]; + } + SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions didFailWithError:errorPointer]; if (*errorPointer != nil) { return nil; @@ -644,6 +665,31 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd // the 'tracesSampleRate' or 'tracesSampler' option. } +RCT_EXPORT_METHOD(captureReplay: (BOOL)isHardCrash + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + [PrivateSentrySDKOnly captureReplay]; + resolve([PrivateSentrySDKOnly getReplayId]); +} + +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId) +{ + return [PrivateSentrySDKOnly getReplayId]; +} + +- (void) addReplayRNRedactClasses: (NSDictionary *_Nullable)replayOptions +{ + NSMutableArray *_Nonnull classesToRedact = [[NSMutableArray alloc] init]; + if ([replayOptions[@"maskAllImages"] boolValue] == YES) { + [classesToRedact addObject: NSClassFromString(@"RCTImageView")]; + } + if ([replayOptions[@"maskAllText"] boolValue] == YES) { + [classesToRedact addObject: NSClassFromString(@"RCTTextView")]; + } + [PrivateSentrySDKOnly addReplayRedactClasses: classesToRedact]; +} + static NSString* const enabledProfilingMessage = @"Enable Hermes to use Sentry Profiling."; static SentryId* nativeProfileTraceId = nil; static uint64_t nativeProfileStartTime = 0; diff --git a/package.json b/package.json index a7e0110e8..dd0e9c9a5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@sentry/react-native", "homepage": "https://github.com/getsentry/sentry-react-native", "repository": "https://github.com/getsentry/sentry-react-native", - "version": "5.22.2", + "version": "5.23.0-alpha.1", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", diff --git a/samples/expo/app.json b/samples/expo/app.json index bb06f5af5..47bfff57d 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -4,7 +4,7 @@ "slug": "sentry-react-native-expo-sample", "jsEngine": "hermes", "scheme": "sentry-expo-sample", - "version": "5.22.2", + "version": "5.23.0-alpha.1", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -19,7 +19,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "6" + "buildNumber": "7" }, "android": { "adaptiveIcon": { @@ -27,7 +27,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 6 + "versionCode": 7 }, "web": { "bundler": "metro", diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 6fac65744..810f56797 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -78,6 +78,8 @@ process.env.EXPO_SKIP_DURING_EXPORT !== 'true' && Sentry.init({ // dist: `1`, _experiments: { profilesSampleRate: 0, + // replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 1.0, }, enableSpotlight: true, }); diff --git a/samples/expo/package.json b/samples/expo/package.json index acc50fa53..b73fd22c9 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-expo-sample", - "version": "5.22.2", + "version": "5.23.0-alpha.1", "main": "expo-router/entry", "scripts": { "start": "expo start", diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index cfbc81a4b..43ff16d38 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -134,8 +134,8 @@ android { applicationId "io.sentry.reactnative.sample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 13 - versionName "5.22.2" + versionCode 14 + versionName "5.23.0-alpha.1" } signingConfigs { diff --git a/samples/react-native/ios/sentryreactnativesample/Info.plist b/samples/react-native/ios/sentryreactnativesample/Info.plist index f004243c6..e6d7979cf 100644 --- a/samples/react-native/ios/sentryreactnativesample/Info.plist +++ b/samples/react-native/ios/sentryreactnativesample/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 5.22.2 + 5.23.0 CFBundleSignature ???? CFBundleVersion - 13 + 14 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist index 9d791b395..2ecb12ad0 100644 --- a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist +++ b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 5.22.2 + 5.23.0 CFBundleSignature ???? CFBundleVersion - 13 + 14 diff --git a/samples/react-native/package.json b/samples/react-native/package.json index f9fd46e72..365b469db 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-sample", - "version": "5.22.2", + "version": "5.23.0-alpha.1", "private": true, "scripts": { "postinstall": "patch-package", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index ad7edc5a2..29c9b35e3 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -25,6 +25,7 @@ import GesturesTracingScreen from './Screens/GesturesTracingScreen'; import { Platform, StyleSheet } from 'react-native'; import { HttpClient } from '@sentry/integrations'; import Ionicons from 'react-native-vector-icons/Ionicons'; +import PlaygroundScreen from './Screens/PlaygroundScreen'; const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios'; @@ -79,6 +80,10 @@ Sentry.init({ failedRequestTargets: [/.*/], }), Sentry.metrics.metricsAggregatorIntegration(), + Sentry.mobileReplayIntegration({ + maskAllImages: false, + // maskAllText: false, + }), ); return integrations.filter(i => i.name !== 'Dedupe'); }, @@ -102,6 +107,8 @@ Sentry.init({ // dist: `1`, _experiments: { profilesSampleRate: 1.0, + // replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, }, enableSpotlight: true, }); @@ -203,6 +210,22 @@ function BottomTabs() { ), }} /> + ( + + ), + }} + /> ); diff --git a/samples/react-native/src/Screens/PlaygroundScreen.tsx b/samples/react-native/src/Screens/PlaygroundScreen.tsx new file mode 100644 index 000000000..690b14bbe --- /dev/null +++ b/samples/react-native/src/Screens/PlaygroundScreen.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { + View, + StyleSheet, + Text, + TextInput, + Image, + ImageBackground, + TouchableWithoutFeedback, + KeyboardAvoidingView, + Keyboard, + ScrollView, + SafeAreaView, + Pressable, +} from 'react-native'; + +const multilineText = `This +is +a +multiline +input +text +`; + +const PlaygroundScreen = () => { + return ( + + + + + + Text: + {'This is '} + + TextInput: + + + Image: + + + BackgroundImage: + + + This text should be over the image. + + + Pressable: + { + event.stopPropagation(); + event.preventDefault(); + console.log('Pressable pressed'); + }}> + Press me + + + + + + + ); +}; + +export default PlaygroundScreen; + +const styles = StyleSheet.create({ + space: { + marginBottom: 50, + }, + container: { + padding: 5, + flex: 1, + }, + image: { + width: 200, + height: 200, + }, + backgroundImageContainer: { + width: 200, + height: 200, + }, + textInputStyle: { + height: 200, + borderColor: 'gray', + borderWidth: 1, + }, +}); diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index b76ce2848..0c06d9033 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -44,6 +44,8 @@ export interface Spec extends TurboModule { fetchNativePackageName(): string | undefined | null; fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null; initNativeReactNavigationNewFrameTracking(): Promise; + captureReplay(isHardCrash: boolean): Promise; + getCurrentReplayId(): string | undefined | null; } export type NativeStackFrame = { diff --git a/src/js/client.ts b/src/js/client.ts index 5512081e4..57a967331 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -16,6 +16,8 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils'; import { Alert } from 'react-native'; import { createIntegration } from './integrations/factory'; +import type { mobileReplayIntegration } from './integrations/mobilereplay'; +import { MOBILE_REPLAY_INTEGRATION_NAME } from './integrations/mobilereplay'; import { defaultSdkInfo } from './integrations/sdkinfo'; import type { ReactNativeClientOptions } from './options'; import { ReactNativeTracing } from './tracing'; @@ -44,7 +46,6 @@ export class ReactNativeClient extends BaseClient { super(options); this._outcomesBuffer = []; - this._initNativeSdk(); } /** @@ -111,6 +112,21 @@ export class ReactNativeClient extends BaseClient { */ public setupIntegrations(): void { super.setupIntegrations(); + } + + /** + * @inheritDoc + */ + public init(): void { + super.init(); + this._initNativeSdk(); + } + + /** + * @inheritdoc + */ + protected _setupIntegrations(): void { + super._setupIntegrations(); const tracing = this.getIntegration(ReactNativeTracing); const routingName = tracing?.options.routingInstrumentation?.name; if (routingName) { @@ -160,7 +176,14 @@ export class ReactNativeClient extends BaseClient { * Starts native client with dsn and options */ private _initNativeSdk(): void { - NATIVE.initNativeSdk(this._options) + NATIVE.initNativeSdk({ + ...this._options, + mobileReplayOptions: + this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] && + 'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] + ? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType).options + : undefined, + }) .then( (result: boolean) => { return result; diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 37abe6ba5..e6efd9078 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -1,3 +1,5 @@ +/* eslint-disable complexity */ +import type { BrowserOptions } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { ReactNativeClientOptions } from '../options'; @@ -8,6 +10,7 @@ import { browserApiErrorsIntegration, browserGlobalHandlersIntegration, browserLinkedErrorsIntegration, + browserReplayIntegration, debugSymbolicatorIntegration, dedupeIntegration, deviceContextIntegration, @@ -18,6 +21,7 @@ import { httpClientIntegration, httpContextIntegration, inboundFiltersIntegration, + mobileReplayIntegration, modulesLoaderIntegration, nativeLinkedErrorsIntegration, nativeReleaseIntegration, @@ -112,5 +116,16 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ ); } + if ( + (options._experiments && typeof options._experiments.replaysOnErrorSampleRate === 'number') || + (options._experiments && typeof options._experiments.replaysSessionSampleRate === 'number') + ) { + integrations.push(notWeb() ? mobileReplayIntegration() : browserReplayIntegration()); + if (!notWeb()) { + (options as BrowserOptions).replaysOnErrorSampleRate = options._experiments.replaysOnErrorSampleRate; + (options as BrowserOptions).replaysSessionSampleRate = options._experiments.replaysSessionSampleRate; + } + } + return integrations; } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index b229c3cf5..996f5c5dd 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; +export { mobileReplayIntegration } from './mobilereplay'; export { breadcrumbsIntegration, @@ -24,4 +25,5 @@ export { inboundFiltersIntegration, linkedErrorsIntegration as browserLinkedErrorsIntegration, rewriteFramesIntegration, + replayIntegration as browserReplayIntegration, } from '@sentry/react'; diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 5b9a32f3d..f1331379a 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -14,3 +14,4 @@ export { Screenshot } from './screenshot'; export { ViewHierarchy } from './viewhierarchy'; export { ExpoContext } from './expocontext'; export { Spotlight } from './spotlight'; +export { mobileReplayIntegration } from './mobilereplay'; diff --git a/src/js/integrations/mobilereplay.ts b/src/js/integrations/mobilereplay.ts new file mode 100644 index 000000000..9353ab8ee --- /dev/null +++ b/src/js/integrations/mobilereplay.ts @@ -0,0 +1,129 @@ +import type { Client, DynamicSamplingContext, Event, IntegrationFnResult } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { isHardCrash } from '../misc'; +import { hasHooks } from '../utils/clientutils'; +import { isExpoGo, notMobileOs } from '../utils/environment'; +import { NATIVE } from '../wrapper'; + +export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay'; + +export interface MobileReplayOptions { + /** + * Mask all text in recordings + */ + maskAllText?: boolean; + + /** + * Mask all text in recordings + */ + maskAllImages?: boolean; +} + +const defaultOptions: Required = { + maskAllText: true, + maskAllImages: true, +}; + +type MobileReplayIntegration = IntegrationFnResult & { + options: Required; +}; + +/** + * The Mobile Replay Integration, let's you adjust the default mobile replay options. + * To be passed to `Sentry.init` with `replaysOnErrorSampleRate` or `replaysSessionSampleRate`. + * + * ```javascript + * Sentry.init({ + * _experiments: { + * replaysOnErrorSampleRate: 1.0, + * replaysSessionSampleRate: 1.0, + * }, + * integrations: [mobileReplayIntegration({ + * // Adjust the default options + * })], + * }); + * ``` + * + * @experimental + */ +export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defaultOptions): MobileReplayIntegration => { + if (isExpoGo()) { + logger.warn( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} is not supported in Expo Go. Use EAS Build or \`expo prebuild\` to enable it.`, + ); + } + if (notMobileOs()) { + logger.warn(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} is not supported on this platform.`); + } + + if (isExpoGo() || notMobileOs()) { + return mobileReplayIntegrationNoop(); + } + + const options = { ...defaultOptions, ...initOptions }; + + async function processEvent(event: Event): Promise { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + // Event is not an error, will not capture replay + return event; + } + + const recordingReplayId = NATIVE.getCurrentReplayId(); + if (recordingReplayId) { + logger.debug( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} assign already recording replay ${recordingReplayId} for event ${event.event_id}.`, + ); + return event; + } + + const replayId = await NATIVE.captureReplay(isHardCrash(event)); + if (!replayId) { + logger.debug(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sampled for event ${event.event_id}.`); + return event; + } + + return event; + } + + function setup(client: Client): void { + if (!hasHooks(client)) { + return; + } + + client.on('createDsc', (dsc: DynamicSamplingContext) => { + if (dsc.replay_id) { + return; + } + + // TODO: For better performance, we should emit replayId changes on native, and hold the replayId value in JS + const currentReplayId = NATIVE.getCurrentReplayId(); + if (currentReplayId) { + dsc.replay_id = currentReplayId; + } + }); + } + + // TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably + // https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45 + return { + name: MOBILE_REPLAY_INTEGRATION_NAME, + setupOnce() { + /* Noop */ + }, + setup, + processEvent, + options: options, + }; +}; + +const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { + return { + name: MOBILE_REPLAY_INTEGRATION_NAME, + setupOnce() { + /* Noop */ + }, + options: defaultOptions, + }; +}; diff --git a/src/js/options.ts b/src/js/options.ts index bf44620cd..0c5a4baa4 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -187,6 +187,32 @@ export interface BaseReactNativeOptions { * from the function, no screenshot will be attached. */ beforeScreenshot?: (event: Event, hint: EventHint) => boolean; + + /** + * Options which are in beta, or otherwise not guaranteed to be stable. + */ + _experiments?: { + [key: string]: unknown; + + /** + * The sample rate for profiling + * 1.0 will profile all transactions and 0 will profile none. + */ + 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; + }; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { @@ -201,10 +227,12 @@ export interface ReactNativeTransportOptions extends BrowserTransportOptions { * @see ReactNativeFrontend for more information. */ -export interface ReactNativeOptions extends Options, BaseReactNativeOptions {} +export interface ReactNativeOptions + extends Omit, '_experiments'>, + BaseReactNativeOptions {} export interface ReactNativeClientOptions - extends Omit, 'tunnel'>, + extends Omit, 'tunnel' | '_experiments'>, BaseReactNativeOptions {} export interface ReactNativeWrapperOptions { diff --git a/src/js/utils/clientutils.ts b/src/js/utils/clientutils.ts new file mode 100644 index 000000000..95047fa00 --- /dev/null +++ b/src/js/utils/clientutils.ts @@ -0,0 +1,10 @@ +import type { Client } from '@sentry/types'; + +/** + * Checks if the provided Sentry client has hooks implemented. + * @param client The Sentry client object to check. + * @returns True if the client has hooks, false otherwise. + */ +export function hasHooks(client: Client): client is Client & { on: Required['on'] } { + return client.on !== undefined; +} diff --git a/src/js/utils/environment.ts b/src/js/utils/environment.ts index 9e2c96c13..19b120a56 100644 --- a/src/js/utils/environment.ts +++ b/src/js/utils/environment.ts @@ -58,6 +58,16 @@ export function notWeb(): boolean { return Platform.OS !== 'web'; } +/** Checks if the current platform is supported mobile platform (iOS or Android) */ +export function isMobileOs(): boolean { + return Platform.OS === 'ios' || Platform.OS === 'android'; +} + +/** Checks if the current platform is not supported mobile platform (iOS or Android) */ +export function notMobileOs(): boolean { + return !isMobileOs(); +} + /** Returns Hermes Version if hermes is present in the runtime */ export function getHermesVersion(): string | undefined { return ( diff --git a/src/js/version.ts b/src/js/version.ts index ff9761807..d01f20d30 100644 --- a/src/js/version.ts +++ b/src/js/version.ts @@ -1,3 +1,3 @@ export const SDK_PACKAGE_NAME = 'npm:@sentry/react-native'; export const SDK_NAME = 'sentry.javascript.react-native'; -export const SDK_VERSION = '5.22.2'; +export const SDK_VERSION = '5.23.0-alpha.1'; diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 1d27a9d05..f3950d5fa 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -12,6 +12,7 @@ import type { import { logger, normalize, SentryError } from '@sentry/utils'; import { NativeModules, Platform } from 'react-native'; +import type { MobileReplayOptions } from './integrations/mobilereplay'; import { isHardCrash } from './misc'; import type { NativeAppStartResponse, @@ -47,6 +48,10 @@ export interface Screenshot { filename: string; } +export type NativeSdkOptions = Partial & { + mobileReplayOptions: MobileReplayOptions | undefined; +}; + interface SentryNativeWrapper { enableNative: boolean; nativeIsReady: boolean; @@ -63,7 +68,7 @@ interface SentryNativeWrapper { isNativeAvailable(): boolean; - initNativeSdk(options: Partial): PromiseLike; + initNativeSdk(options: NativeSdkOptions): PromiseLike; closeNativeSdk(): PromiseLike; sendEnvelope(envelope: Envelope): Promise; @@ -104,6 +109,9 @@ interface SentryNativeWrapper { */ fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null; initNativeReactNavigationNewFrameTracking(): Promise; + + captureReplay(isHardCrash: boolean): Promise; + getCurrentReplayId(): string | null; } const EOL = utf8ToBytes('\n'); @@ -193,8 +201,8 @@ export const NATIVE: SentryNativeWrapper = { * Starts native with the provided options. * @param options ReactNativeClientOptions */ - async initNativeSdk(originalOptions: Partial): Promise { - const options: Partial = { + async initNativeSdk(originalOptions: NativeSdkOptions): Promise { + const options: NativeSdkOptions = { enableNative: true, autoInitializeNativeSdk: true, ...originalOptions, @@ -608,6 +616,32 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.initNativeReactNavigationNewFrameTracking(); }, + async captureReplay(isHardCrash: boolean): Promise { + if (!this.enableNative) { + logger.warn(`[NATIVE] \`${this.captureReplay.name}\` is not available when native is disabled.`); + return Promise.resolve(null); + } + if (!this._isModuleLoaded(RNSentry)) { + logger.warn(`[NATIVE] \`${this.captureReplay.name}\` is not available when native is not available.`); + return Promise.resolve(null); + } + + return (await RNSentry.captureReplay(isHardCrash)) || null; + }, + + getCurrentReplayId(): string | null { + if (!this.enableNative) { + logger.warn(`[NATIVE] \`${this.getCurrentReplayId.name}\` is not available when native is disabled.`); + return null; + } + if (!this._isModuleLoaded(RNSentry)) { + logger.warn(`[NATIVE] \`${this.getCurrentReplayId.name}\` is not available when native is not available.`); + return null; + } + + return RNSentry.getCurrentReplayId() || null; + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/client.test.ts b/test/client.test.ts index ffdefc954..2ea3a7971 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -241,7 +241,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('catches errors from onReady callback', () => { @@ -254,7 +254,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('calls onReady callback with false if Native SDK was not initialized', done => { @@ -269,7 +269,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('calls onReady callback with false if Native SDK failed to initialize', done => { @@ -290,7 +290,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); });