Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### 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 `ReplaySnapshotObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))
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.

Worth noting that this is for use in snapshot tests and for debugging purposes and isn't currently optimized for production use.

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.

agree, and could we also add a short code snippet showcasing the usage? (in Kotlin would be enough)

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.

Do you mean a snippet here in the CHANGELOG ?

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.

yes! you can find some example down there in the already released versions


### 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,68 @@
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.Sentry
import io.sentry.android.replay.ReplayIntegration
import io.sentry.android.replay.ReplaySnapshotObserver
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 }

val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration
integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
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()
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.

with the bitmap copy, we now have to make sure that we call bitmap.recycle() as should consumers of our API.

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)
}
}
6 changes: 6 additions & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
public final fun getReplayCacheDir ()Ljava/io/File;
public fun getReplayId ()Lio/sentry/protocol/SentryId;
public final fun getSnapshotObserver ()Lio/sentry/android/replay/ReplaySnapshotObserver;
public fun isDebugMaskingOverlayEnabled ()Z
public fun isRecording ()Z
public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V
Expand All @@ -78,10 +79,15 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
public fun resume ()V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
public final fun setSnapshotObserver (Lio/sentry/android/replay/ReplaySnapshotObserver;)V
public fun start ()V
public fun stop ()V
}

public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver {
public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V
}

public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback {
public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V
public abstract fun onScreenshotRecorded (Ljava/io/File;J)V
Expand Down
3 changes: 3 additions & 0 deletions sentry-android-replay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ android {

buildFeatures { buildConfig = true }

configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) }
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 need to dig deeper in to why we need to add this to all the modules that add libs.jetbrains.annotations another day.


androidComponents.beforeVariants {
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
}
Expand All @@ -71,6 +73,7 @@ kotlin { explicitApi() }
dependencies {
api(projects.sentry)

compileOnly(libs.jetbrains.annotations)
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.

not sure if I missed something, but this one turns out to be unused?

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.

It is used for the @ApiStatus.Experimental annotation.

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.

but that one is in sentry and not in sentry-android-replay?

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.

Oh i see, this is leftover from a previous refactoring! thanks!

compileOnly(libs.androidx.compose.ui.replay)
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
// tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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.android.replay.ReplayState.CLOSED
Expand Down Expand Up @@ -122,6 +123,8 @@ public class ReplayIntegration(
private val lifecycleLock = AutoClosableReentrantLock()
private val lifecycle = ReplayLifecycle()

@Volatile public var snapshotObserver: ReplaySnapshotObserver? = null
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.

this awkward API is the consequence of moving the API to the sentry-andorid-replay api

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.

❗- Ah, I thought we were going the SentryReplayOptions route to avoid allowing folks to mutate configs after Sentry.init() returns (+ for enhanced visibility and better ergonomics)?

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.

Oh, then I misunderstood, this is the same API as in your branch which is what I thought we agreed to: a97de37#diff-cb8ccc45cef66aca485d205c9e99c17c114b6319ab9b7d52173b52ee72b047a8R126

We just need to make it public here instead of private in your branch because it needs to be usable from other packages i.e. other apps.

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.

@Experimental


override fun register(scopes: IScopes, options: SentryOptions) {
this.options = options

Expand Down Expand Up @@ -308,6 +311,18 @@ public class ReplayIntegration(
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
val observer = snapshotObserver
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 {
observer.onSnapshotCaptured(copy, frameTimeStamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.sentry.android.replay

import android.graphics.Bitmap
import io.sentry.SentryReplayOptions
import org.jetbrains.annotations.ApiStatus

// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as
// properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter
Expand Down Expand Up @@ -29,3 +31,17 @@ public var SentryReplayOptions.maskAllImages: Boolean
@Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR)
get() = error("Getter not supported")
set(value) = setMaskAllImages(value)

/**
* Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking
* already applied.
*
* **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on
* another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly.
*
* The callback runs on a background thread (the replay executor).
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.

Worth emphasizing that folks should keep processing quick or hand off to another thread. (Otherwise they'll slow down the SDK's replay pipeline.)

*/
@ApiStatus.Experimental
Comment thread
runningcode marked this conversation as resolved.
Outdated
Comment thread
runningcode marked this conversation as resolved.
Outdated
public fun interface ReplaySnapshotObserver {
public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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 +970,104 @@ 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()

replay.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
callbackInvoked = true
receivedTimestamp = frameTimestamp
receivedScreen = screenName
receivedBitmap = bitmap
}

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()

replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> 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
Loading