Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -3998,6 +3998,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun <init> (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;
Expand All @@ -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
Expand All @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion sentry/src/main/java/io/sentry/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions sentry/src/main/java/io/sentry/SentryReplayOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -172,6 +191,12 @@ public enum SentryReplayQuality {
*/
private @NotNull List<String> 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
Expand Down Expand Up @@ -469,4 +494,26 @@ public void setNetworkResponseHeaders(final @NotNull List<String> 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;
}
}
94 changes: 94 additions & 0 deletions sentry/src/test/java/io/sentry/SentryClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading