From d091f74fb9338d8584f2fb4f3736cdd6dde7ee3b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 19 Mar 2026 19:23:56 +0100 Subject: [PATCH 1/4] feat(replay): add `beforeErrorSampling` callback to Session Replay Add a BeforeErrorSamplingCallback to SentryReplayOptions that lets developers filter which errors trigger replay capture. The callback runs before the onErrorSampleRate dice roll - returning false skips captureReplay entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- sentry/api/sentry.api | 6 ++ .../src/main/java/io/sentry/SentryClient.java | 20 +++- .../java/io/sentry/SentryReplayOptions.java | 47 ++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 94 +++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5b8f973e756..cb9078ac07b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3998,6 +3998,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun (ZLio/sentry/protocol/SdkVersion;)V public fun addMaskViewClass (Ljava/lang/String;)V public fun addUnmaskViewClass (Ljava/lang/String;)V + public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback; public fun getErrorReplayDuration ()J public fun getFrameRate ()I public fun getNetworkDetailAllowUrls ()Ljava/util/List; @@ -4017,6 +4018,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z + public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V public fun setDebug (Z)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V @@ -4034,6 +4036,10 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun trackCustomMasking ()V } +public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplingCallback { + public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z +} + public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 26c70f365f5..b8178e35517 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -231,7 +231,25 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul // an event from the past. If it's cached, but with ApplyScopeData, it comes from the outbox // folder and we still want to capture replay (e.g. a native captureException error) if (event != null && !isBackfillable && !isCached && (event.isErrored() || event.isCrashed())) { - options.getReplayController().captureReplay(event.isCrashed()); + boolean shouldCaptureReplay = true; + final SentryReplayOptions.BeforeErrorSamplingCallback beforeErrorSampling = + options.getSessionReplay().getBeforeErrorSampling(); + if (beforeErrorSampling != null) { + try { + shouldCaptureReplay = beforeErrorSampling.execute(event, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "The beforeErrorSampling callback threw an exception. Proceeding with replay capture.", + e); + shouldCaptureReplay = true; + } + } + if (shouldCaptureReplay) { + options.getReplayController().captureReplay(event.isCrashed()); + } } try { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 3c618bfee9d..d4e0fd257cd 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -17,6 +17,25 @@ public final class SentryReplayOptions extends SentryMaskingOptions { + /** + * Callback that is called before the error sample rate is checked for session replay. If the + * callback returns {@code false}, the replay will not be captured for this error event, and the + * {@code onErrorSampleRate} will not be checked. If the callback returns {@code true}, the {@code + * onErrorSampleRate} will be checked as usual. This allows developers to filter which errors + * trigger replay capture. + */ + public interface BeforeErrorSamplingCallback { + /** + * Determines whether replay capture should proceed for the given error event. + * + * @param event the error event that triggered the replay capture + * @param hint the hint associated with the event + * @return {@code true} if the error sample rate should be checked, {@code false} to skip replay + * capture entirely + */ + boolean execute(@NotNull SentryEvent event, @NotNull Hint hint); + } + private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking"; private volatile boolean customMaskingTracked = false; @@ -172,6 +191,12 @@ public enum SentryReplayQuality { */ private @NotNull List networkResponseHeaders = DEFAULT_HEADERS; + /** + * A callback that is called before the error sample rate is checked for session replay. Can be + * used to filter which errors trigger replay capture. + */ + private @Nullable BeforeErrorSamplingCallback beforeErrorSampling; + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { // Add default mask classes directly without setting usingCustomMasking flag @@ -469,4 +494,26 @@ public void setNetworkResponseHeaders(final @NotNull List networkRespons merged.addAll(additionalHeaders); return Collections.unmodifiableList(new ArrayList<>(merged)); } + + /** + * Gets the callback that is called before the error sample rate is checked for session replay. + * + * @return the callback, or {@code null} if not set + */ + public @Nullable BeforeErrorSamplingCallback getBeforeErrorSampling() { + return beforeErrorSampling; + } + + /** + * Sets the callback that is called before the error sample rate is checked for session replay. + * Returning {@code false} from the callback will skip replay capture for the error event entirely + * (the {@code onErrorSampleRate} will not be checked). Returning {@code true} will proceed with + * the normal error sample rate check. + * + * @param beforeErrorSampling the callback, or {@code null} to disable filtering + */ + public void setBeforeErrorSampling( + final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) { + this.beforeErrorSampling = beforeErrorSampling; + } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index e7c4ae84b99..11ff80fd573 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3195,6 +3195,100 @@ class SentryClientTest { assertFalse(called) } + @Test + fun `beforeErrorSampling returning false skips captureReplay`() { + var called = false + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + } + ) + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> false } + val sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + assertFalse(called) + } + + @Test + fun `beforeErrorSampling returning true proceeds with captureReplay`() { + var called = false + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + } + ) + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> true } + val sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + assertTrue(called) + } + + @Test + fun `beforeErrorSampling not set proceeds with captureReplay`() { + var called = false + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + } + ) + val sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + assertTrue(called) + } + + @Test + fun `beforeErrorSampling throwing exception proceeds with captureReplay`() { + var called = false + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + } + ) + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> throw RuntimeException("test") } + val sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + assertTrue(called) + } + + @Test + fun `beforeErrorSampling receives correct event and hint`() { + var receivedEvent: SentryEvent? = null + var receivedHint: Hint? = null + fixture.sentryOptions.setReplayController( + object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) {} + } + ) + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { event, hint -> + receivedEvent = event + receivedHint = hint + true + } + val sut = fixture.getSut() + + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + val hint = Hint() + sut.captureEvent(event, hint) + assertSame(event, receivedEvent) + assertSame(hint, receivedHint) + } + @Test fun `captures replay for cached events with apply scope`() { var called = false From 84f0274aada727998ea32f8c445af05500391b53 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 19 Mar 2026 19:39:48 +0100 Subject: [PATCH 2/4] docs: add changelog entry for beforeErrorSampling callback Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d303a3c1e6..915464672ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +### Features + +- Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) + - Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked + - Returning `false` skips replay capture entirely for that error; returning `true` proceeds with the normal sample rate check + - Example usage: + ```java + SentryAndroid.init(context) { options -> + options.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { event, hint -> + // Skip replay for handled exceptions + val dominated = event.exceptions?.lastOrNull() + val isHandled = dominated?.mechanism?.isHandled == true + !isHandled + } + } + ``` + ## 8.36.0 ### Features From 54bcce430801e83e87f4f0c171b95786739ff7f7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 19 Mar 2026 19:42:55 +0100 Subject: [PATCH 3/4] docs: use any{} in changelog example Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 915464672ee..d8c9a9c7185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,8 @@ options.sessionReplay.beforeErrorSampling = SentryReplayOptions.BeforeErrorSamplingCallback { event, hint -> // Skip replay for handled exceptions - val dominated = event.exceptions?.lastOrNull() - val isHandled = dominated?.mechanism?.isHandled == true - !isHandled + val hasUnhandled = event.exceptions?.any { it.mechanism?.isHandled == false } == true + hasUnhandled } } ``` From 536486e3d35c8a8c4cdae12834ded1b9083900cc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 20 Mar 2026 11:28:56 +0100 Subject: [PATCH 4/4] Apply suggestion from @romtsn --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c9a9c7185..57d640f385a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) +- Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) - Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked - Returning `false` skips replay capture entirely for that error; returning `true` proceeds with the normal sample rate check - Example usage: