Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 34 additions & 5 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<MobileReplayOptions> = {
const defaultOptions: MobileReplayOptions = {
maskAllText: true,
maskAllImages: true,
maskAllVectors: true,
Expand All @@ -105,7 +116,7 @@ const defaultOptions: Required<MobileReplayOptions> = {
screenshotStrategy: 'pixelCopy',
};

function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<MobileReplayOptions> {
function mergeOptions(initOptions: Partial<MobileReplayOptions>): MobileReplayOptions {
const merged = {
...defaultOptions,
...initOptions,
Expand All @@ -119,7 +130,7 @@ function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<Mobil
}

type MobileReplayIntegration = Integration & {
options: Required<MobileReplayOptions>;
options: MobileReplayOptions;
getReplayId: () => string | null;
};

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

const options = mergeOptions(initOptions);

async function processEvent(event: Event): Promise<Event> {
async function processEvent(event: Event, hint: EventHint): Promise<Event> {
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) {
try {
if (initOptions.beforeErrorSampling(event, hint) === false) {
debug.log(
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sent; beforeErrorSampling conditions not met 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));
if (!replayId) {
const recordingReplayId = NATIVE.getCurrentReplayId();
Expand Down
282 changes: 282 additions & 0 deletions packages/core/test/replay/mobilereplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
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<typeof NATIVE.captureReplay>;
let mockGetCurrentReplayId: jest.MockedFunction<typeof NATIVE.getCurrentReplayId>;

beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
jest.spyOn(environment, 'notMobileOs').mockReturnValue(false);
mockCaptureReplay = NATIVE.captureReplay as jest.MockedFunction<typeof NATIVE.captureReplay>;
mockGetCurrentReplayId = NATIVE.getCurrentReplayId as jest.MockedFunction<typeof NATIVE.getCurrentReplayId>;
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<(event: Event, hint: EventHint) => boolean>().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<(event: Event, hint: EventHint) => boolean>().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<(event: Event, hint: EventHint) => boolean>()
.mockReturnValue(undefined as unknown as boolean);
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, 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;
});
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<(event: Event, hint: EventHint) => boolean>().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();
});

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', () => {
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();
});
});
});
Loading