Skip to content
Draft
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 @@ -17,6 +17,7 @@

- Fix visionOS compilation ([#6676](https://github.com/getsentry/sentry-react-native/pull/6676))
- A throwing `beforeBreadcrumb` now drops the breadcrumb, and a throwing `tracesSampler` now falls back to the configured `tracesSampleRate` ([#6675](https://github.com/getsentry/sentry-react-native/pull/6675))
- Stop uploading on-error Session Replays for errors dropped by `sampleRate` ([#6685](https://github.com/getsentry/sentry-react-native/pull/6685))

### Internal

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,29 @@ public void fetchNativeFramesDelay(

public void captureReplay(boolean isHardCrash, Promise promise) {
Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash);
promise.resolve(getCurrentReplayId());
// Resolve with the scope's replayId, which is populated only when a replay
// was actually sent. Returning the controller's buffered id here would make
// JS treat an on-error sampling miss as a successful flush, diverging from
// iOS (which resolves nil unless a replay was captured).
promise.resolve(getReplayIdFromScope());
}

public @Nullable String getCurrentReplayId() {
// Prefer the replay controller's id: it is assigned when recording starts
// (buffer or session) and is therefore available BEFORE a replay is
// flushed, so a buffered (on-error) replay can be linked to the event in
// `beforeSend`. The scope's replayId is only populated once a replay is
// sent, so it stays empty while a buffer replay is recording (issue #6598).
final @NotNull SentryId controllerId =
Sentry.getCurrentScopes().getOptions().getReplayController().getReplayId();
if (controllerId != SentryId.EMPTY_ID) {
return controllerId.toString();
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.

return getReplayIdFromScope();
}

private @Nullable String getReplayIdFromScope() {
final @Nullable IScope scope = InternalSentrySdk.getCurrentScope();
if (scope == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package io.sentry.react;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import io.sentry.IScope;
import io.sentry.IScopes;
import io.sentry.ReplayController;
import io.sentry.Sentry;
import io.sentry.SentryOptions;
import io.sentry.android.core.InternalSentrySdk;
import io.sentry.protocol.SentryId;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;

/**
* Coverage for {@link RNSentryModuleImpl#getCurrentReplayId()} and its buffer (on-error) replay
* lookup added for https://github.com/getsentry/sentry-react-native/issues/6598.
*
* <p>A buffer replay's id is assigned by the {@link ReplayController} when recording starts, but
* the scope's replayId is only populated once a replay is sent. The JS mobile replay integration
* must be able to read the buffered id BEFORE the replay is flushed, so {@code
* getCurrentReplayId()} prefers the controller's id and only falls back to the scope.
*/
public class RNSentryReplayIdTest {

private RNSentryModuleImpl module;

@Before
public void setUp() throws Exception {
ReactApplicationContext reactContext = mock(ReactApplicationContext.class);
PackageManager packageManager = mock(PackageManager.class);
when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo());
when(reactContext.getPackageManager()).thenReturn(packageManager);
when(reactContext.getPackageName()).thenReturn("com.test.app");
module = new RNSentryModuleImpl(reactContext);
}

/**
* Wires {@code Sentry.getCurrentScopes().getOptions().getReplayController()} to return the id and
* hands back the mocked controller so callers can verify interactions with it.
*/
private ReplayController stubControllerReplayId(
final MockedStatic<Sentry> sentry, final SentryId id) {
final ReplayController replayController = mock(ReplayController.class);
when(replayController.getReplayId()).thenReturn(id);
final SentryOptions options = mock(SentryOptions.class);
when(options.getReplayController()).thenReturn(replayController);
final IScopes scopes = mock(IScopes.class);
when(scopes.getOptions()).thenReturn(options);
sentry.when(Sentry::getCurrentScopes).thenReturn(scopes);
return replayController;
}

@Test
public void prefersReplayControllerIdWhenBuffering() {
// A buffer replay is recording: the controller exposes its id even though the scope has none.
final SentryId bufferedId = new SentryId();

try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
stubControllerReplayId(sentry, bufferedId);
// Scope has no replay id yet โ€” the fix must not depend on it.
final IScope scope = mock(IScope.class);
when(scope.getReplayId()).thenReturn(SentryId.EMPTY_ID);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(scope);

assertEquals(bufferedId.toString(), module.getCurrentReplayId());
}
}

@Test
public void fallsBackToScopeIdWhenControllerEmpty() {
// A full-session replay was sent: the controller reports empty, the id lives on the scope.
final SentryId scopeId = new SentryId();

try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
stubControllerReplayId(sentry, SentryId.EMPTY_ID);
final IScope scope = mock(IScope.class);
when(scope.getReplayId()).thenReturn(scopeId);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(scope);

assertEquals(scopeId.toString(), module.getCurrentReplayId());
}
}

@Test
public void returnsNullWhenControllerEmptyAndScopeEmpty() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
stubControllerReplayId(sentry, SentryId.EMPTY_ID);
final IScope scope = mock(IScope.class);
when(scope.getReplayId()).thenReturn(SentryId.EMPTY_ID);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(scope);

assertNull(module.getCurrentReplayId());
}
}

@Test
public void returnsNullWhenControllerEmptyAndScopeNull() {
try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
stubControllerReplayId(sentry, SentryId.EMPTY_ID);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(null);

assertNull(module.getCurrentReplayId());
}
}

@Test
public void captureReplayResolvesNullOnSamplingMissEvenWhileBuffering() {
// On an on-error sampling miss the controller still holds the buffered id, but the scope has
// none because nothing was uploaded. captureReplay must resolve null (matching iOS), rather
// than leak the buffered id as if a replay had been sent.
final SentryId bufferedId = new SentryId();

try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
final ReplayController replayController = stubControllerReplayId(sentry, bufferedId);
final IScope scope = mock(IScope.class);
when(scope.getReplayId()).thenReturn(SentryId.EMPTY_ID);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(scope);

final Promise promise = mock(Promise.class);
module.captureReplay(true, promise);

verify(replayController).captureReplay(true);
verify(promise).resolve(null);
}
}

@Test
public void captureReplayResolvesScopeIdWhenReplayWasSent() {
// When a replay is actually sent the scope carries its id; captureReplay resolves that, not the
// controller's id, so JS learns the real uploaded replay id.
final SentryId bufferedId = new SentryId();
final SentryId scopeId = new SentryId();

try (MockedStatic<Sentry> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
final ReplayController replayController = stubControllerReplayId(sentry, bufferedId);
final IScope scope = mock(IScope.class);
when(scope.getReplayId()).thenReturn(scopeId);
internal.when(InternalSentrySdk::getCurrentScope).thenReturn(scope);

final Promise promise = mock(Promise.class);
module.captureReplay(false, promise);

verify(replayController).captureReplay(false);
verify(promise).resolve(scopeId.toString());
}
}
}
90 changes: 68 additions & 22 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,25 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
return nativeReplayId;
}

async function processEvent(event: ErrorEvent, hint: EventHint): Promise<ErrorEvent> {
// Error `sampleRate` sampling runs AFTER `beforeSend` in `@sentry/core`
// (since 10.70.0, getsentry/sentry-javascript#22819). Flushing the buffered
// replay inside `beforeSend` therefore uploads a replay even for errors that
// are then dropped by `sampleRate`, orphaning the replay (issue #6598).
//
// The work is split in two:
// 1. `tagEventWithReplayId` runs in the `beforeSend` wrapper and only links
// the event to the buffered replay id (no flush).
// 2. `flushReplayForSentEvent` runs in `afterSendEvent`, which fires only
// for events that survive sampling and are actually sent, and performs
// the native flush there.
//
// Trade-off: the link is tagged optimistically from the buffered id before the
// native `replaysOnErrorSampleRate` roll (which still happens at flush time in
// `captureReplay`). If that roll misses, the event carries a `replay_id` for a
// replay that is never uploaded. A fully-correct fix requires the native SDKs
// to decouple the on-error sampling decision from the buffer upload.

function tagEventWithReplayId(event: ErrorEvent, hint: EventHint): ErrorEvent {
const hasException = event.exception?.values && event.exception.values.length > 0;
if (!hasException) {
// Event is not an error, will not capture replay
Expand All @@ -370,39 +388,58 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
}
}

const replayId = await NATIVE.captureReplay(isHardCrash(event));
// Read the buffered replay id WITHOUT flushing. The native bridge returns
// the id assigned when recording started (buffer or full session), so the
// event can be linked to the replay that will be flushed after sampling.
const replayId = NATIVE.getCurrentReplayId();
if (replayId) {
updateCachedReplayId(replayId);
debug.log(
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Captured recording replay ${replayId} for event ${event.event_id}.`,
);
// Add replay_id to error event contexts to link replays to events/traces
event.contexts = event.contexts || {};
event.contexts.replay = {
...event.contexts.replay,
replay_id: replayId,
};
debug.log(
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} linked replay ${replayId} to event ${event.event_id}; flush deferred until after sampling.`,
);
} else {
// Check if there's an ongoing recording and update cache if found
const recordingReplayId = NATIVE.getCurrentReplayId();
if (recordingReplayId) {
updateCachedReplayId(recordingReplayId);
updateCachedReplayId(null);
debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} no active recording for event ${event.event_id}.`);
}

return event;
}

async function flushReplayForSentEvent(event: Event): Promise<void> {
const eventId = event.event_id;
// Only flush for events that were linked to a buffered replay in
// `beforeSend`. The link lives on the event itself, so no shared bookkeeping
// is needed: events dropped by sampling never reach this hook and cannot
// affect the flush decision for other events.
if (!eventId || !event.contexts?.replay?.replay_id) {
return;
}

try {
const replayId = await NATIVE.captureReplay(isHardCrash(event));
if (replayId) {
updateCachedReplayId(replayId);
debug.log(
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} assign already recording replay ${recordingReplayId} for event ${event.event_id}.`,
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} flushed recording replay ${replayId} for sent event ${eventId}.`,
);
// Add replay_id to error event contexts to link replays to events/traces
event.contexts = event.contexts || {};
event.contexts.replay = {
...event.contexts.replay,
replay_id: recordingReplayId,
};
} else {
updateCachedReplayId(null);
debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sampled for event ${event.event_id}.`);
// No replay was uploaded (e.g. an on-error sampling miss). Re-read the
// current recording id so the cache stops exposing an id that was never
// uploaded; it resolves to the still-active buffer id, or null.
updateCachedReplayId(NATIVE.getCurrentReplayId());
debug.log(
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sampled for event ${eventId} (replaysOnErrorSampleRate).`,
);
}
} catch (error) {
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for sent event ${eventId}`, error);
}

return event;
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.

function setup(client: Client): void {
Expand Down Expand Up @@ -498,12 +535,21 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
}
}
try {
return await processEvent(result, hint);
return tagEventWithReplayId(result, hint);
} catch (error) {
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to process event for replay`, error);
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to link event to replay`, error);
return result;
}
};

// Flush the buffered replay only for events that survive sampling. This hook
// fires after the error `sampleRate` roll in `@sentry/core`, so an error
// dropped by sampling never triggers a replay upload (issue #6598).
client.on('afterSendEvent', (event: Event) => {
flushReplayForSentEvent(event).then(undefined, () => {
// errors are logged inside flushReplayForSentEvent
});
});
}

function getReplayId(): string | null {
Expand Down
Loading
Loading