Skip to content

Commit 23af62e

Browse files
runningcodeclaude
andcommitted
refactor(replay): Move ReplaySnapshotObserver to SentryReplayOptions with Hint API (JAVA-504)
Move ReplaySnapshotObserver from the replay module to SentryReplayOptions in the core module and change the callback signature to use Hint instead of Bitmap. The bitmap is now accessible via TypeCheckHint.REPLAY_FRAME_BITMAP. This allows configuring the observer during Sentry.init{} alongside other replay options, removing the need to cast replayController to ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a829fe commit 23af62e

8 files changed

Lines changed: 89 additions & 47 deletions

File tree

sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import android.graphics.Bitmap
44
import android.os.Environment
55
import androidx.lifecycle.Lifecycle
66
import androidx.test.core.app.launchActivity
7-
import io.sentry.Sentry
8-
import io.sentry.android.replay.ReplayIntegration
9-
import io.sentry.android.replay.ReplaySnapshotObserver
7+
import io.sentry.SentryReplayOptions
8+
import io.sentry.TypeCheckHint
109
import java.io.File
1110
import java.util.concurrent.CopyOnWriteArraySet
1211
import java.util.concurrent.CountDownLatch
@@ -43,17 +42,21 @@ class ReplaySnapshotTest : BaseUiTest() {
4342
val activityScenario = launchActivity<ComposeActivity>()
4443
activityScenario.moveToState(Lifecycle.State.RESUMED)
4544

46-
initSentry { it.sessionReplay.sessionSampleRate = 1.0 }
47-
48-
val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration
49-
integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
50-
val name = screenName ?: "unknown"
51-
if (capturedScreens.add(name)) {
52-
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
53-
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
54-
}
55-
bitmap.recycle()
56-
frameReceived.countDown()
45+
initSentry {
46+
it.sessionReplay.sessionSampleRate = 1.0
47+
it.sessionReplay.snapshotObserver =
48+
SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName ->
49+
val bitmap =
50+
hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
51+
?: return@ReplaySnapshotObserver
52+
val name = screenName ?: "unknown"
53+
if (capturedScreens.add(name)) {
54+
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
55+
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
56+
}
57+
bitmap.recycle()
58+
frameReceived.countDown()
59+
}
5760
}
5861

5962
assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame")

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
6565
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
6666
public final fun getReplayCacheDir ()Ljava/io/File;
6767
public fun getReplayId ()Lio/sentry/protocol/SentryId;
68-
public final fun getSnapshotObserver ()Lio/sentry/android/replay/ReplaySnapshotObserver;
6968
public fun isDebugMaskingOverlayEnabled ()Z
7069
public fun isRecording ()Z
7170
public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V
@@ -79,15 +78,10 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
7978
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
8079
public fun resume ()V
8180
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
82-
public final fun setSnapshotObserver (Lio/sentry/android/replay/ReplaySnapshotObserver;)V
8381
public fun start ()V
8482
public fun stop ()V
8583
}
8684

