Skip to content

Commit 55aaf9b

Browse files
romtsnclaude
andauthored
feat(replay): add beforeErrorSampling callback to Session Replay (#5214)
* 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) <noreply@anthropic.com> * docs: add changelog entry for beforeErrorSampling callback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: use any{} in changelog example Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Apply suggestion from @romtsn --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d345880 commit 55aaf9b

File tree

5 files changed

+185
-1
lines changed

5 files changed

+185
-1
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214))
8+
- Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked
9+
- Returning `false` skips replay capture entirely for that error; returning `true` proceeds with the normal sample rate check
10+
- Example usage:
11+
```java
12+
SentryAndroid.init(context) { options ->
13+
options.sessionReplay.beforeErrorSampling =
14+
SentryReplayOptions.BeforeErrorSamplingCallback { event, hint ->
15+
// Skip replay for handled exceptions
16+
val hasUnhandled = event.exceptions?.any { it.mechanism?.isHandled == false } == true
17+
hasUnhandled
18+
}
19+
}
20+
```
21+
322
## 8.36.0
423

524
### Features

sentry/api/sentry.api

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3998,6 +3998,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
39983998
public fun <init> (ZLio/sentry/protocol/SdkVersion;)V
39993999
public fun addMaskViewClass (Ljava/lang/String;)V
40004000
public fun addUnmaskViewClass (Ljava/lang/String;)V
4001+
public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;
40014002
public fun getErrorReplayDuration ()J
40024003
public fun getFrameRate ()I
40034004
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
@@ -4017,6 +4018,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40174018
public fun isSessionReplayEnabled ()Z
40184019
public fun isSessionReplayForErrorsEnabled ()Z
40194020
public fun isTrackConfiguration ()Z
4021+
public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V
40204022
public fun setDebug (Z)V
40214023
public fun setMaskAllImages (Z)V
40224024
public fun setMaskAllText (Z)V
@@ -4034,6 +4036,10 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40344036
public fun trackCustomMasking ()V
40354037
}
40364038

4039+
public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplingCallback {
4040+
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
4041+
}
4042+
40374043
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
40384044
public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality;
40394045
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;

sentry/src/main/java/io/sentry/SentryClient.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,25 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul
231231
// an event from the past. If it's cached, but with ApplyScopeData, it comes from the outbox
232232
// folder and we still want to capture replay (e.g. a native captureException error)
233233
if (event != null && !isBackfillable && !isCached && (event.isErrored() || event.isCrashed())) {
234-
options.getReplayController().captureReplay(event.isCrashed());
234+
boolean shouldCaptureReplay = true;
235+
final SentryReplayOptions.BeforeErrorSamplingCallback beforeErrorSampling =
236+
options.getSessionReplay().getBeforeErrorSampling();
237+
if (beforeErrorSampling != null) {
238+
try {
239+
shouldCaptureReplay = beforeErrorSampling.execute(event, hint);
240+
} catch (Throwable e) {
241+
options
242+
.getLogger()
243+
.log(
244+
SentryLevel.ERROR,
245+
"The beforeErrorSampling callback threw an exception. Proceeding with replay capture.",
246+
e);
247+
shouldCaptureReplay = true;
248+
}
249+
}
250+
if (shouldCaptureReplay) {
251+
options.getReplayController().captureReplay(event.isCrashed());
252+
}
235253
}
236254

