Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d49b64a
feat(replay): Add beforeStoreFrame callback (JAVA-504)
runningcode May 8, 2026
4b5fb3a
feat(replay): Add replay snapshot UI test with Sauce Labs collection …
runningcode May 8, 2026
9d6f12a
fix(replay): Use Java API in snapshot test to avoid extension dep (JA…
runningcode May 8, 2026
240dd96
fix(replay): Skip snapshot test on GH emulators and add changelog (JA…
runningcode May 11, 2026
1f6b03c
Apply suggestion from @markushi
runningcode May 12, 2026
d2b2259
refactor(replay): Replace beforeStoreFrame with ReplaySnapshotObserve…
runningcode May 13, 2026
2b576be
fix(replay): Mark ReplaySnapshotObserver as experimental and use Set …
runningcode May 13, 2026
f2c0c49
fix(replay): Add @ApiStatus.Experimental to ReplaySnapshotObserver (J…
runningcode May 13, 2026
f39d8f5
fix(replay): Make snapshotObserver public for cross-module access (JA…
runningcode May 13, 2026
1720230
fix(replay): Exclude ReplaySnapshotTest when integrations disabled (J…
runningcode May 13, 2026
4a829fe
fix(replay): Copy bitmap before passing to ReplaySnapshotObserver (JA…
runningcode May 13, 2026
23af62e
refactor(replay): Move ReplaySnapshotObserver to SentryReplayOptions …
runningcode May 13, 2026
a7a01d9
fix(replay): Remove unnecessary jetbrains-annotations dependency (JAV…
runningcode May 19, 2026
8a44963
refactor(replay): Rename ReplaySnapshotObserver to ReplayFrameObserve…
runningcode May 19, 2026
6481cdf
Format code
getsentry-bot May 19, 2026
7d51e78
fix(replay): Call onMaskedFrameCaptured in File-based onScreenshotRec…
runningcode May 21, 2026
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
22 changes: 22 additions & 0 deletions .github/workflows/integration-tests-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,28 @@ jobs:
if: env.SAUCE_USERNAME != null


- name: Install Sentry CLI
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
run: curl -sL https://sentry.io/get-cli/ | bash

- name: Upload Replay Snapshots to Sentry
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
run: |
shopt -s globstar nullglob
pngs=(artifacts/**/*.png)
if [ ${#pngs[@]} -gt 0 ]; then
mkdir -p replay-snapshots
cp "${pngs[@]}" replay-snapshots/
sentry-cli build snapshots ./replay-snapshots \
--app-id sentry-android-replay
else
echo "No replay snapshot files found, skipping upload"
fi
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: sentry-sdks
SENTRY_PROJECT: sentry-android

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3
Expand Down
1 change: 1 addition & 0 deletions .sauce/sentry-uitest-android-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ artifacts:
when: always
match:
- junit.xml
- "*.png"
directory: ./artifacts/
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@
### Features

- Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387))
- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))

```kotlin
SentryAndroid.init(context) { options ->
options.sessionReplay.frameObserver =
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
if (bitmap != null) {
try {
// Process the masked replay frame
myAnalyzer.processFrame(bitmap, frameTimestamp, screenName)
} finally {
bitmap.recycle()
}
}
}
}
```

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ android {

val applySentryIntegrations = System.getenv("APPLY_SENTRY_INTEGRATIONS")?.toBoolean() ?: true

if (applySentryIntegrations) {
android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java")
}

dependencies {
implementation(
kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.sentry.uitest.android

import android.graphics.Bitmap
import android.os.Environment
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.launchActivity
import io.sentry.SentryReplayOptions
import io.sentry.TypeCheckHint
import java.io.File
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertTrue
import org.hamcrest.CoreMatchers.`is`
import org.junit.Assume.assumeThat
import org.junit.Before

class ReplaySnapshotTest : BaseUiTest() {

@Before
fun setup() {
// GH Actions emulators don't support capturing screenshots for replay
@Suppress("KotlinConstantConditions")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied this from ReplayTest but why are we even running these on emulators in gh actions?

Copy link
Copy Markdown
Member

@romtsn romtsn May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the purpose of this tests running in gh actions is split in two parts:

That said, we probably don't even need to run the app, just compiling it would be sufficient to catch these two potential issues. Actually running the tests was a nice addition to also verify the SDK behaviour at runtime, considering the two things above (e.g. if R8 strips out some code over-aggressively it'd crash at runtime)

assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true))
}

@Test
fun captureComposeReplayFrameSnapshots() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just FYI, this is a pretty contrived test but it just makes sure we can capture a replay using the new snapshot observer api

val snapshotsDir =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"sauce_labs_custom_screenshots",
)
.apply {
deleteRecursively()
mkdirs()
}
val frameReceived = CountDownLatch(1)
val capturedScreens = CopyOnWriteArraySet<String>()

val activityScenario = launchActivity<ComposeActivity>()
activityScenario.moveToState(Lifecycle.State.RESUMED)

initSentry {
it.sessionReplay.sessionSampleRate = 1.0
it.sessionReplay.frameObserver =
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
val bitmap =
hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
?: return@ReplayFrameObserver
val name = screenName ?: "unknown"
if (capturedScreens.add(name)) {
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
}
bitmap.recycle()
frameReceived.countDown()
}
}

assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame")
assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured")

val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList()
assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk")
assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty")

activityScenario.moveToState(Lifecycle.State.DESTROYED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package io.sentry.android.replay

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.view.MotionEvent
import io.sentry.Breadcrumb
import io.sentry.DataCategory.All
import io.sentry.DataCategory.Replay
import io.sentry.Hint
import io.sentry.IConnectionStatusProvider.ConnectionStatus
import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED
import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver
Expand All @@ -17,8 +19,10 @@ import io.sentry.ReplayBreadcrumbConverter
import io.sentry.ReplayController
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.ERROR
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.TypeCheckHint
import io.sentry.android.replay.ReplayState.CLOSED
import io.sentry.android.replay.ReplayState.PAUSED
import io.sentry.android.replay.ReplayState.RESUMED
Expand Down Expand Up @@ -308,13 +312,45 @@ public class ReplayIntegration(
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
val observer = options.sessionReplay.frameObserver
if (observer != null) {
val copy = bitmap.copy(bitmap.config!!, false)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
runningcode marked this conversation as resolved.
Comment thread
runningcode marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newbie question, but are we able to observe the performance hit of copying on our end?

Esp if we want to expand our use-cases beyond testing or debugging at some point, it'd be good to know whether we need to optimize.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory-wise, each bitmap is roughly 900kb and we produce one frame per second.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

900kb sounds like a lot, last time check it was way less 😅 I guess it depends on complexity of app's UI as well as the device used. We'll probably have to look into this as part of SDK Perf overhead initiative, and we even have an issue for that: #4154. I'd imagine we could use webp without losing anything, but that's for later

if (copy != null) {
Comment thread
runningcode marked this conversation as resolved.
try {
val hint = Hint()
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy)
observer.onMaskedFrameCaptured(hint, frameTimeStamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in ReplayFrameObserver", e)
copy.recycle()
Comment thread
runningcode marked this conversation as resolved.
}
}
}
addFrame(bitmap, frameTimeStamp, screen)
}
Copy link
Copy Markdown
Member

@0xadam-brown 0xadam-brown May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗- It's not in the diff, so apologies for missing it the first time round, but I see there's an onScreenshotRecorded(File, Long) method as well as the Bitmap version above. I imagine we need to call the observer in both....

Copy link
Copy Markdown
Member

@romtsn romtsn May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, that one is used by Flutter exclusively, but would be good to align here and also call the observer 👍

Copy link
Copy Markdown
Contributor Author

@runningcode runningcode May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok added below. Note, we read the file and decode it as a bitmap.

checkCanRecord()
}

override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) {
captureStrategy?.onScreenshotRecorded { _ -> addFrame(screenshot, frameTimestamp) }
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded { _ ->
val observer = options.sessionReplay.frameObserver
if (observer != null) {
val bitmap = BitmapFactory.decodeFile(screenshot.absolutePath)
if (bitmap != null) {
try {
val hint = Hint()
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap)
observer.onMaskedFrameCaptured(hint, frameTimestamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in ReplayFrameObserver", e)
bitmap.recycle()
}
}
}
addFrame(screenshot, frameTimestamp, screen)
}
checkCanRecord()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import io.sentry.SentryEvent
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryOptions
import io.sentry.SentryReplayEvent.ReplayType
import io.sentry.SentryReplayOptions
import io.sentry.TypeCheckHint
import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
Expand Down Expand Up @@ -63,6 +65,7 @@ import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.check
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -969,6 +972,106 @@ class ReplayIntegrationTest {
assertFalse(replay.isDebugMaskingOverlayEnabled)
}

@Test
fun `snapshot observer is invoked with bitmap and metadata`() {
var callbackInvoked = false
var receivedTimestamp = 0L
var receivedScreen: String? = null
var receivedBitmap: Bitmap? = null

val captureStrategy =
mock<CaptureStrategy> {
doAnswer {
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
fixture.replayCache,
1720693523997,
)
}
.whenever(mock)
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
}
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })

fixture.scopes.configureScope { it.screen = "MainActivity" }
replay.register(fixture.scopes, fixture.options)
replay.start()

fixture.options.sessionReplay.frameObserver =
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
callbackInvoked = true
receivedTimestamp = frameTimestamp
receivedScreen = screenName
receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
}

val copyBitmap = mock<Bitmap>()
val sourceBitmap =
mock<Bitmap> {
on { config } doReturn ARGB_8888
on { copy(any(), any()) } doReturn copyBitmap
}
replay.onScreenshotRecorded(sourceBitmap)

assertTrue(callbackInvoked)
assertEquals(1720693523997, receivedTimestamp)
assertEquals("MainActivity", receivedScreen)
assertEquals(copyBitmap, receivedBitmap)
}

