Skip to content
Open
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
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
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.');
Comment on lines +17 to +18
Copy link
Collaborator

Choose a reason for hiding this comment

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

This will work on the future once we have support for Android and iOS. For the time being, this PR is only adding support for iOS

Suggested change
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
debug.log('Native log listener is only supported on iOS and Android.');
if (Platform.OS !== 'ios') {
debug.log('Native log listener is only supported on iOS.'');

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()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be nice if defaultNativeLogHandler doesn't create any events when captureConsoleIntegration is added as an integration.
Q: Shouldn't we use debug instead of console here?

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
28 changes: 28 additions & 0 deletions packages/core/src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,34 @@ export interface BaseReactNativeOptions {
* @default 'all'
*/
logsOrigin?: 'all' | 'js' | 'native';

/**
* A callback that is invoked when the native SDK emits a log message.
* This is useful for surfacing native SDK logs (e.g., transport errors like HTTP 413)
* in the JavaScript console.
*
* Only works when `debug: true` is set.
*
* @example
* ```typescript
* Sentry.init({
* debug: true,
* onNativeLog: ({ level, component, message }) => {
* console.log(`[Sentry Native] [${level}] [${component}] ${message}`);
* },
* });
* ```
*/
onNativeLog?: (log: NativeLogEntry) => void;
}

/**
* Represents a log entry from the native SDK.
*/
export interface NativeLogEntry {
level: string;
component: string;
message: string;
}

export type SentryReplayQuality = 'low' | 'medium' | 'high';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export const NATIVE: SentryNativeWrapper = {
logsOrigin,
profilingOptions,
androidProfilingOptions,
onNativeLog,
...filteredOptions
} = options;
/* eslint-enable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */
Expand Down
Loading