diff --git a/CHANGELOG.md b/CHANGELOG.md index e88e3d7e46..dc37285d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Adds metrics ([#5402](https://github.com/getsentry/sentry-react-native/pull/5402)) + ### Dependencies - Bump Android SDK from v8.27.0 to v8.27.1 ([#5404](https://github.com/getsentry/sentry-react-native/pull/5404)) diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 4a475a33c1..d43df01c5f 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -46,6 +46,7 @@ export { setCurrentClient, addEventProcessor, lastEventId, + metrics, } from '@sentry/core'; export { diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index d84e61a632..3ac01e6498 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -281,6 +281,7 @@ export const NATIVE: SentryNativeWrapper = { beforeSend, beforeBreadcrumb, beforeSendTransaction, + beforeSendMetric, integrations, ignoreErrors, logsOrigin, diff --git a/packages/core/test/metrics.test.ts b/packages/core/test/metrics.test.ts new file mode 100644 index 0000000000..e1e7bc1de1 --- /dev/null +++ b/packages/core/test/metrics.test.ts @@ -0,0 +1,180 @@ +import { getClient, metrics, setCurrentClient } from '@sentry/core'; +import { ReactNativeClient } from '../src/js'; +import { getDefaultTestClientOptions } from './mocks/client'; +import { NATIVE } from './mockWrapper'; + +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); + +const EXAMPLE_DSN = 'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053'; + +describe('Metrics', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => true); + }); + + afterEach(() => { + const client = getClient(); + client?.close(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('beforeSendMetric', () => { + it('is called when enableMetrics is true and a metric is sent', async () => { + const beforeSendMetric = jest.fn(metric => metric); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: true, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric + metrics.count('test_metric', 1); + + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).toHaveBeenCalled(); + }); + + it('is not called when enableMetrics is false', async () => { + const beforeSendMetric = jest.fn(metric => metric); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: false, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric + metrics.count('test_metric', 1); + + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).not.toHaveBeenCalled(); + }); + + it('is called when enableMetrics is undefined (metrics are enabled by default)', async () => { + const beforeSendMetric = jest.fn(metric => metric); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric + metrics.count('test_metric', 1); + + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).toHaveBeenCalled(); + }); + + it('allows beforeSendMetric to modify metrics when enableMetrics is true', async () => { + const beforeSendMetric = jest.fn(metric => { + // Modify the metric + return { ...metric, name: 'modified_metric' }; + }); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: true, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric + metrics.count('test_metric', 1); + + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).toHaveBeenCalled(); + const modifiedMetric = beforeSendMetric.mock.results[0]?.value; + expect(modifiedMetric).toBeDefined(); + expect(modifiedMetric.name).toBe('modified_metric'); + }); + + it('allows beforeSendMetric to drop metrics by returning null', async () => { + const beforeSendMetric = jest.fn(() => null); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: true, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric + metrics.count('test_metric', 1); + + // Advance timers + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).toHaveBeenCalled(); + expect(beforeSendMetric.mock.results[0]?.value).toBeNull(); + }); + }); + + describe('metrics API', () => { + it('metrics.count works when enableMetrics is true', () => { + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: true, + }), + }); + + setCurrentClient(client); + client.init(); + + expect(() => { + metrics.count('test_metric', 1); + }).not.toThrow(); + }); + + it('metrics can be sent with tags', async () => { + const beforeSendMetric = jest.fn(metric => metric); + + const client = new ReactNativeClient({ + ...getDefaultTestClientOptions({ + dsn: EXAMPLE_DSN, + enableMetrics: true, + beforeSendMetric, + }), + }); + + setCurrentClient(client); + client.init(); + + // Send a metric with tags + metrics.count('test_metric', 1, { + attributes: { environment: 'test' }, + }); + + jest.advanceTimersByTime(10000); + expect(beforeSendMetric).toHaveBeenCalled(); + const sentMetric = beforeSendMetric.mock.calls[0]?.[0]; + expect(sentMetric).toBeDefined(); + }); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index a27b13bdc8..8b2ead16c9 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -194,6 +194,24 @@ describe('Tests Native Wrapper', () => { expect(NATIVE.enableNative).toBe(true); }); + test('filter beforeSendMetric when initializing Native SDK', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + beforeSendMetric: jest.fn(), + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + // @ts-expect-error mock value + const initParameter = RNSentry.initNativeSdk.mock.calls[0][0]; + expect(initParameter).not.toHaveProperty('beforeSendMetric'); + expect(NATIVE.enableNative).toBe(true); + }); + test('filter integrations when initializing Native SDK', async () => { await NATIVE.initNativeSdk({ dsn: 'test', diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 68745d776c..0aa94be894 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -118,6 +118,30 @@ export default function TabOneScreen() { }} /> + +