Skip to content
Open
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
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.


### 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?

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.snapshotObserver =
SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName ->
val bitmap =
hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
?: return@ReplaySnapshotObserver
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)
}
}
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)
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 @@ -7,6 +7,7 @@ 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 +18,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,6 +311,20 @@ public class ReplayIntegration(
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
val observer = options.sessionReplay.snapshotObserver
if (observer != null) {
val copy = bitmap.copy(bitmap.config!!, false)
Comment thread
cursor[bot] marked this conversation as resolved.
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.

we copy the bitmap now

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.

We should have our try block below enclose bitmap.copy(), esp given the !! 👍

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 think the only types of errors that can happen when copying the bitmap are memory errors and we shouldn't try to catch those.
As for the !! I don't see how bitmap.config can be null if it is a valid bitmap. Do you know how it could be null?

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.

if (copy != 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.

no point in calling the API if we have a null bitmap

try {
val hint = Hint()
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy)
observer.onSnapshotCaptured(hint, frameTimeStamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
copy.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.

I would not have thought of calling copy.recycle() if we get an exception here. AI is smart.

}
}
}
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....

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.snapshotObserver =
SentryReplayOptions.ReplaySnapshotObserver { 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.snapshotObserver =
SentryReplayOptions.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
7 changes: 7 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -4069,6 +4069,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun getSessionDuration ()J
public fun getSessionSampleRate ()Ljava/lang/Double;
public fun getSessionSegmentDuration ()J
public fun getSnapshotObserver ()Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;
public fun isCaptureSurfaceViews ()Z
public fun isDebug ()Z
public fun isNetworkCaptureBodies ()Z
Expand All @@ -4090,6 +4091,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V
public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V
public fun setSessionSampleRate (Ljava/lang/Double;)V
public fun setSnapshotObserver (Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;)V
public fun setTrackConfiguration (Z)V
public fun trackCustomMasking ()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$ReplaySnapshotObserver {
public abstract fun onSnapshotCaptured (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