diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d303a3c1e..57d640f385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Features + +- 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: + ```java + SentryAndroid.init(context) { options -> + options.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { event, hint -> + // Skip replay for handled exceptions + val hasUnhandled = event.exceptions?.any { it.mechanism?.isHandled == false } == true + hasUnhandled + } + } + ``` + ## 8.36.0 ### Features diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5b8f973e75..cb9078ac07 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 26c70f365f..b8178e3551 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 3c618bfee9..d4e0fd257c 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 e7c4ae84b9..11ff80fd57 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