-
-
Notifications
You must be signed in to change notification settings - Fork 359
[iOS + Android] Add the ability to intercept errors from native side and forward them to JS console #5622
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
[iOS + Android] Add the ability to intercept errors from native side and forward them to JS console #5622
Changes from 17 commits
ca92d48
4949785
9490055
0995992
ab92830
3fb8b3e
4d1b2f5
f27e050
fb3617f
1afc4b6
666a146
be4b465
e3731a7
d4c5543
a9314a2
02fec86
e45a8d4
0ee38aa
830323e
b5685ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package io.sentry.react; | ||
|
|
||
| import com.facebook.react.bridge.Arguments; | ||
| import com.facebook.react.bridge.ReactApplicationContext; | ||
| import com.facebook.react.bridge.WritableMap; | ||
| import com.facebook.react.modules.core.DeviceEventManagerModule; | ||
| import io.sentry.ILogger; | ||
| import io.sentry.SentryLevel; | ||
| import io.sentry.android.core.AndroidLogger; | ||
| import java.lang.ref.WeakReference; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Custom ILogger implementation that wraps AndroidLogger and forwards log messages to React Native. | ||
| * This allows native SDK logs to appear in the Metro console when debug mode is enabled. | ||
| */ | ||
| public class RNSentryLogger implements ILogger { | ||
| private static final String TAG = "RNSentry"; | ||
| private static final String EVENT_NAME = "SentryNativeLog"; | ||
|
|
||
| private final AndroidLogger androidLogger; | ||
| private WeakReference<ReactApplicationContext> reactContextRef; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-volatile field risks stale reads across threadsLow Severity The Triggered by project rule: PR Review Guidelines for Cursor Bot
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can ignore this |
||
|
|
||
| public RNSentryLogger() { | ||
| this.androidLogger = new AndroidLogger(TAG); | ||
| } | ||
|
|
||
| public void setReactContext(@Nullable ReactApplicationContext context) { | ||
| this.reactContextRef = context != null ? new WeakReference<>(context) : null; | ||
| } | ||
|
|
||
| @Override | ||
| public void log(@NotNull SentryLevel level, @NotNull String message, @Nullable Object... args) { | ||
| // Always log to Logcat (default behavior) | ||
| androidLogger.log(level, message, args); | ||
|
|
||
| // Forward to JS | ||
| String formattedMessage = | ||
| (args == null || args.length == 0) ? message : String.format(message, args); | ||
| forwardToJS(level, formattedMessage); | ||
| } | ||
|
|
||
| @Override | ||
| public void log( | ||
| @NotNull SentryLevel level, @NotNull String message, @Nullable Throwable throwable) { | ||
| androidLogger.log(level, message, throwable); | ||
|
|
||
| String fullMessage = throwable != null ? message + ": " + throwable.getMessage() : message; | ||
| forwardToJS(level, fullMessage); | ||
| } | ||
|
|
||
| @Override | ||
| public void log( | ||
| @NotNull SentryLevel level, | ||
| @Nullable Throwable throwable, | ||
| @NotNull String message, | ||
| @Nullable Object... args) { | ||
| androidLogger.log(level, throwable, message, args); | ||
|
|
||
| String formattedMessage = | ||
| (args == null || args.length == 0) ? message : String.format(message, args); | ||
| if (throwable != null) { | ||
| formattedMessage += ": " + throwable.getMessage(); | ||
| } | ||
| forwardToJS(level, formattedMessage); | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isEnabled(@Nullable SentryLevel level) { | ||
| return androidLogger.isEnabled(level); | ||
| } | ||
|
|
||
| private void forwardToJS(@NotNull SentryLevel level, @NotNull String message) { | ||
| ReactApplicationContext context = reactContextRef != null ? reactContextRef.get() : null; | ||
| if (context == null || !context.hasActiveReactInstance()) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| WritableMap params = Arguments.createMap(); | ||
| params.putString("level", level.name().toLowerCase()); | ||
| params.putString("component", "Sentry"); | ||
| params.putString("message", message); | ||
|
|
||
| context | ||
| .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) | ||
| .emit(EVENT_NAME, params); | ||
| } catch (Exception e) { | ||
| // Silently ignore - don't cause issues if JS bridge isn't ready | ||
| // We intentionally swallow this exception to avoid disrupting the app | ||
| // when the React Native bridge is not yet initialized or has been torn down | ||
| androidLogger.log(SentryLevel.DEBUG, "Failed to forward log to JS: " + e.getMessage()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| #import <Foundation/Foundation.h> | ||
|
|
||
| extern NSString *const RNSentryNewFrameEvent; | ||
| extern NSString *const RNSentryNativeLogEvent; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| #import "RNSentryEvents.h" | ||
|
|
||
| NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame"; | ||
| NSString *const RNSentryNativeLogEvent = @"SentryNativeLog"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| #import <Foundation/Foundation.h> | ||
| #import <React/RCTEventEmitter.h> | ||
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| /** | ||
| * Singleton class that forwards native Sentry SDK logs to JavaScript via React Native events. | ||
| * This allows React Native developers to see native SDK logs in the Metro console. | ||
| */ | ||
| @interface RNSentryNativeLogsForwarder : NSObject | ||
|
|
||
| + (instancetype)shared; | ||
|
|
||
| - (void)configureWithEventEmitter:(RCTEventEmitter *)emitter; | ||
|
|
||
| - (void)stopForwarding; | ||
|
|
||
| @end | ||
|
|
||
| NS_ASSUME_NONNULL_END |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| #import "RNSentryNativeLogsForwarder.h" | ||
| #import "RNSentryEvents.h" | ||
|
|
||
| @import Sentry; | ||
|
|
||
| @interface RNSentryNativeLogsForwarder () | ||
|
|
||
| @property (nonatomic, weak) RCTEventEmitter *eventEmitter; | ||
|
|
||
| @end | ||
|
|
||
| @implementation RNSentryNativeLogsForwarder | ||
|
|
||
| + (instancetype)shared | ||
| { | ||
| static RNSentryNativeLogsForwarder *instance = nil; | ||
| static dispatch_once_t onceToken; | ||
| dispatch_once(&onceToken, ^{ instance = [[RNSentryNativeLogsForwarder alloc] init]; }); | ||
| return instance; | ||
| } | ||
|
|
||
| - (void)configureWithEventEmitter:(RCTEventEmitter *)emitter | ||
| { | ||
| self.eventEmitter = emitter; | ||
|
|
||
| __weak RNSentryNativeLogsForwarder *weakSelf = self; | ||
|
|
||
| // Set up the Sentry SDK log output to forward logs to JS | ||
| [SentrySDKLog setOutput:^(NSString *_Nonnull message) { | ||
| // Always print to console (default behavior) | ||
| NSLog(@"%@", message); | ||
|
|
||
| // Forward to JS if we have an emitter | ||
| RNSentryNativeLogsForwarder *strongSelf = weakSelf; | ||
| if (strongSelf) { | ||
| [strongSelf forwardLogMessage:message]; | ||
| } | ||
| }]; | ||
|
|
||
| // Send a log to notify user the forwarding works | ||
| [self forwardLogMessage: | ||
| @"[Sentry] [info] [0] [RNSentryNativeLogsForwarder] Native log forwarding " | ||
| @"configured successfully"]; | ||
|
alwx marked this conversation as resolved.
|
||
| } | ||
|
|
||
| - (void)stopForwarding | ||
| { | ||
| self.eventEmitter = nil; | ||
|
|
||
| // TODO: Ideally we should save the previous output block in configureWithEventEmitter: | ||
| // and restore it here instead of hardcoding NSLog. | ||
| // Reset to default print behavior | ||
| [SentrySDKLog setOutput:^(NSString *_Nonnull message) { NSLog(@"%@", message); }]; | ||
|
antonis marked this conversation as resolved.
|
||
| } | ||
|
|
||
| - (void)forwardLogMessage:(NSString *)message | ||
| { | ||
| RCTEventEmitter *emitter = self.eventEmitter; | ||
| if (emitter == nil) { | ||
| return; | ||
| } | ||
|
|
||
| // Only forward messages that look like Sentry SDK logs | ||
| if (![message hasPrefix:@"[Sentry]"]) { | ||
| return; | ||
| } | ||
|
|
||
| // Parse the log message to extract level and component | ||
| // Format: "[Sentry] [level] [timestamp] [Component:line] message" | ||
| // or: "[Sentry] [level] [timestamp] message" | ||
| NSString *level = [self extractLevelFromMessage:message]; | ||
| NSString *component = [self extractComponentFromMessage:message]; | ||
| NSString *cleanMessage = [self extractCleanMessageFromMessage:message]; | ||
|
|
||
| NSDictionary *body = @{ | ||
| @"level" : level, | ||
| @"component" : component, | ||
| @"message" : cleanMessage, | ||
| }; | ||
|
|
||
| // Dispatch async to avoid blocking the calling thread and potential deadlocks | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| RCTEventEmitter *currentEmitter = self.eventEmitter; | ||
| if (currentEmitter != nil) { | ||
| [currentEmitter sendEventWithName:RNSentryNativeLogEvent body:body]; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| - (NSString *)extractLevelFromMessage:(NSString *)message | ||
| { | ||
| // Look for patterns like [debug], [info], [warning], [error], [fatal] | ||
| NSRegularExpression *regex = | ||
| [NSRegularExpression regularExpressionWithPattern:@"\\[(debug|info|warning|error|fatal)\\]" | ||
| options:NSRegularExpressionCaseInsensitive | ||
| error:nil]; | ||
|
|
||
| NSTextCheckingResult *match = [regex firstMatchInString:message | ||
| options:0 | ||
| range:NSMakeRange(0, message.length)]; | ||
|
|
||
| if (match && match.numberOfRanges > 1) { | ||
| return [[message substringWithRange:[match rangeAtIndex:1]] lowercaseString]; | ||
| } | ||
|
|
||
| return @"info"; | ||
| } | ||
|
|
||
| - (NSString *)extractComponentFromMessage:(NSString *)message | ||
| { | ||
| // Look for pattern like [ComponentName:123] | ||
| NSRegularExpression *regex = | ||
| [NSRegularExpression regularExpressionWithPattern:@"\\[([A-Za-z]+):\\d+\\]" | ||
| options:0 | ||
| error:nil]; | ||
|
|
||
| NSTextCheckingResult *match = [regex firstMatchInString:message | ||
| options:0 | ||
| range:NSMakeRange(0, message.length)]; | ||
|
|
||
| if (match && match.numberOfRanges > 1) { | ||
| return [message substringWithRange:[match rangeAtIndex:1]]; | ||
| } | ||
|
|
||
| return @"Sentry"; | ||
| } | ||
|
|
||
| - (NSString *)extractCleanMessageFromMessage:(NSString *)message | ||
| { | ||
| // Remove the prefix parts: [Sentry] [level] [timestamp] [Component:line] | ||
| // and return just the actual message content | ||
| NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern: | ||
| @"^\\[Sentry\\]\\s*\\[[^\\]]+\\]\\s*\\[[^\\]]+\\]\\s*(?:\\[[^\\]]+\\]\\s*)?" | ||
| options:0 | ||
| error:nil]; | ||
|
|
||
| NSString *cleanMessage = [regex stringByReplacingMatchesInString:message | ||
| options:0 | ||
| range:NSMakeRange(0, message.length) | ||
| withTemplate:@""]; | ||
|
|
||
| return [cleanMessage stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; | ||
| } | ||
|
|
||
| @end | ||


Uh oh!
There was an error while loading. Please reload this page.