Skip to content

Commit 91a12dd

Browse files
authored
Merge 8a44963 into 11f90db
2 parents 11f90db + 8a44963 commit 91a12dd

10 files changed

Lines changed: 293 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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@
66

77
- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426))
88
- 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))
9+
- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))
10+
11+
```kotlin
12+
SentryAndroid.init(context) { options ->
13+
options.sessionReplay.frameObserver =
14+
SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName ->
15+
val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java)
16+
if (bitmap != null) {
17+
try {
18+
// Process the masked replay frame
19+
myAnalyzer.processFrame(bitmap, frameTimestamp, screenName)
20+
} finally {
21+
bitmap.recycle()
22+
}
23+
}
24+
}
25+
}
26+
```
927

1028
### Dependencies
1129

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: 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.frameObserver
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.onMaskedFrameCaptured(hint, frameTimeStamp, screen)
322+
} catch (e: Throwable) {
323+
options.logger.log(ERROR, "Error in ReplayFrameObserver", 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.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
@@ -4060,6 +4060,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40604060
public fun addUnmaskViewClass (Ljava/lang/String;)V
40614061
public fun getBeforeErrorSampling ()Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;
40624062
public fun getErrorReplayDuration ()J
4063+
public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver;
40634064
public fun getFrameRate ()I
40644065
public fun getNetworkDetailAllowUrls ()Ljava/util/List;
40654066
public fun getNetworkDetailDenyUrls ()Ljava/util/List;
@@ -4082,6 +4083,7 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption
40824083
public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V
40834084
public fun setCaptureSurfaceViews (Z)V
40844085
public fun setDebug (Z)V
4086+
public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V
40854087
public fun setMaskAllImages (Z)V
40864088
public fun setMaskAllText (Z)V
40874089
public fun setNetworkCaptureBodies (Z)V
@@ -4102,6 +4104,10 @@ public abstract interface class io/sentry/SentryReplayOptions$BeforeErrorSamplin
41024104
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Z
41034105
}
41044106

4107+
public abstract interface class io/sentry/SentryReplayOptions$ReplayFrameObserver {
4108+
public abstract fun onMaskedFrameCaptured (Lio/sentry/Hint;JLjava/lang/String;)V
4109+
}
4110+
41054111
public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum {
41064112
public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality;
41074113
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
@@ -4648,6 +4654,7 @@ public final class io/sentry/TypeCheckHint {
46484654
public static final field OKHTTP_RESPONSE Ljava/lang/String;
46494655
public static final field OPEN_FEIGN_REQUEST Ljava/lang/String;
46504656
public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String;
4657+
public static final field REPLAY_FRAME_BITMAP Ljava/lang/String;
46514658
public static final field SENTRY_DART_SDK_NAME Ljava/lang/String;
46524659
public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String;
46534660
public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String;

0 commit comments

Comments
 (0)