Skip to content

Commit 9b50d32

Browse files
feat(replay): Add beforeErrorSampling callback to mobileReplayIntegration (#5393)
* feat(replay): Add beforeErrorSampling callback to mobileReplayIntegration * Adds changelog * Add exception handling * Update log message Co-authored-by: LucasZF <[email protected]> --------- Co-authored-by: LucasZF <[email protected]>
1 parent c198191 commit 9b50d32

File tree

3 files changed

+317
-5
lines changed

3 files changed

+317
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Added `logsOrigin` to Sentry Options ([#5354](https://github.com/getsentry/sentry-react-native/pull/5354))
1414
- 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.
1515
- Takes effect only if `enableLogs` is `true` and defaults to 'all', preserving previous behavior.
16+
- Add `beforeErrorSampling` callback to `mobileReplayIntegration` ([#5393](https://github.com/getsentry/sentry-react-native/pull/5393))
1617

1718
### Fixes
1819

packages/core/src/js/replay/mobilereplay.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Client, DynamicSamplingContext, Event, Integration } from '@sentry/core';
1+
import type { Client, DynamicSamplingContext, Event, EventHint, Integration } from '@sentry/core';
22
import { debug } from '@sentry/core';
33
import { isHardCrash } from '../misc';
44
import { hasHooks } from '../utils/clientutils';
@@ -93,9 +93,20 @@ export interface MobileReplayOptions {
9393
* @platform android
9494
*/
9595
screenshotStrategy?: ScreenshotStrategy;
96+
97+
/**
98+
* Callback to determine if a replay should be captured for a specific error.
99+
* When this callback returns `false`, no replay will be captured for the error.
100+
* This callback is only called when an error occurs and `replaysOnErrorSampleRate` is set.
101+
*
102+
* @param event The error event
103+
* @param hint Additional event information
104+
* @returns `false` to skip capturing a replay for this error, `true` or `undefined` to proceed with sampling
105+
*/
106+
beforeErrorSampling?: (event: Event, hint: EventHint) => boolean;
96107
}
97108

98-
const defaultOptions: Required<MobileReplayOptions> = {
109+
const defaultOptions: MobileReplayOptions = {
99110
maskAllText: true,
100111
maskAllImages: true,
101112
maskAllVectors: true,
@@ -105,7 +116,7 @@ const defaultOptions: Required<MobileReplayOptions> = {
105116
screenshotStrategy: 'pixelCopy',
106117
};
107118

108-
function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<MobileReplayOptions> {
119+
function mergeOptions(initOptions: Partial<MobileReplayOptions>): MobileReplayOptions {
109120
const merged = {
110121
...defaultOptions,
111122
...initOptions,
@@ -119,7 +130,7 @@ function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<Mobil
119130
}
120131

121132
type MobileReplayIntegration = Integration & {
122-
options: Required<MobileReplayOptions>;
133+
options: MobileReplayOptions;
123134
getReplayId: () => string | null;
124135
};
125136

@@ -155,13 +166,31 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
155166

156167
const options = mergeOptions(initOptions);
157168

158-
async function processEvent(event: Event): Promise<Event> {
169+
async function processEvent(event: Event, hint: EventHint): Promise<Event> {
159170
const hasException = event.exception?.values && event.exception.values.length > 0;
160171
if (!hasException) {
161172
// Event is not an error, will not capture replay
162173
return event;
163174
}
164175

176+
// Check if beforeErrorSampling callback filters out this error
177+
if (initOptions.beforeErrorSampling) {
178+
try {
179+
if (initOptions.beforeErrorSampling(event, hint) === false) {
180+
debug.log(
181+
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sent; beforeErrorSampling conditions not met for event ${event.event_id}.`,
182+
);
183+
return event;
184+
}
185+
} catch (error) {
186+
debug.error(
187+
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} beforeErrorSampling callback threw an error, proceeding with replay capture`,
188+
error,
189+
);
190+
// Continue with replay capture if callback throws
191+
}
192+
}
193+
165194
const replayId = await NATIVE.captureReplay(isHardCrash(event));
166195
if (!replayId) {
167196
const recordingReplayId = NATIVE.getCurrentReplayId();
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2+
import type { Event, EventHint } from '@sentry/core';
3+
import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay';
4+
import * as environment from '../../src/js/utils/environment';
5+
import { NATIVE } from '../../src/js/wrapper';
6+
7+
jest.mock('../../src/js/wrapper');
8+
9+
describe('Mobile Replay Integration', () => {
10+
let mockCaptureReplay: jest.MockedFunction<typeof NATIVE.captureReplay>;
11+
let mockGetCurrentReplayId: jest.MockedFunction<typeof NATIVE.getCurrentReplayId>;
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
16+
jest.spyOn(environment, 'notMobileOs').mockReturnValue(false);
17+
mockCaptureReplay = NATIVE.captureReplay as jest.MockedFunction<typeof NATIVE.captureReplay>;
18+
mockGetCurrentReplayId = NATIVE.getCurrentReplayId as jest.MockedFunction<typeof NATIVE.getCurrentReplayId>;
19+
mockCaptureReplay.mockResolvedValue('test-replay-id');
20+
mockGetCurrentReplayId.mockReturnValue('test-replay-id');
21+
});
22+
23+
afterEach(() => {
24+
jest.restoreAllMocks();
25+
});
26+
27+
describe('beforeErrorSampling', () => {
28+
it('should capture replay when beforeErrorSampling returns true', async () => {
29+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(true);
30+
const integration = mobileReplayIntegration({ beforeErrorSampling });
31+
32+
const event: Event = {
33+
event_id: 'test-event-id',
34+
exception: {
35+
values: [{ type: 'Error', value: 'Test error' }],
36+
},
37+
};
38+
const hint: EventHint = {};
39+
40+
if (integration.processEvent) {
41+
await integration.processEvent(event, hint);
42+
}
43+
44+
expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint);
45+
expect(mockCaptureReplay).toHaveBeenCalled();
46+
});
47+
48+
it('should not capture replay when beforeErrorSampling returns false', async () => {
49+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(false);
50+
const integration = mobileReplayIntegration({ beforeErrorSampling });
51+
52+
const event: Event = {
53+
event_id: 'test-event-id',
54+
exception: {
55+
values: [{ type: 'Error', value: 'Test error' }],
56+
},
57+
};
58+
const hint: EventHint = {};
59+
60+
if (integration.processEvent) {
61+
await integration.processEvent(event, hint);
62+
}
63+
64+
expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint);
65+
expect(mockCaptureReplay).not.toHaveBeenCalled();
66+
});
67+
68+
it('should capture replay when beforeErrorSampling returns undefined', async () => {
69+
const beforeErrorSampling = jest
70+
.fn<(event: Event, hint: EventHint) => boolean>()
71+
.mockReturnValue(undefined as unknown as boolean);
72+
const integration = mobileReplayIntegration({ beforeErrorSampling });
73+
74+
const event: Event = {
75+
event_id: 'test-event-id',
76+
exception: {
77+
values: [{ type: 'Error', value: 'Test error' }],
78+
},
79+
};
80+
const hint: EventHint = {};
81+
82+
if (integration.processEvent) {
83+
await integration.processEvent(event, hint);
84+
}
85+
86+
expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint);
87+
expect(mockCaptureReplay).toHaveBeenCalled();
88+
});
89+
90+
it('should capture replay when beforeErrorSampling is not provided', async () => {
91+
const integration = mobileReplayIntegration();
92+
93+
const event: Event = {
94+
event_id: 'test-event-id',
95+
exception: {
96+
values: [{ type: 'Error', value: 'Test error' }],
97+
},
98+
};
99+
const hint: EventHint = {};
100+
101+
if (integration.processEvent) {
102+
await integration.processEvent(event, hint);
103+
}
104+
105+
expect(mockCaptureReplay).toHaveBeenCalled();
106+
});
107+
108+
it('should filter out specific error types using beforeErrorSampling', async () => {
109+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>((event: Event) => {
110+
// Only capture replays for unhandled errors (not manually captured)
111+
const isHandled = event.exception?.values?.some(exception => exception.mechanism?.handled === true);
112+
return !isHandled;
113+
});
114+
const integration = mobileReplayIntegration({ beforeErrorSampling });
115+
116+
// Test with handled error
117+
const handledEvent: Event = {
118+
event_id: 'handled-event-id',
119+
exception: {
120+
values: [
121+
{
122+
type: 'Error',
123+
value: 'Handled error',
124+
mechanism: { handled: true, type: 'generic' },
125+
},
126+
],
127+
},
128+
};
129+
const hint: EventHint = {};
130+
131+
if (integration.processEvent) {
132+
await integration.processEvent(handledEvent, hint);
133+
}
134+
135+
expect(beforeErrorSampling).toHaveBeenCalledWith(handledEvent, hint);
136+
expect(mockCaptureReplay).not.toHaveBeenCalled();
137+
138+
jest.clearAllMocks();
139+
140+
// Test with unhandled error
141+
const unhandledEvent: Event = {
142+
event_id: 'unhandled-event-id',
143+
exception: {
144+
values: [
145+
{
146+
type: 'Error',
147+
value: 'Unhandled error',
148+
mechanism: { handled: false, type: 'generic' },
149+
},
150+
],
151+
},
152+
};
153+
154+
if (integration.processEvent) {
155+
await integration.processEvent(unhandledEvent, hint);
156+
}
157+
158+
expect(beforeErrorSampling).toHaveBeenCalledWith(unhandledEvent, hint);
159+
expect(mockCaptureReplay).toHaveBeenCalled();
160+
});
161+
162+
it('should not call beforeErrorSampling for non-error events', async () => {
163+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockReturnValue(false);
164+
const integration = mobileReplayIntegration({ beforeErrorSampling });
165+
166+
const event: Event = {
167+
event_id: 'test-event-id',
168+
message: 'Test message without exception',
169+
};
170+
const hint: EventHint = {};
171+
172+
if (integration.processEvent) {
173+
await integration.processEvent(event, hint);
174+
}
175+
176+
expect(beforeErrorSampling).not.toHaveBeenCalled();
177+
expect(mockCaptureReplay).not.toHaveBeenCalled();
178+
});
179+
180+
it('should handle exceptions thrown by beforeErrorSampling and proceed with capture', async () => {
181+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockImplementation(() => {
182+
throw new Error('Callback error');
183+
});
184+
const integration = mobileReplayIntegration({ beforeErrorSampling });
185+
186+
const event: Event = {
187+
event_id: 'test-event-id',
188+
exception: {
189+
values: [{ type: 'Error', value: 'Test error' }],
190+
},
191+
};
192+
const hint: EventHint = {};
193+
194+
if (integration.processEvent) {
195+
await integration.processEvent(event, hint);
196+
}
197+
198+
expect(beforeErrorSampling).toHaveBeenCalledWith(event, hint);
199+
// Should proceed with replay capture despite callback error
200+
expect(mockCaptureReplay).toHaveBeenCalled();
201+
});
202+
203+
it('should not crash the event pipeline when beforeErrorSampling throws', async () => {
204+
const beforeErrorSampling = jest.fn<(event: Event, hint: EventHint) => boolean>().mockImplementation(() => {
205+
throw new TypeError('Unexpected callback error');
206+
});
207+
const integration = mobileReplayIntegration({ beforeErrorSampling });
208+
209+
const event: Event = {
210+
event_id: 'test-event-id',
211+
exception: {
212+
values: [{ type: 'Error', value: 'Test error' }],
213+
},
214+
};
215+
const hint: EventHint = {};
216+
217+
// Should not throw
218+
if (integration.processEvent) {
219+
await expect(integration.processEvent(event, hint)).resolves.toBeDefined();
220+
}
221+
222+
expect(beforeErrorSampling).toHaveBeenCalled();
223+
expect(mockCaptureReplay).toHaveBeenCalled();
224+
});
225+
});
226+
227+
describe('processEvent', () => {
228+
it('should not process events without exceptions', async () => {
229+
const integration = mobileReplayIntegration();
230+
231+
const event: Event = {
232+
event_id: 'test-event-id',
233+
message: 'Test message',
234+
};
235+
const hint: EventHint = {};
236+
237+
if (integration.processEvent) {
238+
await integration.processEvent(event, hint);
239+
}
240+
241+
expect(mockCaptureReplay).not.toHaveBeenCalled();
242+
});
243+
244+
it('should process events with exceptions', async () => {
245+
const integration = mobileReplayIntegration();
246+
247+
const event: Event = {
248+
event_id: 'test-event-id',
249+
exception: {
250+
values: [{ type: 'Error', value: 'Test error' }],
251+
},
252+
};
253+
const hint: EventHint = {};
254+
255+
if (integration.processEvent) {
256+
await integration.processEvent(event, hint);
257+
}
258+
259+
expect(mockCaptureReplay).toHaveBeenCalled();
260+
});
261+
});
262+
263+
describe('platform checks', () => {
264+
it('should return noop integration in Expo Go', () => {
265+
jest.spyOn(environment, 'isExpoGo').mockReturnValue(true);
266+
267+
const integration = mobileReplayIntegration();
268+
269+
expect(integration.name).toBe('MobileReplay');
270+
expect(integration.processEvent).toBeUndefined();
271+
});
272+
273+
it('should return noop integration on non-mobile platforms', () => {
274+
jest.spyOn(environment, 'notMobileOs').mockReturnValue(true);
275+
276+
const integration = mobileReplayIntegration();
277+
278+
expect(integration.name).toBe('MobileReplay');
279+
expect(integration.processEvent).toBeUndefined();
280+
});
281+
});
282+
});

0 commit comments

Comments
 (0)