From 49f8b9cfbafa1d6c6c67175775137e61d84ef078 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:46:16 +0200 Subject: [PATCH 1/2] fix(core): [Callback Errors 7] Handle replay sampling failures Skip replay capture when beforeErrorSampling throws instead of capturing replay after a failed user filter. Continue sending the error event and retain existing exception logging and catch types. Cover handled errors, crashes, and successful replay capture after a previous callback failure. Verify error delivery and failure logging. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 4 +- .../test/java/io/sentry/SentryClientTest.kt | 67 ++++++++++++++++--- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f3c86b49a..d316af0066a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. + - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event. - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 705f6fe3bbb..e409603f80c 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -244,9 +244,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul .getLogger() .log( SentryLevel.ERROR, - "The beforeErrorSampling callback threw an exception. Proceeding with replay capture.", + "The beforeErrorSampling callback threw an exception. Skipping replay capture.", e); - shouldCaptureReplay = true; + shouldCaptureReplay = false; } } if (shouldCaptureReplay) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 99d12e12184..dc59e767758 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3962,24 +3962,71 @@ class SentryClientTest { } @Test - fun `beforeErrorSampling throwing exception proceeds with captureReplay`() { - var called = false - fixture.sentryOptions.setReplayController( - object : ReplayController by NoOpReplayController.getInstance() { - override fun captureReplay(isTerminating: Boolean?): SentryId { - called = true - return SentryId.EMPTY_ID + fun `beforeErrorSampling throwing exception skips replay but still sends errors and crashes`() { + val replayController = mock() + whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) + fixture.sentryOptions.setReplayController(replayController) + val logger = mock() + fixture.sentryOptions.setLogger(logger) + val exception = RuntimeException("test") + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + throw exception + } + val sut = fixture.getSut() + val events = + listOf(true, false).map { handled -> + SentryEvent().apply { + exceptions = + listOf( + SentryException().apply { mechanism = Mechanism().apply { isHandled = handled } } + ) } } - ) + + events.forEach { event -> + assertThat(sut.captureEvent(event)).isEqualTo(event.eventId) + } + + verify(replayController, never()).captureReplay(anyOrNull()) + val envelopes = argumentCaptor() + verify(fixture.transport, times(2)).send(envelopes.capture(), anyOrNull()) + assertThat(envelopes.allValues.map { getEventFromData(it.items.first().data).eventId }) + .containsExactlyElementsIn(events.map { it.eventId }) + .inOrder() + verify(logger, times(2)) + .log( + SentryLevel.ERROR, + "The beforeErrorSampling callback threw an exception. Skipping replay capture.", + exception, + ) + } + + @Test + fun `beforeErrorSampling throwing exception does not prevent replay capture for subsequent events`() { + val replayController = mock() + whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) + fixture.sentryOptions.setReplayController(replayController) + var invocations = 0 fixture.sentryOptions.sessionReplay.beforeErrorSampling = SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> - throw RuntimeException("test") + invocations++ + if (invocations == 1) { + throw RuntimeException("test") + } + true } val sut = fixture.getSut() sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) - assertTrue(called) + verify(replayController, never()).captureReplay(anyOrNull()) + + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + assertThat(sut.captureEvent(event)).isEqualTo(event.eventId) + + assertThat(invocations).isEqualTo(2) + verify(replayController).captureReplay(false) + verify(fixture.transport, times(2)).send(any(), anyOrNull()) } @Test From b62c0944c212952006541394fef8f72b4213cc67 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:53:53 +0200 Subject: [PATCH 2/2] changelog Link the beforeErrorSampling failure entry to PR #6165. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d316af0066a..b77d26210c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,7 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event. + - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124)