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

### 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,67 @@
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?

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

@Test
fun captureComposeReplayFrameSnapshots() {
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) }
}
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)
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

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

Expand Down Expand Up @@ -308,6 +311,14 @@ public class ReplayIntegration(
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
val observer = snapshotObserver
if (observer != null) {
try {
observer.onSnapshotCaptured(bitmap, frameTimeStamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
}
}
addFrame(bitmap, frameTimeStamp, screen)
}
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,18 @@ 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 owned by the replay system and may be reused. Do not store a
* reference to it or access it after this method returns — copy the pixel data (e.g., compress to a
* file) within this method if you need it later. Do not recycle the bitmap.
*
* The callback runs on a background thread (the replay executor).
*/
@ApiStatus.Experimental
Comment thread
runningcode marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Annotation is Experimental instead of agreed-upon Internal

Medium Severity

ReplaySnapshotObserver is annotated with @ApiStatus.Experimental but the PR discussion reached clear consensus to use @ApiStatus.Internal instead, to avoid exposing a footgun API (bitmap lifecycle/deadlock risks). Additionally, the snapshotObserver property on ReplayIntegration has no @ApiStatus annotation at all, making the getter/setter fully public in the API surface. Both sites need @ApiStatus.Internal to match the agreed intent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f39d8f5. Configure here.

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 @@ -969,6 +969,93 @@ 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
}

replay.onScreenshotRecorded(mock<Bitmap>())

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

@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") }

replay.onScreenshotRecorded(mock<Bitmap>())

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