Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

- Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984))

### Fixes

- Check `captureReplay` return value in iOS bridge to avoid linking error events to uncaptured replays ([#6008](https://github.com/getsentry/sentry-react-native/pull/6008))

### Dependencies

- Bump JavaScript SDK from v10.48.0 to v10.49.0 ([#6011](https://github.com/getsentry/sentry-react-native/pull/6011))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
+ (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys
otherUserKeys:(NSDictionary *)userDataKeys;

+ (BOOL)captureReplayWithReturnValue;

@end
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import "RNSentryTests.h"
#import "RNSentry+Test.h"
#import "RNSentryReplay.h"
#import "RNSentryStart+Test.h"
#import "SentrySDKWrapper.h"
Expand Down Expand Up @@ -1402,4 +1403,28 @@ - (void)testStartEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritte
XCTAssertEqual(testEvent.tags[@"event.environment"], @"native");
}

#pragma mark - captureReplayWithReturnValue Tests

#if SENTRY_TARGET_REPLAY_SUPPORTED
- (void)testCaptureReplayWithReturnValueReturnsFalseWhenReplayNotRunning
{
// Without starting the SDK with replay enabled,
// the replay integration should not be available
// and captureReplayWithReturnValue should return NO
BOOL result = [RNSentry captureReplayWithReturnValue];
XCTAssertFalse(result,
@"captureReplayWithReturnValue should return NO when replay integration is not available");
}

- (void)testCaptureReplayWithReturnValueReturnsFalseWithoutInit
{
// When the SDK is not initialized at all, there should be no replay integration
// and captureReplayWithReturnValue should return NO without crashing
BOOL result = [RNSentry captureReplayWithReturnValue];
XCTAssertFalse(result,
@"captureReplayWithReturnValue should return NO without crashing when SDK is not "
@"initialized");
}
#endif

@end
59 changes: 57 additions & 2 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -794,12 +794,67 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys
// the 'tracesSampleRate' or 'tracesSampler' option.
}

/**
* Calls captureReplay on the native replay integration and returns
* the BOOL result indicating whether the capture succeeded.
*
* PrivateSentrySDKOnly.captureReplay is void and discards the result,
* so we call the integration directly to get the success status.
* This prevents returning a stale buffer-mode replay ID when the
* capture actually failed (e.g., replay not running).
*
* Falls back to the old void captureReplay if the integration
* cannot be accessed directly (e.g., future Cocoa SDK changes).
*
* See https://github.com/getsentry/sentry-react-native/issues/5074
*/
+ (BOOL)captureReplayWithReturnValue
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
@try {
if ([PrivateSentrySDKOnly respondsToSelector:@selector(getReplayIntegration)]) {
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Warc-performSelector-leaks"
id replayIntegration =
[PrivateSentrySDKOnly performSelector:@selector(getReplayIntegration)];
# pragma clang diagnostic pop
if (replayIntegration &&
[replayIntegration respondsToSelector:@selector(captureReplay)]) {
typedef BOOL (*CaptureReplayIMP)(id, SEL);
CaptureReplayIMP captureFunc = (CaptureReplayIMP)
[replayIntegration methodForSelector:@selector(captureReplay)];
return captureFunc(replayIntegration, @selector(captureReplay));
}
}
} @catch (NSException *exception) {
NSLog(@"[RNSentry] Failed to call captureReplay on integration: %@", exception);
}
// Fallback: call the void method and assume success if a replay ID exists.
// This preserves the old behavior when the integration isn't directly accessible.
// clang-format off
@try {
[PrivateSentrySDKOnly captureReplay];
return [PrivateSentrySDKOnly getReplayId] != nil;
} @catch (NSException *exception) {
NSLog(@"[RNSentry] Failed to call captureReplay fallback: %@", exception);
return NO;
}
// clang-format on
#else
return NO;
#endif
}

RCT_EXPORT_METHOD(captureReplay : (BOOL)isHardCrash resolver : (
RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
{
#if SENTRY_TARGET_REPLAY_SUPPORTED
[PrivateSentrySDKOnly captureReplay];
resolve([PrivateSentrySDKOnly getReplayId]);
BOOL captured = [RNSentry captureReplayWithReturnValue];
Comment thread
antonis marked this conversation as resolved.
if (captured) {
resolve([PrivateSentrySDKOnly getReplayId]);
} else {
resolve(nil);
}
#else
resolve(nil);
#endif
Expand Down
88 changes: 88 additions & 0 deletions packages/core/test/replay/mobilereplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,94 @@ describe('Mobile Replay Integration', () => {
});
});

describe('captureReplay returns null (native capture failed)', () => {
it('should not set replay_id when captureReplay returns null and no ongoing recording', async () => {
mockCaptureReplay.mockResolvedValue(null);
mockGetCurrentReplayId.mockReturnValue(null);

const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

const event = {
event_id: 'test-event-id',
exception: {
values: [
{
type: 'Error',
value: 'Test error',
mechanism: { handled: false, type: 'onerror' },
},
],
},
} as ErrorEvent;
const hint: EventHint = {};

const result = await clientOptions.beforeSend?.(event, hint);

expect(result).toBeDefined();
expect(mockCaptureReplay).toHaveBeenCalledWith(true); // isHardCrash
expect(result?.contexts?.replay?.replay_id).toBeUndefined();
});

it('should use ongoing recording when captureReplay returns null but recording exists', async () => {
mockCaptureReplay.mockResolvedValue(null);
// First call during setup returns initial ID, second call during processEvent returns ongoing ID
mockGetCurrentReplayId.mockReturnValueOnce(null).mockReturnValue('ongoing-replay-id');

const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

const event = {
event_id: 'test-event-id',
exception: {
values: [
{
type: 'Error',
value: 'Test error',
mechanism: { handled: false, type: 'onerror' },
},
],
},
} as ErrorEvent;
const hint: EventHint = {};

const result = await clientOptions.beforeSend?.(event, hint);

expect(result).toBeDefined();
expect(mockCaptureReplay).toHaveBeenCalled();
// Should fall back to ongoing recording ID
expect(result?.contexts?.replay?.replay_id).toBe('ongoing-replay-id');
});

it('should set replay_id when captureReplay succeeds', async () => {
mockCaptureReplay.mockResolvedValue('new-replay-id');
mockGetCurrentReplayId.mockReturnValue(null);

const integration = mobileReplayIntegration();
integration.setup?.(mockClient);

const event = {
event_id: 'test-event-id',
exception: {
values: [
{
type: 'Error',
value: 'Test error',
mechanism: { handled: false, type: 'onerror' },
},
],
},
} as ErrorEvent;
const hint: EventHint = {};

const result = await clientOptions.beforeSend?.(event, hint);

expect(result).toBeDefined();
expect(mockCaptureReplay).toHaveBeenCalled();
expect(result?.contexts?.replay?.replay_id).toBe('new-replay-id');
});
});

describe('platform checks', () => {
it('should return noop integration in Expo Go', () => {
jest.spyOn(environment, 'isExpoGo').mockReturnValue(true);
Expand Down
Loading