Skip to content

fix(replay): keep Compose screenshots by routing them onto the verified mask path - #713

Merged
ioannisj merged 11 commits into
mainfrom
posthog-self-driving/fixreplay-stop-discarding-every-057ea8
Sep 1, 2026
Merged

fix(replay): keep Compose screenshots by routing them onto the verified mask path#713
ioannisj merged 11 commits into
mainfrom
posthog-self-driving/fixreplay-stop-discarding-every-057ea8

Conversation

@posthog

@posthog posthog Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

  • Who is hurt: Session replay is non-functional for Jetpack Compose apps — the default UI toolkit for new Android development. Customers get blank gray recordings and, when the first frame is dropped, a "this recording can't be played" error. It fails silently: the SDK logs a discard, support ends up guessing.
  • Root cause: In PostHogReplayIntegration, the default screenshot path (maskLegacyScreenshot) discards a screenshot whenever a draw lands during capture unless the redraw classifier can prove it is animation-only. That classifier needs transient state or a SurfaceView/TextureView in the tree — a Compose window has neither, so every recomposition invalidates the frame.
  • Second symptom: Since 59725fc, a discarded frame emits nothing (not a placeholder), so a dropped first capture leaves no full snapshot and the player cannot start playback.
  • Why now: The fix mostly exists. b4e79ff added real mask-alignment verification that survives pixel-only redraws, but it sits behind verifyScreenshotMaskAlignment, an experimental flag defaulting to false.

💚 How did you test it?

  • New unit tests in PostHogReplayIntegrationTest:
    • a Compose-rooted window uses the verified path even when verifyScreenshotMaskAlignment is off (the legacy animation-redraw classifier does not run for it);
    • a plain View window still uses the legacy classifier (control);
    • a run of discarded captures raises one warning.
  • Ran the full :posthog-android unit test suite, plus spotlessCheck, detekt, and apiCheck — all green.

Changes

  • shouldVerifyMaskAlignment(view, drawState) returns true when the flag is set or the window is Compose-rooted, so Compose windows use the verified capture path automatically. The result is detected once from the view tree and cached on WindowDrawState, so the redraw handling does not re-walk the tree on every draw.
  • Public API and the default for non-Compose windows are unchanged: a plain View app still uses the legacy path.
  • Session replay logs one warning after 3 consecutive screenshot discards, so a blank recording becomes diagnosable.

📝 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

🤖 Agent context

Autonomy: Fully autonomous

  • Authored by PostHog Desktop (Claude Opus). Investigated the discard path in PostHogReplayIntegration.kt and the b4e79ff verification work, then chose to auto-route Compose windows onto the verified path rather than weakening the legacy redraw classifier — the classifier cannot distinguish a pixel-only redraw from a geometry change on Compose, so keeping frames there risks a PII leak, whereas the verified path compares real mask geometry.
  • Compose-rooted detection is cached per window to avoid a per-draw tree walk.
  • Verified against unit tests only; no manual device run.

Created with PostHog Desktop from this inbox report.

…ed mask path

A Jetpack Compose window recomposes on almost every frame, and the legacy
redraw guard can never classify a Compose redraw as animation-only, so it
discarded every screenshot and the recording showed a blank gray screen.
Since a discarded first frame emits nothing, there was also no full snapshot,
so the player reported "this recording can't be played".

Route Compose-rooted windows onto the mask-alignment verification path (added
in b4e79ff), which compares real mask geometry and keeps frames through
pixel-only redraws. Also log a warning after several consecutive discards so a
blank recording stops being silent.

Generated-By: PostHog Desktop
Task-Id: 3ceeac8c-691c-446e-bf60-790794c09f5a
@posthog

posthog Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

🦔 ReviewHog reviewed this pull request

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

Published 1 finding (view the review).

Resolved comments: 1 fixed

@posthog

posthog Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

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 Author

Choose a reason for hiding this comment

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

ReviewHog Report

Found 1 consider.

