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
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
39 changes: 39 additions & 0 deletions packages/core/src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
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
144 changes: 144 additions & 0 deletions packages/core/test/NativeLogListener.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading