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() {
}}
/>
+
+
+
+
+
+
diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx
index 7e84d27ef6..121a5284a0 100644
--- a/samples/expo/app/_layout.tsx
+++ b/samples/expo/app/_layout.tsx
@@ -40,6 +40,11 @@ Sentry.init({
console.log('Transaction beforeSend:', event.event_id);
return event;
},
+ beforeSendMetric: (metric: Sentry.Metric) => {
+ console.log('Metric beforeSend:', metric.name, metric.value);
+ return metric;
+ },
+ enableMetrics: true,
// This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted.
onReady: ({ didCallNativeInit }) => {
console.log('onReady called with didCallNativeInit:', didCallNativeInit);
diff --git a/samples/react-native-macos/src/App.tsx b/samples/react-native-macos/src/App.tsx
index 817d538efc..a12e51d5f7 100644
--- a/samples/react-native-macos/src/App.tsx
+++ b/samples/react-native-macos/src/App.tsx
@@ -54,6 +54,10 @@ Sentry.init({
logWithoutTracing('Transaction beforeSend:', event.event_id);
return event;
},
+ beforeSendMetric(metric: Sentry.Metric) {
+ logWithoutTracing('Metric beforeSend:', metric.name, metric.value);
+ return metric;
+ },
// This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted.
onReady: ({ didCallNativeInit }) => {
logWithoutTracing(
diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx
index 8974c90206..b7f4db4ce7 100644
--- a/samples/react-native/src/App.tsx
+++ b/samples/react-native/src/App.tsx
@@ -57,6 +57,10 @@ Sentry.init({
logWithoutTracing('Transaction beforeSend:', event.event_id);
return event;
},
+ beforeSendMetric(metric: Sentry.Metric) {
+ logWithoutTracing('Metric beforeSend:', metric.name, metric.value);
+ return metric;
+ },
// This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted.
onReady: ({ didCallNativeInit }) => {
logWithoutTracing(