Skip to content

Commit 878d0cc

Browse files
authored
[iOS + Android] Add the ability to intercept errors from native side and forward them to JS console (#5622)
* An (ongoing) experiment with adding native logs listener * NativeLogListener.ts * Log forwarder * Fixes * Fixes * Android changes * Fixes * Missing Android logger * Changelog entry * Changelog entry moved * Changelog entry fixed * Fix * Fixes * Fixes * Consolidate the two `startWithOptions` overloads into a single canonical 5-arg method that accepts both `currentActivity` and `configuration` * isExpoGo check * isExpoGo check; mocks for tests
1 parent 78feee8 commit 878d0cc

15 files changed

Lines changed: 620 additions & 14 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@
1010

1111
### Features
1212

13+
- Add `onNativeLog` callback to intercept and forward native SDK logs to JavaScript console ([#5622](https://github.com/getsentry/sentry-react-native/pull/5622))
14+
- The callback receives native log events with `level`, `component`, and `message` properties
15+
- Only works when `debug: true` is enabled in `Sentry.init`
16+
- Use `consoleSandbox` inside the callback to prevent feedback loops with Sentry's console integration
17+
```js
18+
import * as Sentry from '@sentry/react-native';
19+
20+
Sentry.init({
21+
debug: true,
22+
onNativeLog: ({ level, component, message }) => {
23+
// Use consoleSandbox to avoid feedback loops
24+
Sentry.consoleSandbox(() => {
25+
console.log(
26+
`[Sentry Native] [${level.toUpperCase()}] [${component}] ${message}`
27+
);
28+
});
29+
}
30+
});
31+
```
1332
- Add expo constants on event context ([#5748](https://github.com/getsentry/sentry-react-native/pull/5748))
1433
- Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750))
1534

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package io.sentry.react;
2+
3+
import com.facebook.react.bridge.Arguments;
4+
import com.facebook.react.bridge.ReactApplicationContext;
5+
import com.facebook.react.bridge.WritableMap;
6+
import com.facebook.react.modules.core.DeviceEventManagerModule;
7+
import io.sentry.ILogger;
8+
import io.sentry.SentryLevel;
9+
import io.sentry.android.core.AndroidLogger;
10+
import java.lang.ref.WeakReference;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.jetbrains.annotations.Nullable;
13+
14+
/**
15+
* Custom ILogger implementation that wraps AndroidLogger and forwards log messages to React Native.
16+
* This allows native SDK logs to appear in the Metro console when debug mode is enabled.
17+
*/
18+
public class RNSentryLogger implements ILogger {
19+
private static final String TAG = "RNSentry";
20+
private static final String EVENT_NAME = "SentryNativeLog";
21+
22+
private final AndroidLogger androidLogger;
23+
private WeakReference<ReactApplicationContext> reactContextRef;
24+
25+
public RNSentryLogger() {
26+
this.androidLogger = new AndroidLogger(TAG);
27+
}
28+
29+
public void setReactContext(@Nullable ReactApplicationContext context) {
30+
this.reactContextRef = context != null ? new WeakReference<>(context) : null;
31+
}
32+
33+
@Override
34+
public void log(@NotNull SentryLevel level, @NotNull String message, @Nullable Object... args) {
35+
// Always log to Logcat (default behavior)
36+
androidLogger.log(level, message, args);
37+
38+
// Forward to JS
39+
String formattedMessage =
40+
(args == null || args.length == 0) ? message : String.format(message, args);
41+
forwardToJS(level, formattedMessage);
42+
}
43+
44+
@Override
45+
public void log(
46+
@NotNull SentryLevel level, @NotNull String message, @Nullable Throwable throwable) {
47+
androidLogger.log(level, message, throwable);
48+
49+
String fullMessage = throwable != null ? message + ": " + throwable.getMessage() : message;
50+
forwardToJS(level, fullMessage);
51+
}
52+
53+
@Override
54+
public void log(
55+
@NotNull SentryLevel level,
56+
@Nullable Throwable throwable,
57+
@NotNull String message,
58+
@Nullable Object... args) {
59+
androidLogger.log(level, throwable, message, args);
60+
61+
String formattedMessage =
62+
(args == null || args.length == 0) ? message : String.format(message, args);
63+
if (throwable != null) {
64+
formattedMessage += ": " + throwable.getMessage();
65+
}
66+
forwardToJS(level, formattedMessage);
67+
}
68+
69+
@Override
70+
public boolean isEnabled(@Nullable SentryLevel level) {
71+
return androidLogger.isEnabled(level);
72+
}
73+
74+
private void forwardToJS(@NotNull SentryLevel level, @NotNull String message) {
75+
ReactApplicationContext context = reactContextRef != null ? reactContextRef.get() : null;
76+
if (context == null || !context.hasActiveReactInstance()) {
77+
return;
78+
}
79+
80+
try {
81+
WritableMap params = Arguments.createMap();
82+
params.putString("level", level.name().toLowerCase());
83+
params.putString("component", "Sentry");
84+
params.putString("message", message);
85+
86+
context
87+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
88+
.emit(EVENT_NAME, params);
89+
} catch (Exception e) {
90+
// Silently ignore - don't cause issues if JS bridge isn't ready
91+
// We intentionally swallow this exception to avoid disrupting the app
92+
// when the React Native bridge is not yet initialized or has been torn down
93+
androidLogger.log(SentryLevel.DEBUG, "Failed to forward log to JS: " + e.getMessage());
94+
}
95+
}
96+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
import io.sentry.SentryExecutorService;
4141
import io.sentry.SentryLevel;
4242
import io.sentry.SentryOptions;
43-
import io.sentry.android.core.AndroidLogger;
4443
import io.sentry.android.core.AndroidProfiler;
4544
import io.sentry.android.core.BuildInfoProvider;
4645
import io.sentry.android.core.InternalSentrySdk;
@@ -87,7 +86,8 @@ public class RNSentryModuleImpl {
8786

8887
public static final String NAME = "RNSentry";
8988

90-
private static final ILogger logger = new AndroidLogger(NAME);
89+
private static final RNSentryLogger rnLogger = new RNSentryLogger();
90+
private static final ILogger logger = rnLogger;
9191
private static final BuildInfoProvider buildInfo = new BuildInfoProvider(logger);
9292
private static final String modulesPath = "modules.json";
9393
private static final Charset UTF_8 = Charset.forName("UTF-8"); // NOPMD - Allow using UTF-8
@@ -170,8 +170,18 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) {
170170
}
171171

172172
public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
173+
// Set the React context for the logger so it can forward logs to JS
174+
rnLogger.setReactContext(this.reactApplicationContext);
175+
173176
RNSentryStart.startWithOptions(
174-
getApplicationContext(), rnOptions, getCurrentActivity(), logger);
177+
getApplicationContext(),
178+
rnOptions,
179+
getCurrentActivity(),
180+
options -> {
181+
// Use our custom logger that forwards to JS
182+
options.setLogger(rnLogger);
183+
},
184+
logger);
175185

176186
promise.resolve(true);
177187
}

packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,28 +49,30 @@ static void startWithOptions(
4949
@NotNull final ReadableMap rnOptions,
5050
@NotNull Sentry.OptionsConfiguration<SentryAndroidOptions> configuration,
5151
@NotNull ILogger logger) {
52-
Sentry.OptionsConfiguration<SentryAndroidOptions> defaults =
53-
options -> updateWithReactDefaults(options, null);
54-
Sentry.OptionsConfiguration<SentryAndroidOptions> rnConfigurationOptions =
55-
options -> getSentryAndroidOptions(options, rnOptions, logger);
56-
RNSentryCompositeOptionsConfiguration compositeConfiguration =
57-
new RNSentryCompositeOptionsConfiguration(
58-
rnConfigurationOptions, defaults, configuration, RNSentryStart::updateWithReactFinals);
59-
SentryAndroid.init(context, compositeConfiguration);
52+
startWithOptions(context, rnOptions, null, configuration, logger);
6053
}
6154

6255
static void startWithOptions(
6356
@NotNull final Context context,
6457
@NotNull final ReadableMap rnOptions,
6558
@Nullable Activity currentActivity,
6659
@NotNull ILogger logger) {
60+
startWithOptions(context, rnOptions, currentActivity, options -> {}, logger);
61+
}
62+
63+
static void startWithOptions(
64+
@NotNull final Context context,
65+
@NotNull final ReadableMap rnOptions,
66+
@Nullable Activity currentActivity,
67+
@NotNull Sentry.OptionsConfiguration<SentryAndroidOptions> configuration,
68+
@NotNull ILogger logger) {
6769
Sentry.OptionsConfiguration<SentryAndroidOptions> defaults =
6870
options -> updateWithReactDefaults(options, currentActivity);
6971
Sentry.OptionsConfiguration<SentryAndroidOptions> rnConfigurationOptions =
7072
options -> getSentryAndroidOptions(options, rnOptions, logger);
7173
RNSentryCompositeOptionsConfiguration compositeConfiguration =
7274
new RNSentryCompositeOptionsConfiguration(
73-
rnConfigurationOptions, defaults, RNSentryStart::updateWithReactFinals);
75+
rnConfigurationOptions, defaults, configuration, RNSentryStart::updateWithReactFinals);
7476
SentryAndroid.init(context, compositeConfiguration);
7577
}
7678

packages/core/ios/RNSentry.mm

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
#import "RNSentryDependencyContainer.h"
4141
#import "RNSentryEvents.h"
42+
#import "RNSentryNativeLogsForwarder.h"
4243

4344
#if SENTRY_TARGET_REPLAY_SUPPORTED
4445
# import "RNSentryReplay.h"
@@ -284,17 +285,19 @@ - (void)initFramesTracking
284285
- (void)startObserving
285286
{
286287
hasListeners = YES;
288+
[[RNSentryNativeLogsForwarder shared] configureWithEventEmitter:self];
287289
}
288290

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

295298
- (NSArray<NSString *> *)supportedEvents
296299
{
297-
return @[ RNSentryNewFrameEvent ];
300+
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ];
298301
}
299302

300303
RCT_EXPORT_METHOD(

packages/core/ios/RNSentryEvents.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#import <Foundation/Foundation.h>
22

33
extern NSString *const RNSentryNewFrameEvent;
4+
extern NSString *const RNSentryNativeLogEvent;

packages/core/ios/RNSentryEvents.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#import "RNSentryEvents.h"
22

33
NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
4+
NSString *const RNSentryNativeLogEvent = @"SentryNativeLog";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#import <Foundation/Foundation.h>
2+
#import <React/RCTEventEmitter.h>
3+
4+
NS_ASSUME_NONNULL_BEGIN
5+
6+
/**
7+
* Singleton class that forwards native Sentry SDK logs to JavaScript via React Native events.
8+
* This allows React Native developers to see native SDK logs in the Metro console.
9+
*/
10+
@interface RNSentryNativeLogsForwarder : NSObject
11+
12+
+ (instancetype)shared;
13+
14+
- (void)configureWithEventEmitter:(RCTEventEmitter *)emitter;
15+
16+
- (void)stopForwarding;
17+
18+
@end
19+
20+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)