Skip to content

Commit b002297

Browse files
authored
Merge 23af62e into 5dee26b
2 parents 5dee26b + 23af62e commit b002297

11 files changed

Lines changed: 277 additions & 0 deletions

File tree

.github/workflows/integration-tests-ui.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ jobs:
7373
if: env.SAUCE_USERNAME != null
7474

7575

76+
- name: Install Sentry CLI
77+
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
78+
run: curl -sL https://sentry.io/get-cli/ | bash
79+
80+
- name: Upload Replay Snapshots to Sentry
81+
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
82+
run: |
83+
shopt -s globstar nullglob
84+
pngs=(artifacts/**/*.png)
85+
if [ ${#pngs[@]} -gt 0 ]; then
86+
mkdir -p replay-snapshots
87+
cp "${pngs[@]}" replay-snapshots/
88+
sentry-cli build snapshots ./replay-snapshots \
89+
--app-id sentry-android-replay
90+
else
91+
echo "No replay snapshot files found, skipping upload"
92+
fi
93+
env:
94+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
95+
SENTRY_ORG: sentry-sdks
96+
SENTRY_PROJECT: sentry-android
97+
7698
- name: Upload test results to Codecov
7799
if: ${{ !cancelled() }}
78100
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3

.sauce/sentry-uitest-android-ui.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ artifacts:
3232
when: always
3333
match:
3434
- junit.xml
35+
- "*.png"
3536
directory: ./artifacts/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- 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))
8+
- Session Replay: Add `ReplaySnapshotObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))
89

910
### Dependencies
1011

sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ android {
8383

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

86+
if (applySentryIntegrations) {
87+
android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java")
88+
}
89+
8690
dependencies {
8791
implementation(
8892
kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.sentry.uitest.android
2+
3+
import android.graphics.Bitmap
4+
import android.os.Environment
5+
import androidx.lifecycle.Lifecycle
6+
import androidx.test.core.app.launchActivity
7+
import io.sentry.SentryReplayOptions
8+
import io.sentry.TypeCheckHint
9+
import java.io.File
10+
import java.util.concurrent.CopyOnWriteArraySet
11+
import java.util.concurrent.CountDownLatch
12+
import java.util.concurrent.TimeUnit
13+
import kotlin.test.Test
14+
import kotlin.test.assertTrue
15+
import org.hamcrest.CoreMatchers.`is`
16+
import org.junit.Assume.assumeThat
17+
import org.junit.Before
18+
19+
class ReplaySnapshotTest : BaseUiTest() {
20+
21+
@Before
22+
fun setup() {
23+
// GH Actions emulators don't support capturing screenshots for replay
24+
@Suppress("KotlinConstantConditions")
25+
assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true))
26+
}
27+
28+
@Test
29+
fun captureComposeReplayFrameSnapshots() {
30+
val snapshotsDir =
31+
File(
32+
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
33+
"sauce_labs_custom_screenshots",
34+
)
35+
.apply {
36+
deleteRecursively()
37+
mkdirs()
38+
}
39+
val frameReceived = CountDownLatch(1)
40+
val capturedScreens = CopyOnWriteArraySet<String>()
41+
42+
val activityScenario = launchActivity<ComposeActivity>()
43+
activityScenario.moveToState(Lifecycle.State.RESUMED)
44+
45+
initSentry {
46+
it.sessionReplay.sessionSampleRate = 1.0
47+
it.sessionReplay.snapshotObserver =
48+
SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName ->
49+
val bitmap =
50+
hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
51+
?: return@ReplaySnapshotObserver
52+
val name = screenName ?: "unknown"
53+
if (capturedScreens.add(name)) {
54+
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
55+
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
56+
}
57+
bitmap.recycle()
58+
frameReceived.countDown()
59+
}
60+
}
61+
62+
assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame")
63+
assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured")
64+
65+
val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList()
66+
assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk")
67+
assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty")
68+
69+
activityScenario.moveToState(Lifecycle.State.DESTROYED)
70+
}
71+
}

sentry-android-replay/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ android {
6161

6262
buildFeatures { buildConfig = true }
6363

64+
configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) }
65+
6466
androidComponents.beforeVariants {
6567
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
6668
}
@@ -71,6 +73,7 @@ kotlin { explicitApi() }
7173
dependencies {
7274
api(projects.sentry)
7375

76+
compileOnly(libs.jetbrains.annotations)
7477
compileOnly(libs.androidx.compose.ui.replay)
7578
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
7679
// tests

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.view.MotionEvent
77
import io.sentry.Breadcrumb
88
import io.sentry.DataCategory.All
99
import io.sentry.DataCategory.Replay
10+
import io.sentry.Hint
1011
import io.sentry.IConnectionStatusProvider.ConnectionStatus
1112
import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED
1213
import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver
@@ -17,8 +18,10 @@ import io.sentry.ReplayBreadcrumbConverter
1718
import io.sentry.ReplayController
1819
import io.sentry.SentryIntegrationPackageStorage
1920
import io.sentry.SentryLevel.DEBUG
21+
import io.sentry.SentryLevel.ERROR
2022
import io.sentry.SentryLevel.INFO
2123
import io.sentry.SentryOptions
24+
import io.sentry.TypeCheckHint
2225
import io.sentry.android.replay.ReplayState.CLOSED
2326
import io.sentry.android.replay.ReplayState.PAUSED
2427
import io.sentry.android.replay.ReplayState.RESUMED
@@ -308,6 +311,20 @@ public class ReplayIntegration(
308311
var screen: String? = null
309312
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
310313
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
314+
val observer = options.sessionReplay.snapshotObserver
315+
if (observer != null) {
316+
val copy = bitmap.copy(bitmap.config!!, false)
317+
if (copy != null) {
318+
try {
319+
val hint = Hint()
320+
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy)
321+
observer.onSnapshotCaptured(hint, frameTimeStamp, screen)
322+
} catch (e: Throwable) {
323+
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
324+
copy.recycle()
325+
}
326+
}
327+
}
311328
addFrame(bitmap, frameTimeStamp, screen)
312329
}
313330
checkCanRecord()

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import io.sentry.SentryEvent
1818
import io.sentry.SentryIntegrationPackageStorage
1919
import io.sentry.SentryOptions
2020
import io.sentry.SentryReplayEvent.ReplayType
21+
import io.sentry.SentryReplayOptions
22+
import io.sentry.TypeCheckHint
2123
import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT
2224
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
2325
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
@@ -63,6 +65,7 @@ import org.mockito.kotlin.anyOrNull
6365
import org.mockito.kotlin.argThat
6466
import org.mockito.kotlin.check
6567
import org.mockito.kotlin.doAnswer
68+
import org.mockito.kotlin.doReturn
6669
import org.mockito.kotlin.eq
6770
import org.mockito.kotlin.mock
6871
import org.mockito.kotlin.never
@@ -969,6 +972,106 @@ class ReplayIntegrationTest {
969972
assertFalse(replay.isDebugMaskingOverlayEnabled)
970973
}
971974

975+
@Test
976+
fun `snapshot observer is invoked with bitmap and metadata`() {
977+
var callbackInvoked = false
978+
var receivedTimestamp = 0L
979+
var receivedScreen: String? = null
980+
var receivedBitmap: Bitmap? = null
981+
982+
val captureStrategy =
983+
mock<CaptureStrategy> {
984+
doAnswer {
985+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
986+
fixture.replayCache,
987+
1720693523997,
988+
)
989+
}
990+
.whenever(mock)
991+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
992+
}
993+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
994+
995+
fixture.scopes.configureScope { it.screen = "MainActivity" }
996+
replay.register(fixture.scopes, fixture.options)
997+
replay.start()
998+
999+
fixture.options.sessionReplay.snapshotObserver =
1000+
SentryReplayOptions.ReplaySnapshotObserver { hint, frameTimestamp, screenName ->
1001+
callbackInvoked = true
1002+
receivedTimestamp = frameTimestamp
1003+
receivedScreen = screenName
1004+
receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
1005+
}
1006+
1007+
val copyBitmap = mock<Bitmap>()
1008+
val sourceBitmap =
1009+
mock<Bitmap> {
1010+
on { config } doReturn ARGB_8888
1011+
on { copy(any(), any()) } doReturn copyBitmap
1012+
}
1013+
replay.onScreenshotRecorded(sourceBitmap)
1014+
1015+
assertTrue(callbackInvoked)
1016+
assertEquals(1720693523997, receivedTimestamp)
1017+
assertEquals("MainActivity", receivedScreen)
1018+
assertEquals(copyBitmap, receivedBitmap)
1019+
}
1020+
1021+
@Test
1022+
fun `snapshot observer exception does not prevent frame storage`() {
1023+
val captureStrategy =
1024+
mock<CaptureStrategy> {
1025+
doAnswer {
1026+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
1027+
fixture.replayCache,
1028+
1720693523997,
1029+
)
1030+
}
1031+
.whenever(mock)
1032+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
1033+
}
1034+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
1035+
1036+
replay.register(fixture.scopes, fixture.options)
1037+
replay.start()
1038+
1039+
fixture.options.sessionReplay.snapshotObserver =
1040+
SentryReplayOptions.ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") }
1041+
1042+
val sourceBitmap =
1043+
mock<Bitmap> {
1044+
on { config } doReturn ARGB_8888
1045+
on { copy(any(), any()) } doReturn mock<Bitmap>()
1046+
}
1047+
replay.onScreenshotRecorded(sourceBitmap)
1048+
1049+
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
1050+
}
1051+
1052+
@Test
1053+
fun `snapshot observer is not invoked when null`() {
1054+
val captureStrategy =
1055+
mock<CaptureStrategy> {
1056+
doAnswer {
1057+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
1058+
fixture.replayCache,
1059+
1720693523997,
1060+
)
1061+
}
1062+
.whenever(mock)
1063+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
1064+
}
1065+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
1066+
1067+
replay.register(fixture.scopes, fixture.options)
1068+
replay.start()
1069+
1070+
replay.onScreenshotRecorded(mock<Bitmap>())
1071+
1072+
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
1073+
}
1074+
9721075
private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy =
9731076
SessionCaptureStrategy(
9741077
options,

sentry/api/sentry.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4069,6 +4069,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40694069
public fun getSessionDuration ()J
40704070
public fun getSessionSampleRate ()Ljava/lang/Double;
40714071
public fun getSessionSegmentDuration ()J
4072+
public fun getSnapshotObserver ()Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;
40724073
public fun isCaptureSurfaceViews ()Z
40734074
public fun isDebug ()Z
40744075
public fun isNetworkCaptureBodies ()Z
@@ -4090,6 +4091,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40904091
public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V
40914092
public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V
40924093
public fun setSessionSampleRate (Ljava/lang/Double;)V
4094+
public fun setSnapshotObserver (Lio/sentry/SentryReplayOptions$ReplaySnapshotObserver;)V
40934095
public fun setTrackConfiguration (Z)V
40944096
public fun trackCustomMasking ()V
40954097
}
@@ -4098,6 +4100,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin
40984100
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
40994101
}
41004102

4103+
public abstract interface class io/sentry/SentryReplayOptions$ReplaySnapshotObserver {
4104+
public abstract fun onSnapshotCaptured (Lio/sentry/Hint;JLjava/lang/String;)V
4105+
}
4106+
41014107
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
41024108
public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality;
41034109
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
@@ -4644,6 +4650,7 @@ public final class io/sentry/TypeCheckHint {
46444650
public static final field OKHTTP_RESPONSE Ljava/lang/String;
46454651
public static final field OPEN_FEIGN_REQUEST Ljava/lang/String;
46464652
public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String;
4653+
public static final field REPLAY_FRAME_BITMAP Ljava/lang/String;
46474654
public static final field SENTRY_DART_SDK_NAME Ljava/lang/String;
46484655
public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String;
46494656
public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String;

0 commit comments

Comments
 (0)