The consecutive-discard warning hardcoded "because the screen kept
changing during capture", but recordScreenshotDiscarded is invoked for
every null base64 — including PixelCopy timeouts (latch.await returns
false and logs nothing) and bitmap encoding failures (webpBase64 returns
null silently). On repeated timeouts the operator was told the screen
kept changing, sending support down the wrong path.

Reword the summary to list the possible causes (screen changing,
PixelCopy failing or timing out, bitmap encoding failing) instead of
asserting a single one. Pure log-string change; no capture, masking, or
frame behavior changes. The substring the covering test asserts on
("screenshots in a row") is preserved.

Generated-By: PostHog Desktop
Task-Id: e6bd6325-2e7a-4afa-9936-461f97ebaeea
@dustinbyrne
dustinbyrne requested a review from a team August 24, 2026 15:05
@ioannisj ioannisj self-assigned this Aug 25, 2026
@ioannisj
ioannisj marked this pull request as ready for review August 26, 2026 10:52

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

Looks close, just left some questions and nits.

I also think it's worth running this on a device/emulator before merging, since it moves the mask pre-walk onto the main thread on every capture.

view: View,
drawState: WindowDrawState,
): Boolean {
return config.sessionReplayConfig.verifyScreenshotMaskAlignment ||

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.

[suggestion] This makes the @PostHogExperimental verified path the default for Compose apps, but verifyScreenshotMaskAlignment's KDoc still reads "Defaults to false" with no mention of the Compose opt-in (PostHogSessionReplayConfig.kt:65-72) — so setting it false now looks like it disables verification when it doesn't. I think we need to update that doc alongside this, and it's worth deciding whether the flag stays @PostHogExperimental once its path ships to most new Android apps.

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.

Fixed in 4473a4b, the KDoc now says Compose-rooted windows always take the verified path regardless of the flag, so setting it false doesn't disable verification for them.

Left @PostHogExperimental on it for now. Dropping it is a public API change that'd want its own changeset bump, so I think it's a separate call rather than something to fold into a fix. Worth opening separately?

view: View,
drawState: WindowDrawState,
): ArmedMaskCapture? {
// The whole loop runs in ONE main-thread message so a draw can't land between

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.

[question] Moving the arm loop into one main-thread message is a solid fix for the tearing. It does put the whole pre-walk on main for everyone on the verified path, up to 3 attempts, with the capture thread blocking up to 1s — was that measured on a device? The PR notes unit tests only, and ComposeReplayActivity looks like exactly the harness for a frame-timing check before release.

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.

Measured it. Pixel 8 emulator, ComposeReplayActivity with the infinite animation, 60s of capture, timing runArmMaskCaptureLoop directly rather than inferring it from frame stats.

Main-thread occupancy came out at 0.31ms p50, 1.86ms p95, 4.14ms worst across 57 arm cycles, so 0 of 57 crossed a 60Hz frame budget and the worst was about a quarter of one. The capture thread never got near the 1s ceiling either, worst block was 305ms, and the 3-attempt loop never re-ran once.

One caveat on that block number: it's dominated by waiting for the main thread to dequeue rather than by the walk itself, and this is a software-rendered emulator sitting at ~57% janky frames, so the queue wait is pessimistic. The occupancy figure is the one that decides whether frames drop. I tried the frame-stats A/B you'd expect first and it was useless, the emulator's jank floor swamped the signal entirely.

// Compose can be mounted lazily, so a "not Compose" verdict must be re-checked after a
// layout. A "Compose" verdict never needs re-checking: the window simply stays on the
// verified path (the safe direction), avoiding a tree re-walk on every layout pass.
if (composeRooted == false) {

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.

[suggestion] For a View-rooted window that merely has Compose on the classpath this never settles: every layout clears the verdict and the next draw re-walks the whole tree, and onDrawCallback is the unthrottled draw callback (PostHogReplayIntegration.kt:331). containsComposeView also allocates a fresh mutableSetOf<Int>() per walk, unlike MaskWalk.visitedViews which is reused. Could we bound the re-check — at most once every N ms while it's false — so lazy-mounted Compose still gets picked up without a per-frame walk?

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.

Fixed in e087bdc, the re-check is rate-limited to once a second while the verdict is false, and the walk reuses an IntHashSet instead of allocating a fresh mutableSetOf<Int>() per call, matching MaskWalk.visitedViews.

One correction on the premise though: recordLayout() only clears a false verdict, a Compose verdict is sticky, so it was never re-walking on every layout for Compose-rooted windows. The case you're describing is real for a View-rooted window with Compose on the classpath, which is what the bound fixes.

Worth flagging that my first cut of this broke compose detection failure is not cached and re-runs on the next draw, since an indefinite verdict was spending the re-check budget. That's handled with clearComposeRootCheck() now, and there's a new test that fails if the bound comes out.

}

// Warns once after a run of discards so a silently blank recording stops being invisible.
private fun recordScreenshotDiscarded(drawState: WindowDrawState) {

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.

[question] config.logger only writes when debug = true (PostHogAndroidLogger.kt:12-16), so this warning won't reach a production build — which is the "support ends up guessing" case the PR description opens with. Is a log the right channel, or should a discard streak surface as a property on the replay payload?

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.

Fair point, you're right that it doesn't reach a production build. PostHogAndroidLogger.isEnabled() returns config.debug, so the warning is invisible in exactly the case the PR description opens with.

I'd rather not add the payload property here though. .capture(postHog) is already used in this file for replay events so the channel exists, but nothing currently attaches diagnostics to the $snapshot payload, and inventing snapshot metadata wants a posthog-js parity check on the contract first. Happy to take it as a follow-up, or would you rather block this on it?

// discards this tick and retries at the next scheduled snapshot.
private const val MAX_BASELINE_ARM_ATTEMPTS: Int = 3

// Consecutive screenshot discards before warning that the recording may be blank.

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.

[nit] This comment describes CONSECUTIVE_DISCARD_WARNING_THRESHOLD, which lives in ViewTreeSnapshotStatus.kt — sitting here it reads as documenting integrationInstalled.

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.

Fixed in 4473a4b, dropped the comment rather than moving it, CONSECUTIVE_DISCARD_WARNING_THRESHOLD reads clearly enough at its declaration in ViewTreeSnapshotStatus.kt.

@@ -0,0 +1,5 @@
---
"posthog-android": patch

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.

[question] Should this be minor? It's a fix, but it switches the default capture path for every Compose-rooted app and moves where arming runs for anyone already on verifyScreenshotMaskAlignment — a bit more than patch usually implies.

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.

Checked the convention and I think patch is right. Every fix(...) changeset in the last ~30 on this repo used patch regardless of behavioural scope, #722 (replay surviving session rotation), #717 (flush retry semantics) and #714 (date offset overflow) are all the same shape as this one.

Happy to bump it if you'd rather, but it'd be diverging from what the repo's been doing rather than correcting it.

</activity>

<activity
android:name="com.posthog.android.sample.ComposeReplayActivity"

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.

[nit] ComposeReplayActivity isn't reachable from the sample's UI, so it's adb-only. A button on the main screen would make it a usable repro for the next person who hits this.

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.

Fixed in d150e39, there's a Compose Replay button on the main sample screen now.

It was a bit worse than adb-only actually, am start refuses it outright with a SecurityException since the activity isn't exported, so there was no way to reach it without the button. Verified on the emulator that it launches.

@ioannisj
ioannisj enabled auto-merge (squash) September 1, 2026 13:26
@ioannisj
ioannisj merged commit da718a6 into main Sep 1, 2026
15 checks passed
@ioannisj
ioannisj deleted the posthog-self-driving/fixreplay-stop-discarding-every-057ea8 branch September 1, 2026 13:36
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.

2 participants