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/
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertTrue

class ReplaySnapshotTest : BaseUiTest() {

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

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

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

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

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.8.0 - Integrations false

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: Expected at least one replay frame

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.7.0 - Integrations false

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: Expected at least one replay frame

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.9.0 - Integrations false

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: Expected at least one replay frame

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.7.0 - Integrations true

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: Expected at least one replay frame

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.9.0 - Integrations true

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: Expected at least one replay frame

Check failure on line 55 in sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

View workflow job for this annotation

GitHub Actions / AGP Matrix Release - AGP 8.8.0 - Integrations true

io.sentry.uitest.android.AutomaticSpansTest ► io.sentry.uitest.android.ReplaySnapshotTest ► captureComposeReplayFrameSnapshots

Failed test found in: sentry-android-integration-tests/sentry-uitest-android/build/outputs/androidTest-results/connected/release/TEST-emulator-5554 - 11-_sentry-android-integration-tests_sentry-uitest-android-.xml Error: java.lang.AssertionError: 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)
}
}
1 change: 1 addition & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public final class io/sentry/android/replay/SentryReplayModifiers {
}

public final class io/sentry/android/replay/SessionReplayOptionsKt {
public static final fun beforeStoreFrame (Lio/sentry/SentryReplayOptions;Lkotlin/jvm/functions/Function3;)V
public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z
public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z
public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V
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,16 @@ public class ReplayIntegration(
var screen: String? = null
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
val callback = options.sessionReplay.beforeStoreFrame
if (callback != null) {
try {
val hint = Hint()
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap)
callback.execute(hint, frameTimeStamp, screen)
} catch (e: Throwable) {
options.logger.log(ERROR, "Error in beforeStoreFrame callback", 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 io.sentry.TypeCheckHint

// 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,28 @@ public var SentryReplayOptions.maskAllImages: Boolean
@Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR)
get() = error("Getter not supported")
set(value) = setMaskAllImages(value)

/**
* Sets a callback that is invoked right before a replay frame is stored to disk. The callback
* receives the frame bitmap (with masking applied), the timestamp, and the current screen name.
*
* The callback runs on a background thread (the replay executor). Do not recycle the bitmap — it
* may be reused by the replay system.
*
* @param callback the callback to invoke, or null to clear
*/
public fun SentryReplayOptions.beforeStoreFrame(
callback: ((frameBitmap: Bitmap, frameTimestamp: Long, screenName: String?) -> Unit)?
) {
beforeStoreFrame =
if (callback != null) {
SentryReplayOptions.BeforeStoreFrameCallback { hint, timestamp, screen ->
val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
if (bitmap != null) {
callback(bitmap, timestamp, screen)
}
}
} else {
null
}
}
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 @@ -969,6 +971,95 @@ class ReplayIntegrationTest {
assertFalse(replay.isDebugMaskingOverlayEnabled)
}

@Test
fun `beforeStoreFrame callback is invoked with bitmap in hint`() {
var callbackInvoked = false
var receivedTimestamp = 0L
var receivedScreen: String? = null
var receivedBitmap: Any? = null

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

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.onScreenshotRecorded(mock<Bitmap>())

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

@Test
fun `beforeStoreFrame callback exception does not prevent frame storage`() {
fixture.options.sessionReplay.beforeStoreFrame =
SentryReplayOptions.BeforeStoreFrameCallback { _, _, _ -> throw RuntimeException("test") }

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

@Test
fun `beforeStoreFrame callback 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 @@ -4055,6 +4055,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun addMaskViewClass (Ljava/lang/String;)V
public fun addUnmaskViewClass (Ljava/lang/String;)V
public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;
public fun getBeforeStoreFrame ()Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback;
public fun getErrorReplayDuration ()J
public fun getFrameRate ()I
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
Expand All @@ -4076,6 +4077,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
public fun isSessionReplayForErrorsEnabled ()Z
public fun isTrackConfiguration ()Z
public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V
public fun setBeforeStoreFrame (Lio/sentry/SentryReplayOptions$BeforeStoreFrameCallback;)V
public fun setCaptureSurfaceViews (Z)V
public fun setDebug (Z)V
public fun setMaskAllImages (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$BeforeStoreFrameCallback {
public abstract fun execute (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
51 changes: 51 additions & 0 deletions sentry/src/main/java/io/sentry/SentryReplayOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@

public final class SentryReplayOptions extends SentryMaskingOptions {

/**
* Callback that is invoked right before a replay frame is stored to disk. This allows
* intercepting frames for testing (e.g., screenshot comparison tests) or custom processing. The
* callback receives the frame after masking has been applied.
*
* <p>The frame bitmap is passed via a {@link Hint} using the key {@link
* TypeCheckHint#REPLAY_FRAME_BITMAP}. On Android, retrieve it with: {@code hint.getAs(
* TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap.class)}.
*
* <p>The callback runs on a background thread (replay executor). Do not recycle the bitmap — it
* may be reused by the replay system.
*/
@ApiStatus.Experimental
public interface BeforeStoreFrameCallback {
/**
* Called before a replay frame is stored to disk.
*
* @param hint contains the frame bitmap under {@link TypeCheckHint#REPLAY_FRAME_BITMAP}
* @param frameTimestamp the timestamp (in milliseconds since epoch) when the frame was captured
* @param screenName the current screen name, or {@code null} if unknown
*/
void execute(@NotNull Hint hint, long frameTimestamp, @Nullable String screenName);
}

/**
* Callback that is called before the error sample rate is checked for session replay. If the
* callback returns {@code false}, the replay will not be captured for this error event, and the
Expand Down Expand Up @@ -211,6 +235,12 @@ public enum SentryReplayQuality {
*/
private @Nullable BeforeErrorSamplingCallback beforeErrorSampling;

/**
* A callback that is invoked right before a replay frame is stored to disk. Can be used for
* screenshot snapshot testing or custom frame processing.
*/
@ApiStatus.Experimental private @Nullable BeforeStoreFrameCallback beforeStoreFrame;

public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {
if (!empty) {
// Add default mask classes directly without setting usingCustomMasking flag
Expand Down Expand Up @@ -550,4 +580,25 @@ public void setBeforeErrorSampling(
final @Nullable BeforeErrorSamplingCallback beforeErrorSampling) {
this.beforeErrorSampling = beforeErrorSampling;
}

/**
* Gets the callback that is invoked before a replay frame is stored to disk.
*
* @return the callback, or {@code null} if not set
*/
@ApiStatus.Experimental
public @Nullable BeforeStoreFrameCallback getBeforeStoreFrame() {
return beforeStoreFrame;
}

/**
* Sets the callback that is invoked before a replay frame is stored to disk. The frame bitmap is
* passed via a {@link Hint} using the key {@link TypeCheckHint#REPLAY_FRAME_BITMAP}.
*
* @param beforeStoreFrame the callback, or {@code null} to clear
*/
@ApiStatus.Experimental
public void setBeforeStoreFrame(final @Nullable BeforeStoreFrameCallback beforeStoreFrame) {
this.beforeStoreFrame = beforeStoreFrame;
}
}
3 changes: 3 additions & 0 deletions sentry/src/main/java/io/sentry/TypeCheckHint.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,7 @@ public final class TypeCheckHint {

/** Used for Ktor Request breadcrumbs. */
public static final String KTOR_CLIENT_REQUEST = "ktorClient:request";

/** Used for Session Replay frame bitmaps in the beforeStoreFrame callback. */
public static final String REPLAY_FRAME_BITMAP = "replay:frameBitmap";
}
Loading