Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@

### Features

- Add `onNativeLog` callback to intercept and forward native SDK logs to JavaScript console ([#5622](https://github.com/getsentry/sentry-react-native/pull/5622))
- The callback receives native log events with `level`, `component`, and `message` properties
- Only works when `debug: true` is enabled in `Sentry.init`
- Use `logWithoutTracing` inside the callback to prevent feedback loops with Sentry's console integration
```js
import * as Sentry from '@sentry/react-native';

Sentry.init({
debug: true,
onNativeLog: ({ level, component, message }) => {
// Use logWithoutTracing to avoid feedback loops
Sentry.logWithoutTracing(
Comment thread
alwx marked this conversation as resolved.
Outdated
`[Sentry Native] [${level.toUpperCase()}] [${component}] ${message}`
);
}
});
```
- Add expo constants on event context ([#5748](https://github.com/getsentry/sentry-react-native/pull/5748))
- Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750))

Expand Down
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-volatile field risks stale reads across threads

Low Severity

The reactContextRef field is written from the JS thread in setReactContext and read from arbitrary threads in forwardToJS (wherever the Sentry SDK logs internally). Without volatile, there's no happens-before guarantee, so reader threads may see a stale null and silently skip forwarding logs to JS.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Expand Up @@ -40,7 +40,6 @@
import io.sentry.SentryExecutorService;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.android.core.AndroidLogger;
import io.sentry.android.core.AndroidProfiler;
import io.sentry.android.core.BuildInfoProvider;
import io.sentry.android.core.InternalSentrySdk;
Expand Down Expand Up @@ -87,7 +86,8 @@ public class RNSentryModuleImpl {

public static final String NAME = "RNSentry";

private static final ILogger logger = new AndroidLogger(NAME);
private static final RNSentryLogger rnLogger = new RNSentryLogger();
private static final ILogger logger = rnLogger;
private static final BuildInfoProvider buildInfo = new BuildInfoProvider(logger);
private static final String modulesPath = "modules.json";
private static final Charset UTF_8 = Charset.forName("UTF-8"); // NOPMD - Allow using UTF-8
Expand Down Expand Up @@ -170,8 +170,17 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) {
}

public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
// Set the React context for the logger so it can forward logs to JS
rnLogger.setReactContext(this.reactApplicationContext);

RNSentryStart.startWithOptions(
getApplicationContext(), rnOptions, getCurrentActivity(), logger);
getApplicationContext(),
rnOptions,
options -> {
// Use our custom logger that forwards to JS
options.setLogger(rnLogger);
},
logger);
Comment thread
cursor[bot] marked this conversation as resolved.

promise.resolve(true);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

#import "RNSentryDependencyContainer.h"
#import "RNSentryEvents.h"
#import "RNSentryNativeLogsForwarder.h"

#if SENTRY_TARGET_REPLAY_SUPPORTED
# import "RNSentryReplay.h"
Expand Down Expand Up @@ -284,17 +285,19 @@ - (void)initFramesTracking
- (void)startObserving
{
hasListeners = YES;
[[RNSentryNativeLogsForwarder shared] configureWithEventEmitter:self];
}

// Will be called when this module's last listener is removed, or on dealloc.
- (void)stopObserving
{
hasListeners = NO;
[[RNSentryNativeLogsForwarder shared] stopForwarding];
}

- (NSArray<NSString *> *)supportedEvents
{
return @[ RNSentryNewFrameEvent ];
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ];
}

RCT_EXPORT_METHOD(
Expand Down
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.h
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;
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.m
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";
20 changes: 20 additions & 0 deletions packages/core/ios/RNSentryNativeLogsForwarder.h
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
145 changes: 145 additions & 0 deletions packages/core/ios/RNSentryNativeLogsForwarder.m
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"];
Comment thread
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); }];
Comment thread
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
Loading
Loading