Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Binary file modified packages/core/android/libs/replay-stubs.jar
Binary file not shown.
5 changes: 4 additions & 1 deletion packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

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

#if SENTRY_TARGET_REPLAY_SUPPORTED
# import "RNSentryReplay.h"
Expand Down Expand Up @@ -311,17 +312,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";
2 changes: 1 addition & 1 deletion packages/core/ios/RNSentryExperimentalOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ + (void)setEnableSessionReplayInUnreliableEnvironment:(BOOL)enabled
if (sentryOptions == nil) {
return;
}
sentryOptions.experimental.enableSessionReplayInUnreliableEnvironment = enabled;
// sentryOptions.experimental.enableSessionReplayInUnreliableEnvironment = enabled;
}

+ (void)configureProfilingWithOptions:(NSDictionary *)profilingOptions
Expand Down
33 changes: 33 additions & 0 deletions packages/core/ios/RNSentryNativeLogsForwarder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#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

/**
* Returns the shared instance of the logs forwarder.
*/
+ (instancetype)shared;

/**
* Configures the forwarder with the event emitter to use for sending events to JS.
* Call this when the React Native module starts observing events.
*
* @param emitter The RCTEventEmitter instance (typically the RNSentry module).
*/
- (void)configureWithEventEmitter:(RCTEventEmitter *)emitter;

/**
* Clears the event emitter reference.
* Call this when the React Native module stops observing events.
*/
- (void)stopForwarding;

@end

NS_ASSUME_NONNULL_END
143 changes: 143 additions & 0 deletions packages/core/ios/RNSentryNativeLogsForwarder.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#import "RNSentryNativeLogsForwarder.h"

@import Sentry;

static NSString *const RNSentryNativeLogEventName = @"SentryNativeLog";

@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 test log to verify the forwarding works
[self forwardLogMessage:@"[Sentry] [info] [0] [RNSentryNativeLogsForwarder] Native log forwarding "
@"configured successfully"];
}

- (void)stopForwarding
{
self.eventEmitter = nil;

// Reset to default print behavior
[SentrySDKLog setOutput:^(NSString *_Nonnull message) { NSLog(@"%@", message); }];
}

- (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:RNSentryNativeLogEventName 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
90 changes: 90 additions & 0 deletions packages/core/src/js/NativeLogListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { debug } from '@sentry/core';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import type { NativeLogEntry } from './options';

const NATIVE_LOG_EVENT_NAME = 'SentryNativeLog';

let nativeLogListener: ReturnType<NativeEventEmitter['addListener']> | null = null;

/**
* Sets up the native log listener that forwards logs from the native SDK to JS.
* This only works when `debug: true` is set in Sentry options.
*
* @param callback - The callback to invoke when a native log is received.
* @returns A function to remove the listener, or undefined if setup failed.
*/
export function setupNativeLogListener(callback: (log: NativeLogEntry) => void): (() => void) | undefined {
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
debug.log('Native log listener is only supported on iOS and Android.');
return undefined;
}

if (!NativeModules.RNSentry) {
debug.warn('Could not set up native log listener: RNSentry module not found.');
return undefined;
}

try {
// Remove existing listener if any
if (nativeLogListener) {
nativeLogListener.remove();
nativeLogListener = null;
}

const eventEmitter = new NativeEventEmitter(NativeModules.RNSentry);

nativeLogListener = eventEmitter.addListener(
NATIVE_LOG_EVENT_NAME,
(event: { level?: string; component?: string; message?: string }) => {
const logEntry: NativeLogEntry = {
level: event.level ?? 'info',
component: event.component ?? 'Sentry',
message: event.message ?? '',
};
callback(logEntry);
},
);

debug.log('Native log listener set up successfully.');

return () => {
if (nativeLogListener) {
nativeLogListener.remove();
nativeLogListener = null;
debug.log('Native log listener removed.');
}
};
} catch (error) {
debug.warn('Failed to set up native log listener:', error);
return undefined;
}
}

/**
* Default handler for native logs that logs to the JS console.
*/
export function defaultNativeLogHandler(log: NativeLogEntry): void {
const prefix = `[Sentry] [${log.level.toUpperCase()}] [${log.component}]`;
const message = `${prefix} ${log.message}`;

switch (log.level.toLowerCase()) {
case 'fatal':
case 'error':
// eslint-disable-next-line no-console
console.error(message);
break;
case 'warning':
// eslint-disable-next-line no-console
console.warn(message);
break;
case 'info':
// eslint-disable-next-line no-console
console.info(message);
break;
case 'debug':
default:
// eslint-disable-next-line no-console
console.log(message);
break;
}
}
14 changes: 14 additions & 0 deletions packages/core/src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Alert } from 'react-native';
import { getDevServer } from './integrations/debugsymbolicatorutils';
import { defaultSdkInfo } from './integrations/sdkinfo';
import { getDefaultSidecarUrl } from './integrations/spotlight';
import { defaultNativeLogHandler, setupNativeLogListener } from './NativeLogListener';
import type { ReactNativeClientOptions } from './options';
import type { mobileReplayIntegration } from './replay/mobilereplay';
import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
Expand All @@ -42,6 +43,7 @@ const DEFAULT_FLUSH_INTERVAL = 5000;
export class ReactNativeClient extends Client<ReactNativeClientOptions> {
private _outcomesBuffer: Outcome[];
private _logFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
private _removeNativeLogListener: (() => void) | undefined;

/**
* Creates a new React Native SDK instance.
Expand Down Expand Up @@ -127,6 +129,12 @@ export class ReactNativeClient extends Client<ReactNativeClientOptions> {
* @inheritDoc
*/
public close(): PromiseLike<boolean> {
// Clean up native log listener
if (this._removeNativeLogListener) {
this._removeNativeLogListener();
this._removeNativeLogListener = undefined;
}

// As super.close() flushes queued events, we wait for that to finish before closing the native SDK.
return super.close().then((result: boolean) => {
return NATIVE.closeNativeSdk().then(() => result);
Expand Down Expand Up @@ -215,6 +223,12 @@ export class ReactNativeClient extends Client<ReactNativeClientOptions> {
* Starts native client with dsn and options
*/
private _initNativeSdk(): void {
// Set up native log listener if debug is enabled
if (this._options.debug) {
const logHandler = this._options.onNativeLog ?? defaultNativeLogHandler;
this._removeNativeLogListener = setupNativeLogListener(logHandler);
}

NATIVE.initNativeSdk({
...this._options,
defaultSidecarUrl: getDefaultSidecarUrl(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export {
export * from './integrations/exports';

export { SDK_NAME, SDK_VERSION } from './version';
export type { ReactNativeOptions } from './options';
export type { ReactNativeOptions, NativeLogEntry } from './options';
export { ReactNativeClient } from './client';

export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun } from './sdk';
Expand Down
Loading
Loading