From 57c34f658aeb61987843218b8cf81de552889849 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 20 Nov 2025 16:54:28 +0100 Subject: [PATCH 1/4] feat(replay): Add beforeErrorSampling callback to mobileReplayIntegration --- packages/core/src/js/replay/mobilereplay.ts | 29 ++- .../core/test/replay/mobilereplay.test.ts | 234 ++++++++++++++++++ 2 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/replay/mobilereplay.test.ts diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 1af6aa367a..cfbcefc2ba 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -1,4 +1,4 @@ -import type { Client, DynamicSamplingContext, Event, Integration } from '@sentry/core'; +import type { Client, DynamicSamplingContext, Event, EventHint, Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { isHardCrash } from '../misc'; import { hasHooks } from '../utils/clientutils'; @@ -93,9 +93,20 @@ export interface MobileReplayOptions { * @platform android */ screenshotStrategy?: ScreenshotStrategy; + + /** + * Callback to determine if a replay should be captured for a specific error. + * When this callback returns `false`, no replay will be captured for the error. + * This callback is only called when an error occurs and `replaysOnErrorSampleRate` is set. + * + * @param event The error event + * @param hint Additional event information + * @returns `false` to skip capturing a replay for this error, `true` or `undefined` to proceed with sampling + */ + beforeErrorSampling?: (event: Event, hint: EventHint) => boolean; } -const defaultOptions: Required = { +const defaultOptions: MobileReplayOptions = { maskAllText: true, maskAllImages: true, maskAllVectors: true, @@ -105,7 +116,7 @@ const defaultOptions: Required = { screenshotStrategy: 'pixelCopy', }; -function mergeOptions(initOptions: Partial): Required { +function mergeOptions(initOptions: Partial): MobileReplayOptions { const merged = { ...defaultOptions, ...initOptions, @@ -119,7 +130,7 @@ function mergeOptions(initOptions: Partial): Required; + options: MobileReplayOptions; getReplayId: () => string | null; }; @@ -155,13 +166,21 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau const options = mergeOptions(initOptions); - async function processEvent(event: Event): Promise { + async function processEvent(event: Event, hint: EventHint): Promise { const hasException = event.exception?.values && event.exception.values.length > 0; if (!hasException) { // Event is not an error, will not capture replay return event; } + // Check if beforeErrorSampling callback filters out this error + if (initOptions.beforeErrorSampling && initOptions.beforeErrorSampling(event, hint) === false) { + debug.log( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} skipped due to beforeErrorSampling for event ${event.event_id}.`, + ); + return event; + } + const replayId = await NATIVE.captureReplay(isHardCrash(event)); if (!replayId) { const recordingReplayId = NATIVE.getCurrentReplayId(); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts new file mode 100644 index 0000000000..e90a04d760 --- /dev/null +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { Event, EventHint } from '@sentry/core'; +import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay'; +import * as environment from '../../src/js/utils/environment'; +import { NATIVE } from '../../src/js/wrapper'; + +jest.mock('../../src/js/wrapper'); + +describe('Mobile Replay Integration', () => { + let mockCaptureReplay: jest.MockedFunction; + let mockGetCurrentReplayId: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + jest.spyOn(environment, 'notMobileOs').mockReturnValue(false); + mockCaptureReplay = NATIVE.captureReplay as jest.MockedFunction; + mockGetCurrentReplayId = NATIVE.getCurrentReplayId as jest.MockedFunction; + mockCaptureReplay.mockResolvedValue('test-replay-id'); + mockGetCurrentReplayId.mockReturnValue('test-replay-id'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('beforeErrorSampling', () => { + it('should capture replay when beforeErrorSampling returns true', async () => { + const beforeErrorSampling = jest.fn().mockReturnValue(true); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint); + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + + it('should not capture replay when beforeErrorSampling returns false', async () => { + const beforeErrorSampling = jest.fn().mockReturnValue(false); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint); + expect(mockCaptureReplay).not.toHaveBeenCalled(); + }); + + it('should capture replay when beforeErrorSampling returns undefined', async () => { + const beforeErrorSampling = jest.fn().mockReturnValue(undefined); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint); + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + + it('should capture replay when beforeErrorSampling is not provided', async () => { + const integration = mobileReplayIntegration(); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + + it('should filter out specific error types using beforeErrorSampling', async () => { + const beforeErrorSampling = jest.fn((event: Event) => { + // Only capture replays for unhandled errors (not manually captured) + const isHandled = event.exception?.values?.some(exception => exception.mechanism?.handled === true); + return !isHandled; + }); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + // Test with handled error + const handledEvent: Event = { + event_id: 'handled-event-id', + exception: { + values: [ + { + type: 'Error', + value: 'Handled error', + mechanism: { handled: true, type: 'generic' }, + }, + ], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(handledEvent, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(handledEvent, hint); + expect(mockCaptureReplay).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + // Test with unhandled error + const unhandledEvent: Event = { + event_id: 'unhandled-event-id', + exception: { + values: [ + { + type: 'Error', + value: 'Unhandled error', + mechanism: { handled: false, type: 'generic' }, + }, + ], + }, + }; + + if (integration.processEvent) { + await integration.processEvent(unhandledEvent, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(unhandledEvent, hint); + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + + it('should not call beforeErrorSampling for non-error events', async () => { + const beforeErrorSampling = jest.fn().mockReturnValue(false); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + message: 'Test message without exception', + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(beforeErrorSampling).not.toHaveBeenCalled(); + expect(mockCaptureReplay).not.toHaveBeenCalled(); + }); + }); + + describe('processEvent', () => { + it('should not process events without exceptions', async () => { + const integration = mobileReplayIntegration(); + + const event: Event = { + event_id: 'test-event-id', + message: 'Test message', + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(mockCaptureReplay).not.toHaveBeenCalled(); + }); + + it('should process events with exceptions', async () => { + const integration = mobileReplayIntegration(); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + }); + + describe('platform checks', () => { + it('should return noop integration in Expo Go', () => { + jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); + + const integration = mobileReplayIntegration(); + + expect(integration.name).toBe('MobileReplay'); + expect(integration.processEvent).toBeUndefined(); + }); + + it('should return noop integration on non-mobile platforms', () => { + jest.spyOn(environment, 'notMobileOs').mockReturnValue(true); + + const integration = mobileReplayIntegration(); + + expect(integration.name).toBe('MobileReplay'); + expect(integration.processEvent).toBeUndefined(); + }); + }); +}); From 16326591e320c33b9a8e5878d717208a41f6f4be Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 20 Nov 2025 16:58:15 +0100 Subject: [PATCH 2/4] Adds changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9148147679..f9173913b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 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. +- Add `beforeErrorSampling` callback to `mobileReplayIntegration` ([#5393](https://github.com/getsentry/sentry-react-native/pull/5393)) ### Fixes From 9eac259493b23c8a69fe066c12afe506557a10d7 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 20 Nov 2025 17:21:04 +0100 Subject: [PATCH 3/4] Add exception handling --- packages/core/src/js/replay/mobilereplay.ts | 20 +++++-- .../core/test/replay/mobilereplay.test.ts | 58 +++++++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index cfbcefc2ba..6d4927c3b9 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -174,11 +174,21 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } // Check if beforeErrorSampling callback filters out this error - if (initOptions.beforeErrorSampling && initOptions.beforeErrorSampling(event, hint) === false) { - debug.log( - `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} skipped due to beforeErrorSampling for event ${event.event_id}.`, - ); - return event; + if (initOptions.beforeErrorSampling) { + try { + if (initOptions.beforeErrorSampling(event, hint) === false) { + debug.log( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} skipped due to beforeErrorSampling for event ${event.event_id}.`, + ); + return event; + } + } catch (error) { + debug.error( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} beforeErrorSampling callback threw an error, proceeding with replay capture`, + error, + ); + // Continue with replay capture if callback throws + } } const replayId = await NATIVE.captureReplay(isHardCrash(event)); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index e90a04d760..5db21ef0e8 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -26,7 +26,7 @@ describe('Mobile Replay Integration', () => { describe('beforeErrorSampling', () => { it('should capture replay when beforeErrorSampling returns true', async () => { - const beforeErrorSampling = jest.fn().mockReturnValue(true); + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(true); const integration = mobileReplayIntegration({ beforeErrorSampling }); const event: Event = { @@ -46,7 +46,7 @@ describe('Mobile Replay Integration', () => { }); it('should not capture replay when beforeErrorSampling returns false', async () => { - const beforeErrorSampling = jest.fn().mockReturnValue(false); + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(false); const integration = mobileReplayIntegration({ beforeErrorSampling }); const event: Event = { @@ -66,7 +66,9 @@ describe('Mobile Replay Integration', () => { }); it('should capture replay when beforeErrorSampling returns undefined', async () => { - const beforeErrorSampling = jest.fn().mockReturnValue(undefined); + const beforeErrorSampling = jest + .fn<(event: Event, hint: EventHint) => boolean>() + .mockReturnValue(undefined as unknown as boolean); const integration = mobileReplayIntegration({ beforeErrorSampling }); const event: Event = { @@ -104,7 +106,7 @@ describe('Mobile Replay Integration', () => { }); it('should filter out specific error types using beforeErrorSampling', async () => { - const beforeErrorSampling = jest.fn((event: Event) => { + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>((event: Event) => { // Only capture replays for unhandled errors (not manually captured) const isHandled = event.exception?.values?.some(exception => exception.mechanism?.handled === true); return !isHandled; @@ -158,7 +160,7 @@ describe('Mobile Replay Integration', () => { }); it('should not call beforeErrorSampling for non-error events', async () => { - const beforeErrorSampling = jest.fn().mockReturnValue(false); + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(false); const integration = mobileReplayIntegration({ beforeErrorSampling }); const event: Event = { @@ -174,6 +176,52 @@ describe('Mobile Replay Integration', () => { expect(beforeErrorSampling).not.toHaveBeenCalled(); expect(mockCaptureReplay).not.toHaveBeenCalled(); }); + + it('should handle exceptions thrown by beforeErrorSampling and proceed with capture', async () => { + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockImplementation(() => { + throw new Error('Callback error'); + }); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + if (integration.processEvent) { + await integration.processEvent(event, hint); + } + + expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint); + // Should proceed with replay capture despite callback error + expect(mockCaptureReplay).toHaveBeenCalled(); + }); + + it('should not crash the event pipeline when beforeErrorSampling throws', async () => { + const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockImplementation(() => { + throw new TypeError('Unexpected callback error'); + }); + const integration = mobileReplayIntegration({ beforeErrorSampling }); + + const event: Event = { + event_id: 'test-event-id', + exception: { + values: [{ type: 'Error', value: 'Test error' }], + }, + }; + const hint: EventHint = {}; + + // Should not throw + if (integration.processEvent) { + await expect(integration.processEvent(event, hint)).resolves.toBeDefined(); + } + + expect(beforeErrorSampling).toHaveBeenCalled(); + expect(mockCaptureReplay).toHaveBeenCalled(); + }); }); describe('processEvent', () => { From 3278829065d3e6c6942b9357d1f94544de403cb4 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 24 Nov 2025 09:15:13 +0100 Subject: [PATCH 4/4] Update log message Co-authored-by: LucasZF --- packages/core/src/js/replay/mobilereplay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 6d4927c3b9..ccc9f3b757 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -178,7 +178,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau try { if (initOptions.beforeErrorSampling(event, hint) === false) { debug.log( - `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} skipped due to beforeErrorSampling for event ${event.event_id}.`, + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sent; beforeErrorSampling conditions not met for event ${event.event_id}.`, ); return event; }