diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e9795306..8533b562cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Improve touch event component info if annotated with [`@sentry/babel-plugin-component-annotate`](https://www.npmjs.com/package/@sentry/babel-plugin-component-annotate) ([#3899](https://github.com/getsentry/sentry-react-native/pull/3899)) +- Add replay breadcrumbs for touch & navigation events ([#3846](https://github.com/getsentry/sentry-react-native/pull/3846)) ### Dependencies diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 179e6dd9d5..d32b0aab17 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -188,7 +188,7 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); options.setNativeSdkName(NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); + options.setSdkVersion(sdkVersion); if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { options.setDebug(true); @@ -256,6 +256,7 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { } if (rnOptions.hasKey("_experiments")) { options.getExperimental().setSessionReplay(getReplayOptions(rnOptions)); + options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); } options.setBeforeSend((event, hint) -> { // React native internally throws a JavascriptException diff --git a/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java new file mode 100644 index 0000000000..d2989343a7 --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -0,0 +1,60 @@ +package io.sentry.react; + +import io.sentry.Breadcrumb; +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; +import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebBreadcrumbEvent; +import java.util.ArrayList; +import java.util.HashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadcrumbConverter { + public RNSentryReplayBreadcrumbConverter() { + } + + @Override + public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { + RRWebBreadcrumbEvent rrwebBreadcrumb = new RRWebBreadcrumbEvent(); + assert rrwebBreadcrumb.getCategory() == null; + + if (breadcrumb.getCategory().equals("touch")) { + rrwebBreadcrumb.setCategory("ui.tap"); + ArrayList path = (ArrayList) breadcrumb.getData("path"); + if (path != null) { + StringBuilder message = new StringBuilder(); + for (int i = Math.min(3, path.size()); i >= 0; i--) { + HashMap item = (HashMap) path.get(i); + message.append(item.get("name")); + if (item.containsKey("element") || item.containsKey("file")) { + message.append('('); + if (item.containsKey("element")) { + message.append(item.get("element")); + if (item.containsKey("file")) { + message.append(", "); + message.append(item.get("file")); + } + } else if (item.containsKey("file")) { + message.append(item.get("file")); + } + message.append(')'); + } + if (i > 0) { + message.append(" > "); + } + } + rrwebBreadcrumb.setMessage(message.toString()); + } + rrwebBreadcrumb.setData(breadcrumb.getData()); + } + + if (rrwebBreadcrumb.getCategory() != null && !rrwebBreadcrumb.getCategory().isEmpty()) { + rrwebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime()); + rrwebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0); + rrwebBreadcrumb.setBreadcrumbType("default"); + return rrwebBreadcrumb; + } + + return super.convert(breadcrumb); + } +} diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index f6dbcbba74..9d391ee2b8 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -38,6 +38,10 @@ #import "RNSentryEvents.h" #import "RNSentryDependencyContainer.h" +#if SENTRY_TARGET_REPLAY_SUPPORTED +#import "RNSentrySessionReplay.h" +#endif + #if SENTRY_HAS_UIKIT #import "RNSentryRNSScreen.h" #import "RNSentryFramesTrackerListener.h" @@ -106,6 +110,10 @@ + (BOOL)requiresMainQueueSetup { sentHybridSdkDidBecomeActive = true; } +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentrySessionReplay postInit]; +#endif + resolve(@YES); } @@ -135,27 +143,9 @@ - (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"]; - } +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentrySessionReplay updateOptions:mutableOptions]; +#endif SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions didFailWithError:errorPointer]; if (*errorPointer != nil) { @@ -635,25 +625,21 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { +#if SENTRY_TARGET_REPLAY_SUPPORTED [PrivateSentrySDKOnly captureReplay]; resolve([PrivateSentrySDKOnly getReplayId]); +#else + resolve(nil); +#endif } RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId) { +#if SENTRY_TARGET_REPLAY_SUPPORTED 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]; +#else + return nil; +#endif } static NSString* const enabledProfilingMessage = @"Enable Hermes to use Sentry Profiling."; diff --git a/ios/RNSentryBreadcrumbConverter.h b/ios/RNSentryBreadcrumbConverter.h new file mode 100644 index 0000000000..e269265b4d --- /dev/null +++ b/ios/RNSentryBreadcrumbConverter.h @@ -0,0 +1,12 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface RNSentryBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + +@end +#endif diff --git a/ios/RNSentryBreadcrumbConverter.m b/ios/RNSentryBreadcrumbConverter.m new file mode 100644 index 0000000000..4e1a1ee865 --- /dev/null +++ b/ios/RNSentryBreadcrumbConverter.m @@ -0,0 +1,70 @@ +#import "RNSentryBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation RNSentryBreadcrumbConverter { + SentrySRDefaultBreadcrumbConverter *defaultConverter; +} + +- (instancetype _Nonnull)init { + if (self = [super init]) { + self->defaultConverter = + [SentrySessionReplayIntegration createDefaultBreadcrumbConverter]; + } + return self; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb { + assert(breadcrumb.timestamp != nil); + + if ([breadcrumb.category isEqualToString:@"touch"]) { + NSMutableString *message; + if (breadcrumb.data) { + NSMutableArray *path = [breadcrumb.data valueForKey:@"path"]; + if (path != nil) { + message = [[NSMutableString alloc] init]; + for (NSInteger i = MIN(3, [path count] - 1); i >= 0; i--) { + NSDictionary *item = [path objectAtIndex:i]; + [message appendString:[item objectForKey:@"name"]]; + if ([item objectForKey:@"element"] || [item objectForKey:@"file"]) { + [message appendString:@"("]; + if ([item objectForKey:@"element"]) { + [message appendString:[item objectForKey:@"element"]]; + if ([item objectForKey:@"file"]) { + [message appendString:@", "]; + [message appendString:[item objectForKey:@"file"]]; + } + } else if ([item objectForKey:@"file"]) { + [message appendString:[item objectForKey:@"file"]]; + } + [message appendString:@")"]; + } + if (i > 0) { + [message appendString:@" > "]; + } + } + } + } + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:@"ui.tap" + message:message + level:breadcrumb.level + data:breadcrumb.data]; + } else if ([breadcrumb.category isEqualToString:@"navigation"]) { + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:breadcrumb.category + message:nil + level:breadcrumb.level + data:breadcrumb.data]; + } else { + return [self->defaultConverter convertFrom:breadcrumb]; + } +} + +@end +#endif diff --git a/ios/RNSentrySessionReplay.h b/ios/RNSentrySessionReplay.h new file mode 100644 index 0000000000..57762b0b7b --- /dev/null +++ b/ios/RNSentrySessionReplay.h @@ -0,0 +1,8 @@ + +@interface RNSentrySessionReplay : NSObject + ++ (void)updateOptions:(NSMutableDictionary *)options; + ++ (void)postInit; + +@end diff --git a/ios/RNSentrySessionReplay.m b/ios/RNSentrySessionReplay.m new file mode 100644 index 0000000000..fe763ad13e --- /dev/null +++ b/ios/RNSentrySessionReplay.m @@ -0,0 +1,60 @@ +#import "RNSentrySessionReplay.h" +#import "RNSentryBreadcrumbConverter.h" + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation RNSentrySessionReplay { +} + ++ (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) { + NSLog(@"Session replay disabled via configuration"); + return; + } + + NSLog(@"Setting up session replay"); + NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{}; + + [options setValue:@{ + @"sessionReplay" : @{ + @"sessionSampleRate" : experiments[@"replaysSessionSampleRate"] + ?: [NSNull null], + @"errorSampleRate" : experiments[@"replaysOnErrorSampleRate"] + ?: [NSNull null], + @"redactAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null], + @"redactAllText" : replayOptions[@"maskAllText"] ?: [NSNull null], + } + } + forKey:@"experimental"]; + + [RNSentrySessionReplay addReplayRNRedactClasses:replayOptions]; +} + ++ (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]; +} + ++ (void)postInit { + RNSentryBreadcrumbConverter *breadcrumbConverter = + [[RNSentryBreadcrumbConverter alloc] init]; + [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter + screenshotProvider:nil]; +} + +@end +#endif diff --git a/src/js/touchevents.tsx b/src/js/touchevents.tsx index 88ba178864..785dc2977c 100644 --- a/src/js/touchevents.tsx +++ b/src/js/touchevents.tsx @@ -193,13 +193,25 @@ class TouchEventBoundary extends React.Component { const info: TouchedComponentInfo = {}; // provided by @sentry/babel-plugin-component-annotate - if (typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' && props[SENTRY_COMPONENT_PROP_KEY].length > 0 && props[SENTRY_COMPONENT_PROP_KEY] !== 'unknown') { + if ( + typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' && + props[SENTRY_COMPONENT_PROP_KEY].length > 0 && + props[SENTRY_COMPONENT_PROP_KEY] !== 'unknown' + ) { info.name = props[SENTRY_COMPONENT_PROP_KEY]; } - if (typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' && props[SENTRY_ELEMENT_PROP_KEY].length > 0 && props[SENTRY_ELEMENT_PROP_KEY] !== 'unknown') { + if ( + typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' && + props[SENTRY_ELEMENT_PROP_KEY].length > 0 && + props[SENTRY_ELEMENT_PROP_KEY] !== 'unknown' + ) { info.element = props[SENTRY_ELEMENT_PROP_KEY]; } - if (typeof props[SENTRY_FILE_PROP_KEY] === 'string' && props[SENTRY_FILE_PROP_KEY].length > 0 && props[SENTRY_FILE_PROP_KEY] !== 'unknown') { + if ( + typeof props[SENTRY_FILE_PROP_KEY] === 'string' && + props[SENTRY_FILE_PROP_KEY].length > 0 && + props[SENTRY_FILE_PROP_KEY] !== 'unknown' + ) { info.file = props[SENTRY_FILE_PROP_KEY]; }