diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fde51f31..26feed3877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Added `logsOrigin` to Sentry Options ([#5354](https://github.com/getsentry/sentry-react-native/pull/5354)) + - You can now choose which logs are captured: 'native' for logs from native code only, 'js' for logs from the JavaScript layer only, or 'all' for both layers. + - Takes effect only if `enableLogs` is `true` and defaults to 'all', preserving previous behavior. + ## 7.6.0 + ### Fixes - Android SDK not being disabled when `options.enabled` is set to `false` ([#5334](https://github.com/getsentry/sentry-react-native/pull/5334)) diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index b263689735..42f3659c2e 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -64,6 +64,15 @@ export class ReactNativeClient extends Client { // We default this to true, as it is the safer scenario options.parentSpanIsAlwaysRootSpan = options.parentSpanIsAlwaysRootSpan === undefined ? true : options.parentSpanIsAlwaysRootSpan; + + // enableLogs must be disabled before calling super() to avoid logs being captured. + // This makes a copy of the user defined value, so we can restore it later for the native usaege. + const originalEnableLogs = options.enableLogs; + if (options.enableLogs && options.logsOrigin === 'native') { + debug.log('disabling Sentry logs on JavaScript due to rule set by logsOrigin'); + options.enableLogs = false; + } + super(options); this._outcomesBuffer = []; @@ -87,6 +96,9 @@ export class ReactNativeClient extends Client { }, DEFAULT_FLUSH_INTERVAL); }); } + + // Restore original settings for enabling Native options. + options.enableLogs = originalEnableLogs; } /** diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 06255435ab..19009ab26b 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -85,7 +85,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (options.enableNative) { integrations.push(deviceContextIntegration()); integrations.push(modulesLoaderIntegration()); - if (options.enableLogs) { + if (options.enableLogs && options.logsOrigin !== 'native') { integrations.push(logEnricherIntegration()); integrations.push(consoleLoggingIntegration()); } diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index feadc58ad0..cfcd6a0c3f 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -316,6 +316,16 @@ export interface BaseReactNativeOptions { * @default false */ propagateTraceparent?: boolean; + + /** + * Controls which log origin is captured when `enableLogs` is set to true. + * 'all' will log all origins. + * 'js' will capture only JavaScript logs. + * 'native' will capture only native logs. + * + * @default 'all' + */ + logsOrigin?: 'all' | 'js' | 'native'; } export type SentryReplayQuality = 'low' | 'medium' | 'high'; diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 716aac0a1c..d84e61a632 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -228,6 +228,10 @@ export const NATIVE: SentryNativeWrapper = { enableNative: true, autoInitializeNativeSdk: true, ...originalOptions, + // Keeps original behavior of enableLogs by not setting it when not defined. + ...(originalOptions.enableLogs !== undefined + ? { enableLogs: originalOptions.enableLogs && originalOptions.logsOrigin !== 'js' } + : {}), }; if (!options.enableNative) { @@ -273,8 +277,15 @@ export const NATIVE: SentryNativeWrapper = { // filter out all the options that would crash native. /* eslint-disable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ - const { beforeSend, beforeBreadcrumb, beforeSendTransaction, integrations, ignoreErrors, ...filteredOptions } = - options; + const { + beforeSend, + beforeBreadcrumb, + beforeSendTransaction, + integrations, + ignoreErrors, + logsOrigin, + ...filteredOptions + } = options; /* eslint-enable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ const nativeIsReady = await RNSentry.initNativeSdk(filteredOptions); diff --git a/packages/core/test/client.test.ts b/packages/core/test/client.test.ts index f495b0476d..39385826cf 100644 --- a/packages/core/test/client.test.ts +++ b/packages/core/test/client.test.ts @@ -7,6 +7,7 @@ import type { Transport, TransportMakeRequestResponse, } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import { addAutoIpAddressToSession, addAutoIpAddressToUser, @@ -852,6 +853,50 @@ describe('Tests ReactNativeClient', () => { ); }); }); + + describe('logger initialization', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + test('does not flush logs when enableLogs is false', () => { + jest.useFakeTimers(); + const flushLogsSpy = jest.spyOn(SentryCore, '_INTERNAL_flushLogsBuffer').mockImplementation(jest.fn()); + + const { client } = createClientWithSpy({ enableLogs: false }); + + client.emit('afterCaptureLog', { message: 'test', attributes: {} } as unknown); + jest.advanceTimersByTime(5000); + + expect(flushLogsSpy).not.toHaveBeenCalled(); + }); + + test('does not flush logs when logsOrigin is native', () => { + jest.useFakeTimers(); + const flushLogsSpy = jest.spyOn(SentryCore, '_INTERNAL_flushLogsBuffer').mockImplementation(jest.fn()); + + const { client } = createClientWithSpy({ enableLogs: true, logsOrigin: 'native' }); + + client.emit('afterCaptureLog', { message: 'test', attributes: {} } as unknown); + jest.advanceTimersByTime(5000); + + expect(flushLogsSpy).not.toHaveBeenCalled(); + }); + + it.each([['all' as const], ['js' as const]])('flushes logs when logsOrigin is %s', logOrlogsOriginigin => { + jest.useFakeTimers(); + const flushLogsSpy = jest.spyOn(SentryCore, '_INTERNAL_flushLogsBuffer').mockImplementation(jest.fn()); + + const { client } = createClientWithSpy({ enableLogs: true, logsOrigin: logOrlogsOriginigin }); + + client.emit('afterCaptureLog', { message: 'test', attributes: {} } as unknown); + jest.advanceTimersByTime(5000); + + expect(flushLogsSpy).toHaveBeenCalledTimes(1); + expect(flushLogsSpy).toHaveBeenLastCalledWith(client); + }); + }); }); function mockedOptions(options: Partial): ReactNativeClientOptions { diff --git a/packages/core/test/integrations/defaultLogs.test.ts b/packages/core/test/integrations/defaultLogs.test.ts new file mode 100644 index 0000000000..ba460e266a --- /dev/null +++ b/packages/core/test/integrations/defaultLogs.test.ts @@ -0,0 +1,78 @@ +import { consoleLoggingIntegration } from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { getDefaultIntegrations } from '../../src/js/integrations/default'; +import { logEnricherIntegration } from '../../src/js/integrations/logEnricherIntegration'; +import type { ReactNativeClientOptions } from '../../src/js/options'; +import { notWeb } from '../../src/js/utils/environment'; + +jest.mock('../../src/js/utils/environment', () => { + const actual = jest.requireActual('../../src/js/utils/environment'); + return { + ...actual, + notWeb: jest.fn(() => true), + }; +}); + +const logEnricherIntegrationName = logEnricherIntegration().name; +const consoleLoggingIntegrationName = consoleLoggingIntegration().name; + +describe('getDefaultIntegrations - logging integrations', () => { + beforeEach(() => { + (notWeb as jest.Mock).mockReturnValue(true); + }); + + const createOptions = (overrides: Partial): ReactNativeClientOptions => { + return { + dsn: 'https://example.com/1', + enableNative: true, + ...overrides, + } as ReactNativeClientOptions; + }; + + const getIntegrationNames = (options: ReactNativeClientOptions): string[] => { + const integrations = getDefaultIntegrations(options); + return integrations.map((integration: Integration) => integration.name); + }; + + it('does not add logging integrations when enableLogs is falsy', () => { + const names = getIntegrationNames(createOptions({ enableLogs: false })); + + expect(names).not.toContain(logEnricherIntegrationName); + expect(names).not.toContain(consoleLoggingIntegrationName); + }); + + it('adds logging integrations when enableLogs is true and logsOrigin is not native', () => { + const names = getIntegrationNames(createOptions({ enableLogs: true })); + + expect(names).toContain(logEnricherIntegrationName); + expect(names).toContain(consoleLoggingIntegrationName); + }); + + it('does not add logging integrations when logsOrigin is native', () => { + const names = getIntegrationNames( + createOptions({ + enableLogs: true, + logsOrigin: 'native' as unknown as ReactNativeClientOptions['logsOrigin'], + }), + ); + + expect(names).not.toContain(logEnricherIntegrationName); + expect(names).not.toContain(consoleLoggingIntegrationName); + }); + + it.each([ + ['all', true], + ['js', true], + ['native', false], + ])('handles logsOrigin %s correctly', (logsOrigin, shouldInclude) => { + const names = getIntegrationNames( + createOptions({ + enableLogs: true, + logsOrigin: logsOrigin as unknown as ReactNativeClientOptions['logsOrigin'], + }), + ); + + expect(names.includes(logEnricherIntegrationName)).toBe(shouldInclude); + expect(names.includes(consoleLoggingIntegrationName)).toBe(shouldInclude); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index cf7f7cc818..e15f98e7df 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -312,6 +312,44 @@ describe('Tests Native Wrapper', () => { expect(initParameter.ignoreErrorsStr).toBeUndefined(); expect(initParameter.ignoreErrorsRegex).toBeUndefined(); }); + + test('does not set enableLogs when option is undefined', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.enableLogs).toBeUndefined(); + }); + + it.each([ + ['without logsOrigin', undefined, true], + ['with logsOrigin set to Native', 'native' as const, true], + ['with logsOrigin set to all', 'all' as const, true], + ['with logsOrigin set to JS', 'js' as const, false], + ])('handles enableLogs %s', async (_description, logsOrigin, expectedEnableLogs) => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + enableLogs: true, + ...(logsOrigin !== undefined ? { logsOrigin } : {}), + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.enableLogs).toBe(expectedEnableLogs); + expect(initParameter.logsOrigin).toBeUndefined(); + }); }); describe('sendEnvelope', () => { diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index c9a24aea6f..8974c90206 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -67,6 +67,7 @@ Sentry.init({ _experiments: { enableUnhandledCPPExceptionsV2: true, }, + logsOrigin: 'all', enableLogs: true, beforeSendLog: (log) => { return log;