Skip to content

fix(replay): verify mask alignment instead of discarding frames on any redraw - #676

Open
arnohillen wants to merge 9 commits into
mainfrom
posthog-code/replay-drawable-animation-discard
Open

fix(replay): verify mask alignment instead of discarding frames on any redraw#676
arnohillen wants to merge 9 commits into
mainfrom
posthog-code/replay-drawable-animation-discard

Conversation

@arnohillen

@arnohillen arnohillen commented Aug 5, 2026

Copy link
Copy Markdown

💡 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 (hasTransientState from #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":

  • Per-window draw state: isOnDrawnCalled/didLayoutSinceReset move into a WindowDrawState on ViewTreeSnapshotStatus. 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.
  • Verified masks instead of heuristics: mask rects are sampled before PixelCopy.request and 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. The hasTransientState/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.
  • Fail closed on unknowable geometry: a walk is poisoned (frame discarded) when it meets a rendered view it cannot place (legacy 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?

  • Unit tests for the keep/discard predicate: clean frame, stable rects, moved rects, appeared/disappeared rects, layout pass, poisoned walks.
  • Deterministic end-to-end Robolectric tests through 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).
  • Full posthog-android unit test suite, apiCheck, and spotlessCheck pass locally.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to 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:

  • The draw-time walk streams each mask rect against the capture baseline and stops on the first mismatch, instead of building a List<Rect> per frame and comparing it under the lock.
  • Walk-confined scratch Rect/Point objects replace the per-view Rect()/Point() allocations, and a primitive int set replaces the boxing MutableSet<Int> for cycle detection.
  • Doomed captures fail fast: a draw or layout during the pre-walk skips the ~10MB ARGB_8888 bitmap and PixelCopy entirely (with a bounded re-arm so a busy screen still captures); once a capture's verdict is sealed as discard, per-frame sampling and the post-copy walk stop.
  • The per-frame draw callback is lock-free (a monotone draw counter, main thread is the single writer) and allocation-free: ~7-10 ns when no capture is in flight.
  • The redundant rootView attach check (verified against AOSP) and the per-walk toString() 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):

PostHogReplayMaskWalkMicroBenchmark isolates the changed data structures (median of 5 batches x 500k ops, 3 runs; bytes/op are exact per-thread allocation counts):

Primitive Before After
Visited-set, 60 views 568 ns, 3,936 B 233 ns, 0 B (2.4x faster)
Visited-set, 550 views 5.8 µs, 34,688 B 2.0 µs, 16 B (2.9x faster)
Rect compare, matching frame (40 rects) 310 ns, 1,456 B 326 ns, 0 B (same speed, zero alloc)
Rect compare, mismatching frame (40 rects) 306 ns, 1,456 B 21 ns, 0 B (15x faster, early exit)

PostHogReplayMaskWalkBenchmark runs 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+Point per visited view, one boxed Integer+map entry per view, one stored Rect per 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:

  • Exhaustive protocol check (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 real WindowDrawState. 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.
  • Property tests (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 a HashSet oracle across 55,000 operations including the zero-sentinel edge case and growth.
  • Behavioral tests: 280 unit tests total, including end-to-end Robolectric captures pinning every keep/discard scenario (stable redraw kept; moved/appeared/disappeared masks discarded; layout discarded; poisoned walks fail closed; pre-walk draws re-arm then capture; in-flight draw samples fail closed; per-window isolation).
  • Adversarial review: the capture protocol went through three rounds of independent adversarial verification (multiple reviewers per round, prompted to construct PII-leak interleavings against the real code). The one design they refuted (keeping pre-walk-overlapping frames when rect geometry agrees) was discarded; the shipped protocol is the one that survived.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)


Created with PostHog Code

@arnohillen
arnohillen requested a review from a team as a code owner August 5, 2026 20:52
@arnohillen arnohillen self-assigned this Aug 5, 2026
@arnohillen
arnohillen requested a review from ioannisj August 5, 2026 20:53
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Security Review

Two fail-open paths remain in screenshot masking: poisoned walks are accepted when no redraw is observed, and a late timed-out PixelCopy callback can erase state belonging to a newer capture.

Prompt To Fix All With AI
### Issue 1
posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt:1174-1176
**Poisoned clean frames bypass masking**

When either mask walk is poisoned without an intervening redraw, this early return accepts the frame and paints only the incomplete post-walk rectangles, causing sensitive content to be emitted without its required mask.

**How this was verified:** The capture resets the draw flag before walking, while timeout and unstable-geometry paths poison a walk independently of that flag.

```suggestion
        if (preWalk.poisoned || postWalk.poisoned) {
            return false
        }
        if (!drawState.isOnDrawnCalled) {
            return true
        }
```

### Issue 2
posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt:1284-1287
**Stale callbacks erase capture state**

If a PixelCopy callback arrives after its one-second timeout and another capture for the same window has started, this old callback resets the shared `WindowDrawState`, erasing draw or layout changes from the newer capture and allowing a screenshot with misaligned masks to be accepted.

**How this was verified:** The timeout releases the capture executor while the pending callback retains the same per-window state and resets it from its `finally` block.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(replay): verify mask alignment inste..." | Re-trigger Greptile

@marandaneto

Copy link
Copy Markdown
Member

@arnohillen, this requires manual testing; otherwise, we risk leaking PII. Have you tested this, or are you purely relying on the unit tests?

@marandaneto
marandaneto requested a review from a team August 6, 2026 09:24
@posthog

posthog Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 1 must fix, 1 should fix, 0 consider.

Published 2 findings (view the review).

@posthog

posthog Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 6 issues

Files (3)
  • .changeset/replay-animated-screens-capture.md
  • posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
  • posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt

@dustinbyrne

Copy link
Copy Markdown
Contributor

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.

@marandaneto

Copy link
Copy Markdown
Member

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

arnohillen and others added 4 commits August 10, 2026 10:12
…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
@dustinbyrne
dustinbyrne force-pushed the posthog-code/replay-drawable-animation-discard branch from 7abe3a3 to 999a082 Compare August 10, 2026 14:21
@dustinbyrne

Copy link
Copy Markdown
Contributor

working as expected on RN

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 2 issues

Files (5)
  • .changeset/replay-animated-screens-capture.md
  • posthog-android/build.gradle.kts
  • posthog-android/gradle.lockfile
  • posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
  • posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt

arnohillen and others added 4 commits August 12, 2026 00:22
… 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
arnohillen requested a review from marandaneto August 13, 2026 11:49
@dustinbyrne

Copy link
Copy Markdown
Contributor

@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!

@arnohillen

Copy link
Copy Markdown
Author

@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.

@dustinbyrne

Copy link
Copy Markdown
Contributor

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Some screens appear to be dropped in session replay recordings

3 participants