feat(replay): Add ReplaySnapshotObserver for snapshot testing#5386
feat(replay): Add ReplaySnapshotObserver for snapshot testing#5386runningcode wants to merge 15 commits into
Conversation
|
📲 Install BuildsAndroid
|
5b10cdd to
e7452f7
Compare
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 27d7cf8 | 309.43 ms | 364.27 ms | 54.85 ms |
| ee35ac3 | 346.83 ms | 435.48 ms | 88.65 ms |
| 2124a46 | 319.19 ms | 415.04 ms | 95.85 ms |
| f6cdbf0 | 314.19 ms | 357.59 ms | 43.40 ms |
| 22f4345 | 325.23 ms | 454.66 ms | 129.43 ms |
| fcec2f2 | 357.47 ms | 447.32 ms | 89.85 ms |
| f064536 | 329.00 ms | 395.62 ms | 66.62 ms |
| bb0ff41 | 344.70 ms | 413.82 ms | 69.12 ms |
| d15471f | 361.89 ms | 378.07 ms | 16.18 ms |
| e59e22a | 368.02 ms | 432.00 ms | 63.98 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 27d7cf8 | 1.58 MiB | 2.12 MiB | 549.42 KiB |
| ee35ac3 | 1.58 MiB | 2.13 MiB | 558.77 KiB |
| 2124a46 | 1.58 MiB | 2.12 MiB | 551.51 KiB |
| f6cdbf0 | 0 B | 0 B | 0 B |
| 22f4345 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| fcec2f2 | 1.58 MiB | 2.12 MiB | 551.50 KiB |
| f064536 | 1.58 MiB | 2.20 MiB | 633.90 KiB |
| bb0ff41 | 0 B | 0 B | 0 B |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| e59e22a | 1.58 MiB | 2.20 MiB | 635.34 KiB |
Previous results on branch: no/java-504-replay-before-store-frame-callback
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1cddad0 | 342.73 ms | 424.14 ms | 81.41 ms |
| 8d0611b | 308.40 ms | 356.90 ms | 48.50 ms |
| cd28dc9 | 316.30 ms | 354.64 ms | 38.34 ms |
| 62bcea4 | 359.22 ms | 426.90 ms | 67.67 ms |
| a9ea107 | 311.04 ms | 361.61 ms | 50.57 ms |
| b002297 | 298.42 ms | 348.54 ms | 50.12 ms |
| c906754 | 338.11 ms | 408.86 ms | 70.75 ms |
| 91a12dd | 326.85 ms | 373.23 ms | 46.37 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1cddad0 | 0 B | 0 B | 0 B |
| 8d0611b | 0 B | 0 B | 0 B |
| cd28dc9 | 0 B | 0 B | 0 B |
| 62bcea4 | 0 B | 0 B | 0 B |
| a9ea107 | 0 B | 0 B | 0 B |
| b002297 | 0 B | 0 B | 0 B |
| c906754 | 0 B | 0 B | 0 B |
| 91a12dd | 0 B | 0 B | 0 B |
e7452f7 to
2aff4b0
Compare
| @Before | ||
| fun setup() { | ||
| // GH Actions emulators don't support capturing screenshots for replay | ||
| @Suppress("KotlinConstantConditions") |
There was a problem hiding this comment.
I copied this from ReplayTest but why are we even running these on emulators in gh actions?
There was a problem hiding this comment.
i think the purpose of this tests running in gh actions is split in two parts:
- Run our SDK on a test app which uses newer AGP versions to catch issues like Build sample app against AGP matrix on CI #2093 early
- Compile the test app against only
sentry-android-corewithout integrations to catch issues like CIRCULAR REFERENCE: com.android.tools.r8.utils.b: Missing class io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter #2738 early
That said, we probably don't even need to run the app, just compiling it would be sufficient to catch these two potential issues. Actually running the tests was a nice addition to also verify the SDK behaviour at runtime, considering the two things above (e.g. if R8 strips out some code over-aggressively it'd crash at runtime)
markushi
left a comment
There was a problem hiding this comment.
LGTM! Let's discuss first if the API should be experimental for internal, but other than that no blockers.
| * 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 |
0xadam-brown
left a comment
There was a problem hiding this comment.
Thanks for this @runningcode. A few comments.
Big picture, did we think about defining a new ReplayScreenshotObserver interface inside the replay module (akin to ScreenshotRecorderCallback) rather than routing through the (universally available) SentryReplayOptions?
An interface in the replay module would let us avoid the Hint indirection, and it'd avoid the tension of putting @ApiStatus.Internal on an *Options member.
Thoughts?
Add an experimental callback that fires right before a replay frame is
stored to disk. The callback receives the masked bitmap (via Hint),
timestamp, and current screen name. This enables snapshot testing of
replay masking without needing to decode stored video segments.
Includes a Kotlin extension for ergonomic usage:
options.sessionReplay.beforeStoreFrame { bitmap, ts, screen -> ... }
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…(JAVA-504) Add ReplaySnapshotTest that uses the beforeStoreFrame callback to capture masked replay frames during a Compose UI test. Frames are written to the Downloads/sauce_labs_custom_screenshots/ directory, which is the standard path Sauce Labs collects screenshots from. CI changes: - Add *.png to Sauce Labs artifact match patterns - Upload collected replay snapshots via sentry-cli build snapshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…VA-504) The Kotlin extension `beforeStoreFrame` comes from `sentry-android-replay` which may not resolve in the UI test module. Use the Java callback API directly instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…VA-504) GH Actions emulators don't support screenshot capture for replay, so the ReplaySnapshotTest needs the same assumeThat guard used by ReplayTest. Also adds a changelog entry for the beforeStoreFrame callback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Markus Hintersteiner <markus.hintersteiner@sentry.io>
…r (JAVA-504) Move the frame observer API from the core sentry module to sentry-android-replay so it can use Bitmap directly instead of the Hint indirection. The new ReplaySnapshotObserver fun interface lives in the replay module and is set on ReplayIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in test (JAVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…AVA-504) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
375389b to
f2c0c49
Compare
|
|
||
| buildFeatures { buildConfig = true } | ||
|
|
||
| configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } |
There was a problem hiding this comment.
I need to dig deeper in to why we need to add this to all the modules that add libs.jetbrains.annotations another day.
…VA-504) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…AVA-504) Move ReplaySnapshotTest to a conditional androidTestReplay source set so it's only compiled when APPLY_SENTRY_INTEGRATIONS is true. The test imports replay classes that aren't on the classpath otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…VA-504) Consumers of the observer API receive a copy of the bitmap instead of the replay system's shared instance. This eliminates race conditions and crashes when consumers store or use the bitmap asynchronously. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| observer.onSnapshotCaptured(copy, frameTimeStamp, screen) | ||
| } catch (e: Throwable) { | ||
| options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e) | ||
| copy.recycle() |
There was a problem hiding this comment.
I would not have thought of calling copy.recycle() if we get an exception here. AI is smart.
| val file = File(snapshotsDir, "${name}_$frameTimestamp.png") | ||
| file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } | ||
| } | ||
| bitmap.recycle() |
There was a problem hiding this comment.
with the bitmap copy, we now have to make sure that we call bitmap.recycle() as should consumers of our API.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4a829fe. Configure here.
| } | ||
|
|
||
| @Test | ||
| fun captureComposeReplayFrameSnapshots() { |
There was a problem hiding this comment.
just FYI, this is a pretty contrived test but it just makes sure we can capture a replay using the new snapshot observer api
| private val lifecycleLock = AutoClosableReentrantLock() | ||
| private val lifecycle = ReplayLifecycle() | ||
|
|
||
| @Volatile public var snapshotObserver: ReplaySnapshotObserver? = null |
There was a problem hiding this comment.
this awkward API is the consequence of moving the API to the sentry-andorid-replay api
| captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> | ||
| val observer = snapshotObserver | ||
| if (observer != null) { | ||
| val copy = bitmap.copy(bitmap.config!!, false) |
There was a problem hiding this comment.
we copy the bitmap now
| val observer = snapshotObserver | ||
| if (observer != null) { | ||
| val copy = bitmap.copy(bitmap.config!!, false) | ||
| if (copy != null) { |
There was a problem hiding this comment.
no point in calling the API if we have a null bitmap
| private val lifecycleLock = AutoClosableReentrantLock() | ||
| private val lifecycle = ReplayLifecycle() | ||
|
|
||
| @Volatile public var snapshotObserver: ReplaySnapshotObserver? = null |
There was a problem hiding this comment.
❗- Ah, I thought we were going the SentryReplayOptions route to avoid allowing folks to mutate configs after Sentry.init() returns (+ for enhanced visibility and better ergonomics)?
There was a problem hiding this comment.
Oh, then I misunderstood, this is the same API as in your branch which is what I thought we agreed to: a97de37#diff-cb8ccc45cef66aca485d205c9e99c17c114b6319ab9b7d52173b52ee72b047a8R126
We just need to make it public here instead of private in your branch because it needs to be usable from other packages i.e. other apps.
| private val lifecycleLock = AutoClosableReentrantLock() | ||
| private val lifecycle = ReplayLifecycle() | ||
|
|
||
| @Volatile public var snapshotObserver: ReplaySnapshotObserver? = null |
| captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> | ||
| val observer = snapshotObserver | ||
| if (observer != null) { | ||
| val copy = bitmap.copy(bitmap.config!!, false) |
There was a problem hiding this comment.
We should have our try block below enclose bitmap.copy(), esp given the !! 👍
There was a problem hiding this comment.
I think the only types of errors that can happen when copying the bitmap are memory errors and we shouldn't try to catch those.
As for the !! I don't see how bitmap.config can be null if it is a valid bitmap. Do you know how it could be null?
There was a problem hiding this comment.
javadoc says:
If the bitmap's internal config is in one of the public formats, return that config, otherwise return null.
that said, I think in our case it will never be null since we always use ARGB_8888
| } | ||
| } | ||
| addFrame(bitmap, frameTimeStamp, screen) | ||
| } |
There was a problem hiding this comment.
❗- It's not in the diff, so apologies for missing it the first time round, but I see there's an onScreenshotRecorded(File, Long) method as well as the Bitmap version above. I imagine we need to call the observer in both....
There was a problem hiding this comment.
yep, that one is used by Flutter exclusively, but would be good to align here and also call the observer 👍
| * **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on | ||
| * another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly. | ||
| * | ||
| * The callback runs on a background thread (the replay executor). |
There was a problem hiding this comment.
Worth emphasizing that folks should keep processing quick or hand off to another thread. (Otherwise they'll slow down the SDK's replay pipeline.)
| captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> | ||
| val observer = snapshotObserver | ||
| if (observer != null) { | ||
| val copy = bitmap.copy(bitmap.config!!, false) |
There was a problem hiding this comment.
Newbie question, but are we able to observe the performance hit of copying on our end?
Esp if we want to expand our use-cases beyond testing or debugging at some point, it'd be good to know whether we need to optimize.
There was a problem hiding this comment.
Memory-wise, each bitmap is roughly 900kb and we produce one frame per second.
There was a problem hiding this comment.
900kb sounds like a lot, last time check it was way less 😅 I guess it depends on complexity of app's UI as well as the device used. We'll probably have to look into this as part of SDK Perf overhead initiative, and we even have an issue for that: #4154. I'd imagine we could use webp without losing anything, but that's for later
| ### 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)) |
There was a problem hiding this comment.
Worth noting that this is for use in snapshot tests and for debugging purposes and isn't currently optimized for production use.
There was a problem hiding this comment.
agree, and could we also add a short code snippet showcasing the usage? (in Kotlin would be enough)
There was a problem hiding this comment.
Do you mean a snippet here in the CHANGELOG ?
There was a problem hiding this comment.
yes! you can find some example down there in the already released versions
…with Hint API (JAVA-504)
Move ReplaySnapshotObserver from the replay module to SentryReplayOptions
in the core module and change the callback signature to use Hint instead
of Bitmap. The bitmap is now accessible via TypeCheckHint.REPLAY_FRAME_BITMAP.
This allows configuring the observer during Sentry.init{} alongside other
replay options, removing the need to cast replayController to
ReplayIntegration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| dependencies { | ||
| api(projects.sentry) | ||
|
|
||
| compileOnly(libs.jetbrains.annotations) |
There was a problem hiding this comment.
not sure if I missed something, but this one turns out to be unused?
There was a problem hiding this comment.
It is used for the @ApiStatus.Experimental annotation.
There was a problem hiding this comment.
but that one is in sentry and not in sentry-android-replay?
There was a problem hiding this comment.
Oh i see, this is leftover from a previous refactoring! thanks!
| * the caller. Call {@code Bitmap.recycle()} when done to free native memory. | ||
| */ | ||
| @ApiStatus.Experimental | ||
| public interface ReplaySnapshotObserver { |
There was a problem hiding this comment.
Not sure, but the word "Snapshot" may be confusing, but I also don't really have a better word for it 😅 (other than "frame" or "screenshot", which are also not the best)
There was a problem hiding this comment.
Actually this is a good point, since we are using snapshots for a different produce. I like frame better.
| @ApiStatus.Experimental | ||
| public interface ReplaySnapshotObserver { | ||
| /** | ||
| * Called when a replay snapshot is captured. |
There was a problem hiding this comment.
I guess important to note here that it's already a masked snapshot/frame. This could potentially read like "captured, but not yet masked" (especially, if we introduce another callback in the future, like onCapturedButNotMasked", like in getsentry/sentry#86905)
…A-504) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…r (JAVA-504) Rename the interface to ReplayFrameObserver and the callback method to onMaskedFrameCaptured to clarify that frames have masking applied. Also update the changelog with a usage snippet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Summary
ReplaySnapshotObserverfunctional interface that fires right before a replay frame is stored to disk, receiving the masked bitmap, timestamp, and screen namesnapshotObserveronReplayIntegrationso callers can set it after SDK initReplaySnapshotTestUI integration test that captures masked replay frames on Sauce LabsuseTestStorageServiceand*.pngartifact collection in the Sauce Labs configReplaySnapshotTestto a conditionalandroidTestReplaysource set so it compiles only whenAPPLY_SENTRY_INTEGRATIONS=trueRelates to JAVA-504
🤖 Generated with Claude Code