fix(replay): verify mask alignment instead of discarding frames on any redraw - #676
fix(replay): verify mask alignment instead of discarding frames on any redraw#676arnohillen wants to merge 9 commits into
Conversation
|
|
@arnohillen, this requires manual testing; otherwise, we risk leaking PII. Have you tested this, or are you purely relying on the unit tests? |
🦔 ReviewHog reviewed this pull requestFound 1 must fix, 1 should fix, 0 consider. Published 2 findings (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Changes
Issues: 6 issues
Files (3)
.changeset/replay-animated-screens-capture.mdposthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.ktposthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt
|
Verified: I ran the Android sample app in screenshot-based session replay mode with a continuously animated loading spinner visible. Before the patch, every capture attempt during the animation was discarded and no replay frames were sent; after the patch, frames were captured continuously with no discard errors, and the animation was represented by changing screenshot payloads. |
awesome, @dustinbyrne @arnohillen worth testing this on react native as well since react native relies on that and sometimes there are some incompatibilities |
…y redraw Screenshot captures were discarded whenever the window redrew during PixelCopy unless an animation-type heuristic matched (hasTransientState, surface/texture views). Most animations (indeterminate spinners such as ProgressDialog, animated GIFs, Lottie, Material progress indicators, Compose infinite animations) match neither signal, so screens showing them produced no replay frames at all. The draw-dirty flags were also shared across all tracked windows, so an animating dialog blanked the static activity behind it. Scope draw-dirty tracking per window and replace the heuristics with direct verification: sample mask rects before and after the pixel copy and keep the frame only when they are identical, no layout pass ran, and the walks saw nothing untrustworthy. Fail closed when a walk meets a rendered view with unknowable geometry (legacy view animation, transient state), when the Compose semantics pass times out, and when PixelCopy times out before masks are painted. Closes #596 Generated-By: PostHog Code Task-Id: 3e1a675f-53c2-4356-9a2f-079d36be9790
…esets Two review findings on the discard guard: Check walk poison before the clean-frame path: a poisoned walk's rect set may be silently incomplete (pruned unstable view, timed-out Compose semantics pass), so keeping a clean frame and painting the incomplete post-walk rects would ship the unmasked content. Drop the drawState.reset() from the PixelCopy callback's finally block: after a latch timeout the callback can fire while a newer capture for the same window is in flight, and the stale reset erased draw/layout flags that capture depended on. The reset at capture start (and in the executor's finally) already provides per-capture hygiene. Generated-By: PostHog Code Task-Id: 3e1a675f-53c2-4356-9a2f-079d36be9790
Trim multi-line comments to single-line WHYs and let the code carry the WHAT: the pruned-but-rendered poison condition moves into a named helper (isRenderedButUnplaceable), and the post-walk skip condition into an alreadyDoomed val. Generated-By: PostHog Code Task-Id: 3e1a675f-53c2-4356-9a2f-079d36be9790
7abe3a3 to
999a082
Compare
|
working as expected on RN |
There was a problem hiding this comment.
ReviewHog Report
Changes
Issues: 2 issues
Files (5)
.changeset/replay-animated-screens-capture.mdposthog-android/build.gradle.ktsposthog-android/gradle.lockfileposthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.ktposthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt
… per-frame path The draw-time walk now streams rects against the capture baseline (stopping on the first mismatch) instead of building and comparing a list per frame, reuses walk-confined scratch Rect/Point objects, and tracks visited views in a primitive int set instead of boxing. Captures that can no longer be kept fail fast: a draw or layout during the pre-walk skips the bitmap and PixelCopy entirely, and draw sampling stops once a capture verdict is sealed. Also drops the redundant rootView attach check and the per-walk text/hint toString() copies. Discard semantics are unchanged: the streaming compare is exactly the old order-sensitive list equality, and early exits only skip work on frames already sealed as discard. Benchmarked with PostHogReplayMaskWalkBenchmark (POSTHOG_BENCHMARK=1): the per-frame walk on a 550-view tree drops from 7.2ms to 4.5ms in Robolectric, with all walk-attributable allocation removed. Generated-By: PostHog Code Task-Id: 49d78ac2-4f35-4fbb-91b9-1ca26cf852ce
The draw-generation check ran only between its sample point and beginMaskCapture, and its fail-fast variant discarded on a lock that recordDraw contended every frame. Arming the capture before the pre-walk closes the same window with clearer ownership: a lock-free monotone draw counter (main thread is the single writer) is snapshotted at arming and checked when the pre-walk fixes the baseline, so a draw overlapping the pre-walk deterministically discards even when its sample loses the lock race to setBaseline. Draws after the baseline keep being verified by rect comparison, so pixel-only animation frames still capture. Keeping pre-walk-overlapping frames on rect agreement was considered and rejected: the pre-walk can already reflect that draw's view tree while PixelCopy freezes the frame before it, so agreement proves nothing about the shipped pixels (three independent adversarial reviews each constructed that interleaving). Generated-By: PostHog Code Task-Id: 49d78ac2-4f35-4fbb-91b9-1ca26cf852ce
Generated-By: PostHog Code Task-Id: 49d78ac2-4f35-4fbb-91b9-1ca26cf852ce
Generated-By: PostHog Desktop Task-Id: 6ee186ae-f972-424a-9636-b747d12e9cc8
|
@arnohillen what do you think about putting this behind a configuration variable? my concern is that this could end up having a performance impact, especially on lower-end hardware or cases with an unusual amount of layout rects (just a guess, happy to hear push back on this). if so, i'm happy to do the work! |
Yes, I think that's a great idea! I'm not 100% certain of this PR so it would be good to be able to revert quickly in case of any issues. |
sounds good, i'll add that in. i've done a good amount of testing with this branch and the overall strategy seems sound. though, i'm not 100% on what we'd consider to promote this to be default on at the moment. we don't have any measurements we can rely on to definitively state that this is performant in all cases. regarding rollback, yes, i agree - that is the scary part. there's really no way of rolling back mobile SDKs considering updating the SDK would require a somewhat lengthy resubmission process and the actual roll out to users can be slow / has a long tail. |
…ures Three CI-run proof layers for the keep/discard machinery: - WindowDrawStateProtocolTest enumerates all 1096 placements of up to three mutate/draw/layout events across one arm attempt's five gaps against the real WindowDrawState, asserting kept frames never coexist with a layout, a pre-walk draw, or an off-baseline draw, and that stable-geometry schedules (pixel-only animations) are always kept. A dedicated test pins the in-flight-sample discard that the atomic-draw enumeration cannot reach. - Property tests: streaming compare-mode verdict is exactly stored-list equality across 10k randomized mutations; IntHashSet matches a HashSet oracle across 55k operations including the zero sentinel and growth; compare mode stores nothing; store mode deep-copies scratch. - An isolated microbenchmark (POSTHOG_BENCHMARK=1, not run in CI) of the changed primitives without the Robolectric walk confound. Generated-By: PostHog Code Task-Id: 49d78ac2-4f35-4fbb-91b9-1ca26cf852ce
💡 Motivation and Context
Closes #596.
In screenshot mode, a frame was discarded whenever its window redrew during the PixelCopy capture, unless an animation-type heuristic matched (
hasTransientStatefrom #529, surface/texture views from #649). Most animated content matches neither signal: indeterminate spinners (ProgressDialog), animated GIFs (Glide/Coil), Lottie (which never sets transient state, so the #529 exemption structurally could not fire for it), Material progress indicators, and Compose infinite animations all redraw per frame on the UI thread. On screens showing any of them, essentially every capture logged "Session Replay screenshot discarded due to screen changes" and the replay showed nothing. On top of that, the draw-dirty flags were single fields shared across all tracked windows, so an animating loader dialog also blanked captures of the static activity behind it.The guard exists for a real reason (#254 / #234): mask rects are computed from live views after the pixels are frozen, so a structural change mid-capture can drift masks off sensitive content. This PR keeps that protection but stops proxying it with "did anything redraw":
isOnDrawnCalled/didLayoutSinceResetmove into aWindowDrawStateonViewTreeSnapshotStatus. PixelCopy copies a single window's surface and masks come from that window's own tree, so one window's draws say nothing about another window's mask alignment.PixelCopy.requestand again in the callback. A dirty frame is kept only when both walks agree, no layout pass ran, and neither walk was poisoned. Pixel-only animation redraws pass this check no matter which library drives them; structural changes still discard. ThehasTransientState/surface-view exemptions are deleted: frames they legitimately kept have stable geometry and pass rect equality anyway, and frames they kept with moving masked geometry were unsafe to keep at all.view.animation, transient state mid-animation), and when the Compose semantics pass times out. Previously such views were silently pruned from the walk, which would have shipped them unmasked. A timed-out PixelCopy latch also no longer ships the bitmap before masks are painted.Behavior is monotone for safety: no frame that was previously discarded for a genuine structural change is now kept, and the discard log line is unchanged for support diagnostics. The default wireframe mode is untouched, and the Flutter/RN forced-screenshot bridge inherits the fix through the same path.
💚 How did you test it?
generateSnapshot+ShadowPixelCopy, using a hook view that injects state changes exactly between the pre- and post-copy walks: dirty-but-stable frame kept (the fix path), masked widget moved mid-capture discarded, layout mid-capture discarded, masked view mid legacy animation fails closed (regression test for the walk-pruning hole), and a redraw+layout in another window no longer discards this window's capture (fails under the old shared-flag code).posthog-androidunit test suite,apiCheck, andspotlessCheckpass locally.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file⚡ Performance
The verification machinery sits on two hot paths: the endpoint walks (twice per ~1s capture) and the draw-time walk, which runs on the main thread on every frame while a capture is in flight, on exactly the animated screens this PR unblocks. Follow-up commits make those paths allocation-free and add fail-fast exits, with discard semantics unchanged:
List<Rect>per frame and comparing it under the lock.Rect/Pointobjects replace the per-viewRect()/Point()allocations, and a primitive int set replaces the boxingMutableSet<Int>for cycle detection.rootViewattach check (verified against AOSP) and the per-walktoString()copies of TextView text/hint are gone.What the numbers show. Two committed benchmarks, both runnable with
POSTHOG_BENCHMARK=1 ./gradlew :posthog-android:testReleaseUnitTest --tests '<name>'(they never run in CI):PostHogReplayMaskWalkMicroBenchmarkisolates the changed data structures (median of 5 batches x 500k ops, 3 runs; bytes/op are exact per-thread allocation counts):PostHogReplayMaskWalkBenchmarkruns the full walk on a real 60/550-view tree. An A/B comparison (4 alternating runs against the pre-optimization commit) shows wall-clock parity within noise there: on Robolectric the walk is dominated by shadow-framework calls both versions make identically. That benchmark's value is the allocation counter and as a regression harness, not as proof of speedup.So the honest claim is: on device, the SDK's own per-frame cost during capture windows (one
Rect+Pointper visited view, one boxedInteger+map entry per view, one storedRectper masked view, a full-list comparison under a lock, ~KB-scale garbage per frame at 60-120 fps) drops to zero allocation and less work per view, mismatching frames stop at the first divergent rect, and unkeepable captures skip the bitmap+PixelCopy+encode entirely. Framework-call count per walk is unchanged.✅ Correctness evidence
All of this runs in CI on every push:
WindowDrawStateProtocolTest): every placement of up to three mutate/draw/layout events across the capture pipeline's five gaps (1,096 schedules, enumerated, count asserted) runs against the realWindowDrawState. Kept frames provably never coexist with a layout, a draw overlapping the pre-walk, or a draw whose geometry differs from the baseline; stable-geometry schedules (pixel-only animations, the bug this PR fixes) are provably always kept.PostHogReplayMaskWalkPropertyTest): the streaming comparison returns exactly the same verdict as the old build-a-list-and-compare across 10,000 randomized baseline mutations (element changed / inserted / removed / swapped / truncated); the primitive int set matches aHashSetoracle across 55,000 operations including the zero-sentinel edge case and growth.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
ProgressBar/AnimatedVectorDrawable/ViewRootImpl), and the fix history of chore: do not capture screenshot during screen changes #254/fix: stop screenshot frames being dropped during animations #529/fix(replay): keep frames on screens with continuous surface rendering (e.g. Rive) #649, then adversarially verified claim by claim.setHasTransientStateis only called byViewPropertyAnimator,Editor,Transition, and view-translation in the framework, so the fix: stop screenshot frames being dropped during animations #529 exemption never fired for Lottie or any drawable-level animation; sibling SDKs (posthog-ios, Sentry Android) avoid this bug class by sampling masks synchronously with the frame, which is what the rect pre/post verification approximates while keeping PixelCopy off the main thread.Created with PostHog Code