From b75b594bc3192c92e484fee1e43b19d24e5c469d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 5 Feb 2026 15:08:40 +0100 Subject: [PATCH 1/8] An (ongoing) experiment with adding native logs listener --- .../core/ios/RNSentryExperimentalOptions.m | 2 +- packages/core/src/js/NativeLogListener.ts | 58 +++++++++++++++++++ packages/core/src/js/client.ts | 14 +++++ packages/core/src/js/index.ts | 2 +- packages/core/src/js/options.ts | 39 +++++++++++++ packages/core/src/js/wrapper.ts | 1 + samples/expo/app.json | 6 +- .../project.pbxproj | 34 ++++------- samples/react-native/src/App.tsx | 3 + 9 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/js/NativeLogListener.ts diff --git a/packages/core/ios/RNSentryExperimentalOptions.m b/packages/core/ios/RNSentryExperimentalOptions.m index 084ed36309..d0feeb9981 100644 --- a/packages/core/ios/RNSentryExperimentalOptions.m +++ b/packages/core/ios/RNSentryExperimentalOptions.m @@ -34,7 +34,7 @@ + (void)setEnableSessionReplayInUnreliableEnvironment:(BOOL)enabled if (sentryOptions == nil) { return; } - sentryOptions.experimental.enableSessionReplayInUnreliableEnvironment = enabled; + // sentryOptions.experimental.enableSessionReplayInUnreliableEnvironment = enabled; } + (void)configureProfilingWithOptions:(NSDictionary *)profilingOptions diff --git a/packages/core/src/js/NativeLogListener.ts b/packages/core/src/js/NativeLogListener.ts new file mode 100644 index 0000000000..f156c0a09e --- /dev/null +++ b/packages/core/src/js/NativeLogListener.ts @@ -0,0 +1,58 @@ +import { debug } from '@sentry/core'; +import { Platform } from 'react-native'; +import type { NativeLogEntry } from './options'; + +/** + * 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. + * + * Note: Native log forwarding is not yet implemented. This function is a placeholder + * for future implementation. Currently, native SDK logs appear in Xcode console (iOS) + * or Logcat (Android) when `debug: true` is set. + * + * @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; + } + + // Native log forwarding is not yet implemented. + // The infrastructure is in place for when native SDKs support log callbacks. + debug.log( + 'Native log forwarding is not yet implemented. Native SDK logs will appear in Xcode console (iOS) or Logcat (Android) when debug mode is enabled.', + ); + + 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; + } +} diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 74a834090a..d481d96b52 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -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'; @@ -42,6 +43,7 @@ const DEFAULT_FLUSH_INTERVAL = 5000; export class ReactNativeClient extends Client { private _outcomesBuffer: Outcome[]; private _logFlushIdleTimeout: ReturnType | undefined; + private _removeNativeLogListener: (() => void) | undefined; /** * Creates a new React Native SDK instance. @@ -127,6 +129,12 @@ export class ReactNativeClient extends Client { * @inheritDoc */ public close(): PromiseLike { + // 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); @@ -215,6 +223,12 @@ export class ReactNativeClient extends Client { * 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(), diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 19ba331003..cb92e30771 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -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'; diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 20f2e7207d..40afcc33a1 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -347,6 +347,45 @@ 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 { + /** + * The log level (e.g., 'debug', 'info', 'warning', 'error', 'fatal'). + */ + level: string; + + /** + * The component or module that emitted the log (e.g., 'Sentry', 'SentryHttpTransport'). + */ + component: string; + + /** + * The log message. + */ + message: string; } export type SentryReplayQuality = 'low' | 'medium' | 'high'; diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 35d686e39d..def9e48960 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -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 */ diff --git a/samples/expo/app.json b/samples/expo/app.json index efff71a578..649311d26a 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -14,9 +14,7 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", @@ -90,4 +88,4 @@ "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000" } } -} \ No newline at end of file +} diff --git a/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj b/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj index e98d264df9..c66b30e41e 100644 --- a/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj +++ b/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj @@ -282,14 +282,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-frameworks.sh\"\n"; @@ -317,14 +313,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-resources.sh\"\n"; @@ -382,14 +374,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample-sentryreactnativesampleTests/Pods-sentryreactnativesample-sentryreactnativesampleTests-frameworks.sh\"\n"; @@ -403,14 +391,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-sentryreactnativesample/Pods-sentryreactnativesample-resources.sh\"\n"; @@ -501,7 +485,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 97JCY7859U; @@ -523,7 +507,7 @@ PRODUCT_NAME = sentryreactnativesample; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.sentry.reactnative.sample"; - RCT_NEW_ARCH_ENABLED = 1; + RCT_NEW_ARCH_ENABLED = 1; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -538,7 +522,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 97JCY7859U; @@ -559,7 +543,7 @@ PRODUCT_NAME = sentryreactnativesample; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.sentry.reactnative.sample"; - RCT_NEW_ARCH_ENABLED = 1; + RCT_NEW_ARCH_ENABLED = 1; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; @@ -640,7 +624,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -713,7 +700,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index f9436b8ed2..38c7e96fcb 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -47,6 +47,9 @@ const BottomTabNavigator = createBottomTabNavigator(); Sentry.init({ // Replace the example DSN below with your own DSN: dsn: getDsn(), + onNativeLog: ({ level, component, message }) => { + console.log(`ALWX [Sentry Native] [${level}] [${component}] ${message}`); + }, debug: true, environment: 'dev', beforeSend: (event: Sentry.ErrorEvent) => { From 4e0885b62d48d88b57383b863770d046ad8d8d9d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 5 Feb 2026 18:28:51 +0100 Subject: [PATCH 2/8] NativeLogListener.ts --- packages/core/ios/RNSentry.mm | 5 +- packages/core/ios/RNSentryEvents.h | 1 + packages/core/ios/RNSentryEvents.m | 1 + packages/core/src/js/NativeLogListener.ts | 58 ++++++-- packages/core/test/NativeLogListener.test.ts | 144 +++++++++++++++++++ samples/react-native/src/App.tsx | 6 +- 6 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 packages/core/test/NativeLogListener.test.ts diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 3461af87af..cfc274aa76 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -41,6 +41,7 @@ #import "RNSentryDependencyContainer.h" #import "RNSentryEvents.h" +#import "RNSentryNativeLogsForwarder.h" #if SENTRY_TARGET_REPLAY_SUPPORTED # import "RNSentryReplay.h" @@ -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 *)supportedEvents { - return @[ RNSentryNewFrameEvent ]; + return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ]; } RCT_EXPORT_METHOD( diff --git a/packages/core/ios/RNSentryEvents.h b/packages/core/ios/RNSentryEvents.h index ee9f5e2088..6f1a5f0540 100644 --- a/packages/core/ios/RNSentryEvents.h +++ b/packages/core/ios/RNSentryEvents.h @@ -1,3 +1,4 @@ #import extern NSString *const RNSentryNewFrameEvent; +extern NSString *const RNSentryNativeLogEvent; diff --git a/packages/core/ios/RNSentryEvents.m b/packages/core/ios/RNSentryEvents.m index 13e3669cdd..bb3e842d73 100644 --- a/packages/core/ios/RNSentryEvents.m +++ b/packages/core/ios/RNSentryEvents.m @@ -1,3 +1,4 @@ #import "RNSentryEvents.h" NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame"; +NSString *const RNSentryNativeLogEvent = @"SentryNativeLog"; diff --git a/packages/core/src/js/NativeLogListener.ts b/packages/core/src/js/NativeLogListener.ts index f156c0a09e..8d07e7bdc2 100644 --- a/packages/core/src/js/NativeLogListener.ts +++ b/packages/core/src/js/NativeLogListener.ts @@ -1,31 +1,63 @@ import { debug } from '@sentry/core'; -import { Platform } from 'react-native'; +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; import type { NativeLogEntry } from './options'; +const NATIVE_LOG_EVENT_NAME = 'SentryNativeLog'; + +let nativeLogListener: ReturnType | 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. * - * Note: Native log forwarding is not yet implemented. This function is a placeholder - * for future implementation. Currently, native SDK logs appear in Xcode console (iOS) - * or Logcat (Android) when `debug: true` is set. - * - * @param _callback - The callback to invoke when a native log is received. + * @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 { +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; } - // Native log forwarding is not yet implemented. - // The infrastructure is in place for when native SDKs support log callbacks. - debug.log( - 'Native log forwarding is not yet implemented. Native SDK logs will appear in Xcode console (iOS) or Logcat (Android) when debug mode is enabled.', - ); + 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; + } - return undefined; + 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; + } } /** diff --git a/packages/core/test/NativeLogListener.test.ts b/packages/core/test/NativeLogListener.test.ts new file mode 100644 index 0000000000..923c08347a --- /dev/null +++ b/packages/core/test/NativeLogListener.test.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-console */ +import { defaultNativeLogHandler, setupNativeLogListener } from '../src/js/NativeLogListener'; +import type { NativeLogEntry } from '../src/js/options'; + +jest.mock('react-native', () => ({ + NativeModules: { + RNSentry: {}, + }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: jest.fn().mockReturnValue({ + remove: jest.fn(), + }), + })), + Platform: { + OS: 'ios', + }, +})); + +describe('NativeLogListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setupNativeLogListener', () => { + it('returns a cleanup function on success', () => { + const callback = jest.fn(); + const cleanup = setupNativeLogListener(callback); + + expect(cleanup).toBeDefined(); + expect(typeof cleanup).toBe('function'); + }); + + it('returns undefined when platform is not ios or android', () => { + jest.resetModules(); + jest.doMock('react-native', () => ({ + NativeModules: { + RNSentry: {}, + }, + NativeEventEmitter: jest.fn(), + Platform: { + OS: 'web', + }, + })); + + // Need to re-import after mocking + const { setupNativeLogListener: setupNativeLogListenerWeb } = jest.requireActual('../src/js/NativeLogListener'); + + const callback = jest.fn(); + const cleanup = setupNativeLogListenerWeb(callback); + + expect(cleanup).toBeUndefined(); + }); + }); + + describe('defaultNativeLogHandler', () => { + const originalConsole = { ...console }; + + beforeEach(() => { + console.log = jest.fn(); + console.info = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); + }); + + afterEach(() => { + console.log = originalConsole.log; + console.info = originalConsole.info; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + }); + + it('logs error level to console.error', () => { + const log: NativeLogEntry = { + level: 'error', + component: 'TestComponent', + message: 'Test error message', + }; + + defaultNativeLogHandler(log); + + expect(console.error).toHaveBeenCalledWith('[Sentry] [ERROR] [TestComponent] Test error message'); + }); + + it('logs fatal level to console.error', () => { + const log: NativeLogEntry = { + level: 'fatal', + component: 'TestComponent', + message: 'Test fatal message', + }; + + defaultNativeLogHandler(log); + + expect(console.error).toHaveBeenCalledWith('[Sentry] [FATAL] [TestComponent] Test fatal message'); + }); + + it('logs warning level to console.warn', () => { + const log: NativeLogEntry = { + level: 'warning', + component: 'TestComponent', + message: 'Test warning message', + }; + + defaultNativeLogHandler(log); + + expect(console.warn).toHaveBeenCalledWith('[Sentry] [WARNING] [TestComponent] Test warning message'); + }); + + it('logs info level to console.info', () => { + const log: NativeLogEntry = { + level: 'info', + component: 'TestComponent', + message: 'Test info message', + }; + + defaultNativeLogHandler(log); + + expect(console.info).toHaveBeenCalledWith('[Sentry] [INFO] [TestComponent] Test info message'); + }); + + it('logs debug level to console.log', () => { + const log: NativeLogEntry = { + level: 'debug', + component: 'TestComponent', + message: 'Test debug message', + }; + + defaultNativeLogHandler(log); + + expect(console.log).toHaveBeenCalledWith('[Sentry] [DEBUG] [TestComponent] Test debug message'); + }); + + it('logs unknown level to console.log', () => { + const log: NativeLogEntry = { + level: 'unknown', + component: 'TestComponent', + message: 'Test unknown message', + }; + + defaultNativeLogHandler(log); + + expect(console.log).toHaveBeenCalledWith('[Sentry] [UNKNOWN] [TestComponent] Test unknown message'); + }); + }); +}); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 38c7e96fcb..555a852257 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -48,9 +48,11 @@ Sentry.init({ // Replace the example DSN below with your own DSN: dsn: getDsn(), onNativeLog: ({ level, component, message }) => { - console.log(`ALWX [Sentry Native] [${level}] [${component}] ${message}`); + if (level === 'fatal') { + console.log(`ALWX [Sentry Native] [${level}] [${component}] ${message}`); + } }, - debug: true, + debug: false, environment: 'dev', beforeSend: (event: Sentry.ErrorEvent) => { logWithoutTracing('Event beforeSend:', event.event_id); From cdadc734f4916e312c27b422f9bc1209f14925f2 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Feb 2026 12:49:41 +0100 Subject: [PATCH 3/8] Log forwarder --- .../core/ios/RNSentryNativeLogsForwarder.h | 33 +++++ .../core/ios/RNSentryNativeLogsForwarder.m | 128 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/core/ios/RNSentryNativeLogsForwarder.h create mode 100644 packages/core/ios/RNSentryNativeLogsForwarder.m diff --git a/packages/core/ios/RNSentryNativeLogsForwarder.h b/packages/core/ios/RNSentryNativeLogsForwarder.h new file mode 100644 index 0000000000..b7bfb41b7d --- /dev/null +++ b/packages/core/ios/RNSentryNativeLogsForwarder.h @@ -0,0 +1,33 @@ +#import +#import + +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 diff --git a/packages/core/ios/RNSentryNativeLogsForwarder.m b/packages/core/ios/RNSentryNativeLogsForwarder.m new file mode 100644 index 0000000000..fe1f1036fc --- /dev/null +++ b/packages/core/ios/RNSentryNativeLogsForwarder.m @@ -0,0 +1,128 @@ +#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]; + } + }]; +} + +- (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; + } + + // 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, + }; + + [emitter 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 From ff955ad6dc2f0f41baed4b6af6cc3541a67876e4 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Feb 2026 14:49:07 +0100 Subject: [PATCH 4/8] Fixes --- packages/core/ios/RNSentryNativeLogsForwarder.m | 17 ++++++++++++++++- samples/react-native/src/App.tsx | 7 +++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/core/ios/RNSentryNativeLogsForwarder.m b/packages/core/ios/RNSentryNativeLogsForwarder.m index fe1f1036fc..3db73fc268 100644 --- a/packages/core/ios/RNSentryNativeLogsForwarder.m +++ b/packages/core/ios/RNSentryNativeLogsForwarder.m @@ -37,6 +37,10 @@ - (void)configureWithEventEmitter:(RCTEventEmitter *)emitter [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 @@ -54,6 +58,11 @@ - (void)forwardLogMessage:(NSString *)message 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" @@ -67,7 +76,13 @@ - (void)forwardLogMessage:(NSString *)message @"message" : cleanMessage, }; - [emitter sendEventWithName:RNSentryNativeLogEventName body:body]; + // 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 diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 555a852257..cb1c809612 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -48,11 +48,10 @@ Sentry.init({ // Replace the example DSN below with your own DSN: dsn: getDsn(), onNativeLog: ({ level, component, message }) => { - if (level === 'fatal') { - console.log(`ALWX [Sentry Native] [${level}] [${component}] ${message}`); - } + // Use logWithoutTracing to avoid feedback loops with Sentry's console integration + logWithoutTracing(`[Sentry Native] [${level.toUpperCase()}] [${component}] ${message}`); }, - debug: false, + debug: true, environment: 'dev', beforeSend: (event: Sentry.ErrorEvent) => { logWithoutTracing('Event beforeSend:', event.event_id); From 7f4aaadbea849c26205caea130a8540e20393ef4 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 10 Feb 2026 10:57:32 +0100 Subject: [PATCH 5/8] Fixes --- packages/core/ios/RNSentryNativeLogsForwarder.h | 13 ------------- packages/core/src/js/options.ts | 11 ----------- 2 files changed, 24 deletions(-) diff --git a/packages/core/ios/RNSentryNativeLogsForwarder.h b/packages/core/ios/RNSentryNativeLogsForwarder.h index b7bfb41b7d..8d6c162517 100644 --- a/packages/core/ios/RNSentryNativeLogsForwarder.h +++ b/packages/core/ios/RNSentryNativeLogsForwarder.h @@ -9,23 +9,10 @@ NS_ASSUME_NONNULL_BEGIN */ @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 diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 40afcc33a1..87ed3efbcb 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -372,19 +372,8 @@ export interface BaseReactNativeOptions { * Represents a log entry from the native SDK. */ export interface NativeLogEntry { - /** - * The log level (e.g., 'debug', 'info', 'warning', 'error', 'fatal'). - */ level: string; - - /** - * The component or module that emitted the log (e.g., 'Sentry', 'SentryHttpTransport'). - */ component: string; - - /** - * The log message. - */ message: string; } From 49948feaa808e49c172526aec0d5c1fe410eacaf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Feb 2026 11:02:45 +0100 Subject: [PATCH 6/8] Android changes --- .../java/io/sentry/react/RNSentryModuleImpl.java | 14 +++++++++++--- packages/core/ios/RNSentryNativeLogsForwarder.m | 5 +++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index a2229a354b..e71ffb343d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -49,7 +49,6 @@ import io.sentry.SentryReplayOptions; import io.sentry.SentryReplayOptions.SentryReplayQuality; import io.sentry.UncaughtExceptionHandlerIntegration; -import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; import io.sentry.android.core.AnrIntegration; import io.sentry.android.core.BuildConfig; @@ -107,7 +106,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 @@ -207,8 +207,16 @@ 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); + SentryAndroid.init( - getApplicationContext(), options -> getSentryAndroidOptions(options, rnOptions, logger)); + getApplicationContext(), + options -> { + // Use our custom logger that forwards to JS + options.setLogger(rnLogger); + getSentryAndroidOptions(options, rnOptions, logger); + }); promise.resolve(true); } diff --git a/packages/core/ios/RNSentryNativeLogsForwarder.m b/packages/core/ios/RNSentryNativeLogsForwarder.m index 3db73fc268..4db599041f 100644 --- a/packages/core/ios/RNSentryNativeLogsForwarder.m +++ b/packages/core/ios/RNSentryNativeLogsForwarder.m @@ -39,8 +39,9 @@ - (void)configureWithEventEmitter:(RCTEventEmitter *)emitter }]; // Send a test log to verify the forwarding works - [self forwardLogMessage:@"[Sentry] [info] [0] [RNSentryNativeLogsForwarder] Native log forwarding " - @"configured successfully"]; + [self forwardLogMessage: + @"[Sentry] [info] [0] [RNSentryNativeLogsForwarder] Native log forwarding " + @"configured successfully"]; } - (void)stopForwarding From 4fbc19d78a707d48ebf8e98e208bc1d3231c714f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Feb 2026 11:56:48 +0100 Subject: [PATCH 7/8] Fixes --- packages/core/src/js/NativeLogListener.ts | 19 +++----- packages/core/test/NativeLogListener.test.ts | 50 ++++++++------------ samples/react-native/src/App.tsx | 6 +-- 3 files changed, 30 insertions(+), 45 deletions(-) diff --git a/packages/core/src/js/NativeLogListener.ts b/packages/core/src/js/NativeLogListener.ts index 8d07e7bdc2..a795465ee2 100644 --- a/packages/core/src/js/NativeLogListener.ts +++ b/packages/core/src/js/NativeLogListener.ts @@ -61,30 +61,25 @@ export function setupNativeLogListener(callback: (log: NativeLogEntry) => void): } /** - * Default handler for native logs that logs to the JS console. + * Default handler for native logs that uses Sentry's debug logger. + * This avoids interference with captureConsoleIntegration which would + * otherwise capture these logs as breadcrumbs or events. */ export function defaultNativeLogHandler(log: NativeLogEntry): void { - const prefix = `[Sentry] [${log.level.toUpperCase()}] [${log.component}]`; - const message = `${prefix} ${log.message}`; + const message = `[Native] [${log.component}] ${log.message}`; switch (log.level.toLowerCase()) { case 'fatal': case 'error': - // eslint-disable-next-line no-console - console.error(message); + debug.error(message); break; case 'warning': - // eslint-disable-next-line no-console - console.warn(message); + debug.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); + debug.log(message); break; } } diff --git a/packages/core/test/NativeLogListener.test.ts b/packages/core/test/NativeLogListener.test.ts index 923c08347a..1edeb0cdd3 100644 --- a/packages/core/test/NativeLogListener.test.ts +++ b/packages/core/test/NativeLogListener.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-console */ +import { debug } from '@sentry/core'; import { defaultNativeLogHandler, setupNativeLogListener } from '../src/js/NativeLogListener'; import type { NativeLogEntry } from '../src/js/options'; @@ -16,6 +16,14 @@ jest.mock('react-native', () => ({ }, })); +jest.mock('@sentry/core', () => ({ + debug: { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + describe('NativeLogListener', () => { beforeEach(() => { jest.clearAllMocks(); @@ -53,23 +61,7 @@ describe('NativeLogListener', () => { }); describe('defaultNativeLogHandler', () => { - const originalConsole = { ...console }; - - beforeEach(() => { - console.log = jest.fn(); - console.info = jest.fn(); - console.warn = jest.fn(); - console.error = jest.fn(); - }); - - afterEach(() => { - console.log = originalConsole.log; - console.info = originalConsole.info; - console.warn = originalConsole.warn; - console.error = originalConsole.error; - }); - - it('logs error level to console.error', () => { + it('logs error level using debug.error', () => { const log: NativeLogEntry = { level: 'error', component: 'TestComponent', @@ -78,10 +70,10 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.error).toHaveBeenCalledWith('[Sentry] [ERROR] [TestComponent] Test error message'); + expect(debug.error).toHaveBeenCalledWith('[Native] [TestComponent] Test error message'); }); - it('logs fatal level to console.error', () => { + it('logs fatal level using debug.error', () => { const log: NativeLogEntry = { level: 'fatal', component: 'TestComponent', @@ -90,10 +82,10 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.error).toHaveBeenCalledWith('[Sentry] [FATAL] [TestComponent] Test fatal message'); + expect(debug.error).toHaveBeenCalledWith('[Native] [TestComponent] Test fatal message'); }); - it('logs warning level to console.warn', () => { + it('logs warning level using debug.warn', () => { const log: NativeLogEntry = { level: 'warning', component: 'TestComponent', @@ -102,10 +94,10 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.warn).toHaveBeenCalledWith('[Sentry] [WARNING] [TestComponent] Test warning message'); + expect(debug.warn).toHaveBeenCalledWith('[Native] [TestComponent] Test warning message'); }); - it('logs info level to console.info', () => { + it('logs info level using debug.log', () => { const log: NativeLogEntry = { level: 'info', component: 'TestComponent', @@ -114,10 +106,10 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.info).toHaveBeenCalledWith('[Sentry] [INFO] [TestComponent] Test info message'); + expect(debug.log).toHaveBeenCalledWith('[Native] [TestComponent] Test info message'); }); - it('logs debug level to console.log', () => { + it('logs debug level using debug.log', () => { const log: NativeLogEntry = { level: 'debug', component: 'TestComponent', @@ -126,10 +118,10 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.log).toHaveBeenCalledWith('[Sentry] [DEBUG] [TestComponent] Test debug message'); + expect(debug.log).toHaveBeenCalledWith('[Native] [TestComponent] Test debug message'); }); - it('logs unknown level to console.log', () => { + it('logs unknown level using debug.log', () => { const log: NativeLogEntry = { level: 'unknown', component: 'TestComponent', @@ -138,7 +130,7 @@ describe('NativeLogListener', () => { defaultNativeLogHandler(log); - expect(console.log).toHaveBeenCalledWith('[Sentry] [UNKNOWN] [TestComponent] Test unknown message'); + expect(debug.log).toHaveBeenCalledWith('[Native] [TestComponent] Test unknown message'); }); }); }); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index cb1c809612..2a4ad2961a 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -47,10 +47,8 @@ const BottomTabNavigator = createBottomTabNavigator(); Sentry.init({ // Replace the example DSN below with your own DSN: dsn: getDsn(), - onNativeLog: ({ level, component, message }) => { - // Use logWithoutTracing to avoid feedback loops with Sentry's console integration - logWithoutTracing(`[Sentry Native] [${level.toUpperCase()}] [${component}] ${message}`); - }, + // Native logs will be forwarded to JS and displayed via Sentry's debug logger + // when debug: true is set. No custom onNativeLog handler needed. debug: true, environment: 'dev', beforeSend: (event: Sentry.ErrorEvent) => { From 313b4e6ff8e4043c7a644c9fa2d93cfb26dd0aef Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Feb 2026 11:57:59 +0100 Subject: [PATCH 8/8] Missing Android logger --- .../java/io/sentry/react/RNSentryLogger.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryLogger.java diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryLogger.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryLogger.java new file mode 100644 index 0000000000..041825af25 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryLogger.java @@ -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 = "Sentry"; + private static final String EVENT_NAME = "SentryNativeLog"; + + private final AndroidLogger androidLogger; + private WeakReference reactContextRef; + + 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()); + } + } +}