From 38d8d7c7df234642905bebb3f99ed38d416375a6 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:55:50 +0200 Subject: [PATCH] feat(replay): Add breadcrumbs mapping from RN to RRWeb format (#3846) --- CHANGELOG.md | 1 + .../io/sentry/react/RNSentryModuleImpl.java | 3 +- .../RNSentryReplayBreadcrumbConverter.java | 74 +++++++++++++++++ ios/RNSentry.mm | 52 +++++------- ios/RNSentryReplay.h | 8 ++ ios/RNSentryReplay.m | 60 ++++++++++++++ ios/RNSentryReplayBreadcrumbConverter.h | 12 +++ ios/RNSentryReplayBreadcrumbConverter.m | 81 +++++++++++++++++++ src/js/touchevents.tsx | 18 ++++- 9 files changed, 272 insertions(+), 37 deletions(-) create mode 100644 android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java create mode 100644 ios/RNSentryReplay.h create mode 100644 ios/RNSentryReplay.m create mode 100644 ios/RNSentryReplayBreadcrumbConverter.h create mode 100644 ios/RNSentryReplayBreadcrumbConverter.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e979530..8533b562c 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 179e6dd9d..d32b0aab1 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 000000000..f58f0d5b1 --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -0,0 +1,74 @@ +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()); + } else if (breadcrumb.getCategory().equals("navigation")) { + rrwebBreadcrumb.setCategory(breadcrumb.getCategory()); + rrwebBreadcrumb.setData(breadcrumb.getData()); + } + + if (rrwebBreadcrumb.getCategory() != null && !rrwebBreadcrumb.getCategory().isEmpty()) { + rrwebBreadcrumb.setLevel(breadcrumb.getLevel()); + rrwebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime()); + rrwebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0); + rrwebBreadcrumb.setBreadcrumbType("default"); + return rrwebBreadcrumb; + } + + RRWebEvent nativeBreadcrumb = super.convert(breadcrumb); + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb instanceof RRWebBreadcrumbEvent) { + rrwebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb; + if (rrwebBreadcrumb.getCategory() != null && rrwebBreadcrumb.getCategory().equals("navigation")) { + return null; + } + } + + return nativeBreadcrumb; + } +} diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index f6dbcbba7..853c4a3e6 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 "RNSentryReplay.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 + [RNSentryReplay 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 + [RNSentryReplay 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/RNSentryReplay.h b/ios/RNSentryReplay.h new file mode 100644 index 000000000..f3cb5a6ef --- /dev/null +++ b/ios/RNSentryReplay.h @@ -0,0 +1,8 @@ + +@interface RNSentryReplay : NSObject + ++ (void)updateOptions:(NSMutableDictionary *)options; + ++ (void)postInit; + +@end diff --git a/ios/RNSentryReplay.m b/ios/RNSentryReplay.m new file mode 100644 index 000000000..ecd7a4d16 --- /dev/null +++ b/ios/RNSentryReplay.m @@ -0,0 +1,60 @@ +#import "RNSentryReplay.h" +#import "RNSentryReplayBreadcrumbConverter.h" + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@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) { + 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"]; + + [RNSentryReplay 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 { + RNSentryReplayBreadcrumbConverter *breadcrumbConverter = + [[RNSentryReplayBreadcrumbConverter alloc] init]; + [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter + screenshotProvider:nil]; +} + +@end +#endif diff --git a/ios/RNSentryReplayBreadcrumbConverter.h b/ios/RNSentryReplayBreadcrumbConverter.h new file mode 100644 index 000000000..98030b67b --- /dev/null +++ b/ios/RNSentryReplayBreadcrumbConverter.h @@ -0,0 +1,12 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface RNSentryReplayBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + +@end +#endif diff --git a/ios/RNSentryReplayBreadcrumbConverter.m b/ios/RNSentryReplayBreadcrumbConverter.m new file mode 100644 index 000000000..92d736e9b --- /dev/null +++ b/ios/RNSentryReplayBreadcrumbConverter.m @@ -0,0 +1,81 @@ +#import "RNSentryReplayBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation RNSentryReplayBreadcrumbConverter { + 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 { + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + return nativeBreadcrumb; + } +} + +@end +#endif diff --git a/src/js/touchevents.tsx b/src/js/touchevents.tsx index 88ba17886..785dc2977 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]; }