Skip to content

Commit

Permalink
feat(replay): Add breadcrumbs mapping from RN to RRWeb format (#3846)
Browse files Browse the repository at this point in the history
  • Loading branch information
vaind committed Jun 26, 2024
1 parent 1f1e41e commit 38d8d7c
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 37 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 19 additions & 33 deletions ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -106,6 +110,10 @@ + (BOOL)requiresMainQueueSetup {
sentHybridSdkDidBecomeActive = true;
}

#if SENTRY_TARGET_REPLAY_SUPPORTED
[RNSentryReplay postInit];
#endif

resolve(@YES);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -635,25 +625,21 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray<NSNumber*>*)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.";
Expand Down
8 changes: 8 additions & 0 deletions ios/RNSentryReplay.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

@interface RNSentryReplay : NSObject

+ (void)updateOptions:(NSMutableDictionary *)options;

+ (void)postInit;

@end
60 changes: 60 additions & 0 deletions ios/RNSentryReplay.m
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions ios/RNSentryReplayBreadcrumbConverter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@import Sentry;

#if SENTRY_TARGET_REPLAY_SUPPORTED
@class SentryRRWebEvent;

@interface RNSentryReplayBreadcrumbConverter
: NSObject <SentryReplayBreadcrumbConverter>

- (instancetype _Nonnull)init;

@end
#endif
81 changes: 81 additions & 0 deletions ios/RNSentryReplayBreadcrumbConverter.m
Original file line number Diff line number Diff line change
@@ -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<SentryRRWebEvent> _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
18 changes: 15 additions & 3 deletions src/js/touchevents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,25 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
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];
}

Expand Down

0 comments on commit 38d8d7c

Please sign in to comment.