@Test
fun `snapshot observer exception does not prevent frame storage`() {
val captureStrategy =
mock<CaptureStrategy> {
doAnswer {
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
fixture.replayCache,
1720693523997,
)
}
.whenever(mock)
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
}
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })

replay.register(fixture.scopes, fixture.options)
replay.start()

fixture.options.sessionReplay.frameObserver =
SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") }

val sourceBitmap =
mock<Bitmap> {
on { config } doReturn ARGB_8888
on { copy(any(), any()) } doReturn mock<Bitmap>()
}
replay.onScreenshotRecorded(sourceBitmap)

verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
}

@Test
fun `snapshot observer is not invoked when null`() {
val captureStrategy =
mock<CaptureStrategy> {
doAnswer {
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
fixture.replayCache,
1720693523997,
)
}
.whenever(mock)
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
}
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })

replay.register(fixture.scopes, fixture.options)
replay.start()

replay.onScreenshotRecorded(mock<Bitmap>())

verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
}

private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy =
SessionCaptureStrategy(
options,
Expand Down
7 changes: 7 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -4056,6 +4056,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun addUnmaskViewClass (Ljava/lang/String;)V
public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;
public fun getErrorReplayDuration ()J
public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver;
public fun getFrameRate ()I
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
public fun getNetworkDetailDenyUrls ()Ljava/util/List;
Expand All @@ -4078,6 +4079,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V
public fun setCaptureSurfaceViews (Z)V
public fun setDebug (Z)V
public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V
public fun setMaskAllImages (Z)V
public fun setMaskAllText (Z)V
public fun setNetworkCaptureBodies (Z)V
Expand All @@ -4098,6 +4100,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
}

public abstract interface class io/sentry/SentryReplayOptions$ReplayFrameObserver {
public abstract fun onMaskedFrameCaptured (Lio/sentry/Hint;JLjava/lang/String;)V
}

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 Expand Up @@ -4644,6 +4650,7 @@ public final class io/sentry/TypeCheckHint {
public static final field OKHTTP_RESPONSE Ljava/lang/String;
public static final field OPEN_FEIGN_REQUEST Ljava/lang/String;
public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String;
public static final field REPLAY_FRAME_BITMAP Ljava/lang/String;
public static final field SENTRY_DART_SDK_NAME Ljava/lang/String;
public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String;
public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String;
Expand Down
Loading
Loading