237255
try {

sentry/src/main/java/io/sentry/SentryReplayOptions.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@
1717

1818
public final class SentryReplayOptions extends SentryMaskingOptions {
1919

20+
/**
21+
* Callback that is called before the error sample rate is checked for session replay. If the
22+
* callback returns {@code false}, the replay will not be captured for this error event, and the
23+
* {@code onErrorSampleRate} will not be checked. If the callback returns {@code true}, the {@code
24+
* onErrorSampleRate} will be checked as usual. This allows developers to filter which errors
25+
* trigger replay capture.
26+
*/
27+
public interface BeforeErrorSamplingCallback {
28+
/**
29+
* Determines whether replay capture should proceed for the given error event.
30+
*
31+
* @param event the error event that triggered the replay capture
32+
* @param hint the hint associated with the event
33+
* @return {@code true} if the error sample rate should be checked, {@code false} to skip replay
34+
* capture entirely
35+
*/
36+
boolean execute(@NotNull SentryEvent event, @NotNull Hint hint);
37+
}
38+
2039
private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking";
2140
private volatile boolean customMaskingTracked = false;
2241

@@ -172,6 +191,12 @@ public enum SentryReplayQuality {
172191
*/
173192
private @NotNull List<String> networkResponseHeaders = DEFAULT_HEADERS;
174193

194+
/**
195+
* A callback that is called before the error sample rate is checked for session replay. Can be
196+
* used to filter which errors trigger replay capture.
197+
*/
198+
private @Nullable BeforeErrorSamplingCallback beforeErrorSampling;
199+
175200
public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {
176201
if (!empty) {
177202
// Add default mask classes directly without setting usingCustomMasking flag
@@ -469,4 +494,26 @@ public void setNetworkResponseHeaders(final @NotNull List<String> networkRespons
469494
merged.addAll(additionalHeaders);
470495
return Collections.unmodifiableList(new ArrayList<>(merged));
471496
}
497+
498+
/**
499+
* Gets the callback that is called before the error sample rate is checked for session replay.
500+
*
501+
* @return the callback, or {@code null} if not set
502+
*/
503+
public @Nullable BeforeErrorSamplingCallback getBeforeErrorSampling() {
504+
return beforeErrorSampling;
505+
}
506+
507+
/**
508+
* Sets the callback that is called before the error sample rate is checked for session replay.
509+
* Returning {@code false} from the callback will skip replay capture for the error event entirely
510+
* (the {@code onErrorSampleRate} will not be checked). Returning {@code true} will proceed with
511+
* the normal error sample rate check.
512+
*
513+
* @param beforeErrorSampling the callback, or {@code null} to disable filtering
514+
*/
515+
public void setBeforeErrorSampling(
516+
final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) {
517+
this.beforeErrorSampling = beforeErrorSampling;
518+
}
472519
}

sentry/src/test/java/io/sentry/SentryClientTest.kt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3195,6 +3195,100 @@ class SentryClientTest {
31953195
assertFalse(called)
31963196
}
31973197

3198+
@Test
3199+
fun `beforeErrorSampling returning false skips captureReplay`() {
3200+
var called = false
3201+
fixture.sentryOptions.setReplayController(
3202+
object : ReplayController by NoOpReplayController.getInstance() {
3203+
override fun captureReplay(isTerminating: Boolean?) {
3204+
called = true
3205+
}
3206+
}
3207+
)
3208+
fixture.sentryOptions.sessionReplay.beforeErrorSampling =
3209+
SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> false }
3210+
val sut = fixture.getSut()
3211+
3212+
sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) })
3213+
assertFalse(called)
3214+
}
3215+
3216+
@Test
3217+
fun `beforeErrorSampling returning true proceeds with captureReplay`() {
3218+
var called = false
3219+
fixture.sentryOptions.setReplayController(
3220+
object : ReplayController by NoOpReplayController.getInstance() {
3221+
override fun captureReplay(isTerminating: Boolean?) {
3222+
called = true
3223+
}
3224+
}
3225+
)
3226+
fixture.sentryOptions.sessionReplay.beforeErrorSampling =
3227+
SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> true }
3228+
val sut = fixture.getSut()
3229+
3230+
sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) })
3231+
assertTrue(called)
3232+
}
3233+
3234+
@Test
3235+
fun `beforeErrorSampling not set proceeds with captureReplay`() {
3236+
var called = false
3237+
fixture.sentryOptions.setReplayController(
3238+
object : ReplayController by NoOpReplayController.getInstance() {
3239+
override fun captureReplay(isTerminating: Boolean?) {
3240+
called = true
3241+
}
3242+
}
3243+
)
3244+
val sut = fixture.getSut()
3245+
3246+
sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) })
3247+
assertTrue(called)
3248+
}
3249+
3250+
@Test
3251+
fun `beforeErrorSampling throwing exception proceeds with captureReplay`() {
3252+
var called = false
3253+
fixture.sentryOptions.setReplayController(
3254+
object : ReplayController by NoOpReplayController.getInstance() {
3255+
override fun captureReplay(isTerminating: Boolean?) {
3256+
called = true
3257+
}
3258+
}
3259+
)
3260+
fixture.sentryOptions.sessionReplay.beforeErrorSampling =
3261+
SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> throw RuntimeException("test") }
3262+
val sut = fixture.getSut()
3263+
3264+
sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) })
3265+
assertTrue(called)
3266+
}
3267+
3268+
@Test
3269+
fun `beforeErrorSampling receives correct event and hint`() {
3270+
var receivedEvent: SentryEvent? = null
3271+
var receivedHint: Hint? = null
3272+
fixture.sentryOptions.setReplayController(
3273+
object : ReplayController by NoOpReplayController.getInstance() {
3274+
override fun captureReplay(isTerminating: Boolean?) {}
3275+
}
3276+
)
3277+
fixture.sentryOptions.sessionReplay.beforeErrorSampling =
3278+
SentryReplayOptions.BeforeErrorSamplingCallback { event, hint ->
3279+
receivedEvent = event
3280+
receivedHint = hint
3281+
true
3282+
}
3283+
val sut = fixture.getSut()
3284+
3285+
val event = SentryEvent().apply { exceptions = listOf(SentryException()) }
3286+
val hint = Hint()
3287+
sut.captureEvent(event, hint)
3288+
assertSame(event, receivedEvent)
3289+
assertSame(hint, receivedHint)
3290+
}
3291+
31983292
@Test
31993293
fun `captures replay for cached events with apply scope`() {
32003294
var called = false

0 commit comments

Comments
 (0)