Skip to content

Commit 11100f7

Browse files
authored
Merge 7d51e78 into c3ee041
2 parents c3ee041 + 7d51e78 commit 11100f7

10 files changed

Lines changed: 314 additions & 1 deletion

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@
88
- Enable via `options.isAttachRawTombstone = true` or manifest: `<meta-data android:name="io.sentry.tombstone.attach-raw" android:value="true" />`
99
- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426))
1010
- 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))
11+
- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))
12+
13+
```kotlin
14+
SentryAndroid.init(context) { options ->
15+
options.sessionReplay.frameObserver =
16+
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
17+
val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
18+
if (bitmap != null) {
19+
try {
20+
// Process the masked replay frame
21+
myAnalyzer.processFrame(bitmap, frameTimestamp, screenName)
22+
} finally {
23+
bitmap.recycle()
24+
}
25+
}
26+
}
27+
}
28+
```
1129

1230
### Dependencies
1331

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.frameObserver =
48+
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
49+
val bitmap =
50+
hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
51+
?: return@ReplayFrameObserver
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/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package io.sentry.android.replay
22

33
import android.content.Context
44
import android.graphics.Bitmap
5+
import android.graphics.BitmapFactory
56
import android.os.Build
67
import android.view.MotionEvent
78
import io.sentry.Breadcrumb
89
import io.sentry.DataCategory.All
910
import io.sentry.DataCategory.Replay
11+
import io.sentry.Hint
1012
import io.sentry.IConnectionStatusProvider.ConnectionStatus
1113
import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED
1214
import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver
@@ -17,8 +19,10 @@ import io.sentry.ReplayBreadcrumbConverter
1719
import io.sentry.ReplayController
1820
import io.sentry.SentryIntegrationPackageStorage
1921
import io.sentry.SentryLevel.DEBUG
22+
import io.sentry.SentryLevel.ERROR
2023
import io.sentry.SentryLevel.INFO
2124
import io.sentry.SentryOptions
25+
import io.sentry.TypeCheckHint
2226
import io.sentry.android.replay.ReplayState.CLOSED
2327
import io.sentry.android.replay.ReplayState.PAUSED
2428
import io.sentry.android.replay.ReplayState.RESUMED
@@ -308,13 +312,45 @@ public class ReplayIntegration(
308312
var screen: String? = null
309313
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
310314
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
315+
val observer = options.sessionReplay.frameObserver
316+
if (observer != null) {
317+
val copy = bitmap.copy(bitmap.config!!, false)
318+
if (copy != null) {
319+
try {
320+
val hint = Hint()
321+
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy)
322+
observer.onMaskedFrameCaptured(hint, frameTimeStamp, screen)
323+
} catch (e: Throwable) {
324+
options.logger.log(ERROR, "Error in ReplayFrameObserver", e)
325+
copy.recycle()
326+
}
327+
}
328+
}
311329
addFrame(bitmap, frameTimeStamp, screen)
312330
}
313331
checkCanRecord()
314332
}
315333

316334
override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) {
317-
captureStrategy?.onScreenshotRecorded { _ -> addFrame(screenshot, frameTimestamp) }
335+
var screen: String? = null
336+
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
337+
captureStrategy?.onScreenshotRecorded { _ ->
338+
val observer = options.sessionReplay.frameObserver
339+
if (observer != null) {
340+
val bitmap = BitmapFactory.decodeFile(screenshot.absolutePath)
341+
if (bitmap != null) {
342+
try {
343+
val hint = Hint()
344+
hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap)
345+
observer.onMaskedFrameCaptured(hint, frameTimestamp, screen)
346+
} catch (e: Throwable) {
347+
options.logger.log(ERROR, "Error in ReplayFrameObserver", e)
348+
bitmap.recycle()
349+
}
350+
}
351+
}
352+
addFrame(screenshot, frameTimestamp, screen)
353+
}
318354
checkCanRecord()
319355
}
320356

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.frameObserver =
1000+
SentryReplayOptions.ReplayFrameObserver { 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.frameObserver =
1040+
SentryReplayOptions.ReplayFrameObserver { _, _, _ -> 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
@@ -4063,6 +4063,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40634063
public fun addUnmaskViewClass (Ljava/lang/String;)V
40644064
public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;
40654065
public fun getErrorReplayDuration ()J
4066+
public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver;
40664067
public fun getFrameRate ()I
40674068
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
40684069
public fun getNetworkDetailDenyUrls ()Ljava/util/List;
@@ -4085,6 +4086,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40854086
public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V
40864087
public fun setCaptureSurfaceViews (Z)V
40874088
public fun setDebug (Z)V
4089+
public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V
40884090
public fun setMaskAllImages (Z)V
40894091
public fun setMaskAllText (Z)V
40904092
public fun setNetworkCaptureBodies (Z)V
@@ -4105,6 +4107,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin
41054107
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
41064108
}
41074109

4110+
public abstract interface class io/sentry/SentryReplayOptions$ReplayFrameObserver {
4111+
public abstract fun onMaskedFrameCaptured (Lio/sentry/Hint;JLjava/lang/String;)V
4112+
}
4113+
41084114
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
41094115
public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality;
41104116
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
@@ -4651,6 +4657,7 @@ public final class io/sentry/TypeCheckHint {
46514657
public static final field OKHTTP_RESPONSE Ljava/lang/String;
46524658
public static final field OPEN_FEIGN_REQUEST Ljava/lang/String;
46534659
public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String;
4660+
public static final field REPLAY_FRAME_BITMAP Ljava/lang/String;
46544661
public static final field SENTRY_DART_SDK_NAME Ljava/lang/String;
46554662
public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String;
46564663
public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String;

0 commit comments

Comments
 (0)