87-
public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver {
88-
public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V
89-
}
90-
9185
public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback {
9286
public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V
9387
public abstract fun onScreenshotRecorded (Ljava/io/File;J)V

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.view.MotionEvent
77
import io.sentry.Breadcrumb
88
import io.sentry.DataCategory.All
99
import io.sentry.DataCategory.Replay
10+
import io.sentry.Hint
1011
import io.sentry.IConnectionStatusProvider.ConnectionStatus
1112
import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED
1213
import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver
@@ -20,6 +21,7 @@ import io.sentry.SentryLevel.DEBUG
2021
import io.sentry.SentryLevel.ERROR
2122
import io.sentry.SentryLevel.INFO
2223
import io.sentry.SentryOptions
24+
import io.sentry.TypeCheckHint
2325
import io.sentry.android.replay.ReplayState.CLOSED
2426
import io.sentry.android.replay.ReplayState.PAUSED
2527
import io.sentry.android.replay.ReplayState.RESUMED
@@ -123,8 +125,6 @@ public class ReplayIntegration(
123125
private val lifecycleLock = AutoClosableReentrantLock()
124126
private val lifecycle = ReplayLifecycle()
125127

126-
@Volatile public var snapshotObserver: ReplaySnapshotObserver? = null
127-
128128
override fun register(scopes: IScopes, options: SentryOptions) {
129129
this.options = options
130130

@@ -311,12 +311,14 @@ public class ReplayIntegration(
311311
var screen: String? = null
312312
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
313313
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
314-
val observer = snapshotObserver
314+
val observer = options.sessionReplay.snapshotObserver
315315
if (observer != null) {
316316
val copy = bitmap.copy(bitmap.config!!, false)
317317
if (copy != null) {
318318
try {
319-
observer.onSnapshotCaptured(copy, frameTimeStamp, screen)
319+
val hint = Hint()
320+
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy)
321+
observer.onSnapshotCaptured(hint, frameTimeStamp, screen)
320322
} catch (e: Throwable) {
321323
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
322324
copy.recycle()

sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package io.sentry.android.replay
22

3-
import android.graphics.Bitmap
43
import io.sentry.SentryReplayOptions
5-
import org.jetbrains.annotations.ApiStatus
64

75
// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as
86
// properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter
@@ -31,17 +29,3 @@ public var SentryReplayOptions.maskAllImages: Boolean
3129
@Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR)
3230
get() = error("Getter not supported")
3331
set(value) = setMaskAllImages(value)
34-
35-
/**
36-
* Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking
37-
* already applied.
38-
*
39-
* **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on
40-
* another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly.
41-
*
42-
* The callback runs on a background thread (the replay executor).
43-
*/
44-
@ApiStatus.Experimental
45-
public fun interface ReplaySnapshotObserver {
46-
public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?)
47-
}

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import io.sentry.SentryEvent
1818
import io.sentry.SentryIntegrationPackageStorage
1919
import io.sentry.SentryOptions
2020
import io.sentry.SentryReplayEvent.ReplayType
21+
import io.sentry.SentryReplayOptions
22+
import io.sentry.TypeCheckHint
2123
import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT
2224
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
2325
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
@@ -994,12 +996,13 @@ class ReplayIntegrationTest {
994996
replay.register(fixture.scopes, fixture.options)
995997
replay.start()
996998

997-
replay.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
998-
callbackInvoked = true
999-
receivedTimestamp = frameTimestamp
1000-
receivedScreen = screenName
1001-
receivedBitmap = bitmap
1002-
}
999+
fixture.options.sessionReplay.snapshotObserver =
1000+
SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName ->
1001+
callbackInvoked = true
1002+
receivedTimestamp = frameTimestamp
1003+
receivedScreen = screenName
1004+
receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
1005+
}
10031006

10041007
val copyBitmap = mock<Bitmap>()
10051008
val sourceBitmap =
@@ -1033,7 +1036,8 @@ class ReplayIntegrationTest {
10331036
replay.register(fixture.scopes, fixture.options)
10341037
replay.start()
10351038

1036-
replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") }
1039+
fixture.options.sessionReplay.snapshotObserver =
1040+
SentryReplayOptions.ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") }
10371041

10381042
val sourceBitmap =
10391043
mock<Bitmap> {

sentry/api/sentry.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4069,6 +4069,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40694069
public fun getSessionDuration ()J
40704070
public fun getSessionSampleRate ()Ljava/lang/Double;
40714071
public fun getSessionSegmentDuration ()J
4072+
public fun getSnapshotObserver ()Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;
40724073
public fun isCaptureSurfaceViews ()Z
40734074
public fun isDebug ()Z
40744075
public fun isNetworkCaptureBodies ()Z
@@ -4090,6 +4091,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40904091
public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V
40914092
public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V
40924093
public fun setSessionSampleRate (Ljava/lang/Double;)V
4094+
public fun setSnapshotObserver (Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;)V
40934095
public fun setTrackConfiguration (Z)V
40944096
public fun trackCustomMasking ()V
40954097
}
@@ -4098,6 +4100,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin
40984100
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
40994101
}
41004102

4103+
public abstract interface class io/sentry/SentryReplayOptions$ReplaySnapshotObserver {
4104+
public abstract fun onSnapshotCaptured (Lio/sentry/Hint;JLjava/lang/String;)V
4105+
}
4106+
41014107
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
41024108
public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality;
41034109
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
@@ -4644,6 +4650,7 @@ public final class io/sentry/TypeCheckHint {
46444650
public static final field OKHTTP_RESPONSE Ljava/lang/String;
46454651
public static final field OPEN_FEIGN_REQUEST Ljava/lang/String;
46464652
public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String;
4653+
public static final field REPLAY_FRAME_BITMAP Ljava/lang/String;
46474654
public static final field SENTRY_DART_SDK_NAME Ljava/lang/String;
46484655
public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String;
46494656
public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String;

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ public interface BeforeErrorSamplingCallback {
3636
boolean execute(@NotNull SentryEvent event, @NotNull Hint hint);
3737
}
3838

39+
/**
40+
* Observer that is notified when a replay snapshot is captured. The snapshot bitmap (with masking
41+
* applied) is passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}.
42+
*
43+
* <p>On Android, retrieve the bitmap with: {@code hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP,
44+
* Bitmap.class)}.
45+
*
46+
* <p>The callback runs on a background thread (replay executor). The bitmap is a copy owned by
47+
* the caller. Call {@code Bitmap.recycle()} when done to free native memory.
48+
*/
49+
@ApiStatus.Experimental
50+
public interface ReplaySnapshotObserver {
51+
/**
52+
* Called when a replay snapshot is captured.
53+
*
54+
* @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP}
55+
* @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured
56+
* @param screenName the current screen name, or {@code null} if unknown
57+
*/
58+
void onSnapshotCaptured(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName);
59+
}
60+
3961
private static final String CUSTOM_MASKING_INTEGRATION_NAME = "ReplayCustomMasking";
4062
private volatile boolean customMaskingTracked = false;
4163

@@ -211,6 +233,8 @@ public enum SentryReplayQuality {
211233
*/
212234
private @Nullable BeforeErrorSamplingCallback beforeErrorSampling;
213235

236+
@ApiStatus.Experimental private @Nullable ReplaySnapshotObserver snapshotObserver;
237+
214238
public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {
215239
if (!empty) {
216240
// Add default mask classes directly without setting usingCustomMasking flag
@@ -550,4 +574,25 @@ public void setBeforeErrorSampling(
550574
final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) {
551575
this.beforeErrorSampling = beforeErrorSampling;
552576
}
577+
578+
/**
579+
* Gets the observer that is notified when a replay snapshot is captured.
580+
*
581+
* @return the observer, or {@code null} if not set
582+
*/
583+
@ApiStatus.Experimental
584+
public @Nullable ReplaySnapshotObserver getSnapshotObserver() {
585+
return snapshotObserver;
586+
}
587+
588+
/**
589+
* Sets the observer that is notified when a replay snapshot is captured. The frame bitmap is
590+
* passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}.
591+
*
592+
* @param snapshotObserver the observer, or {@code null} to clear
593+
*/
594+
@ApiStatus.Experimental
595+
public void setSnapshotObserver(final @Nullable ReplaySnapshotObserver snapshotObserver) {
596+
this.snapshotObserver = snapshotObserver;
597+
}
553598
}

sentry/src/main/java/io/sentry/TypeCheckHint.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,7 @@ public final class TypeCheckHint {
140140

141141
/** Used for Ktor Request breadcrumbs. */
142142
public static final String KTOR_CLIENT_REQUEST = "ktorClient:request";
143+
144+
/** Used for Session Replay frame bitmaps in the ReplaySnapshotObserver callback. */
145+
public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap";
143146
}

0 commit comments

Comments
 (0)