Skip to content

chore!: remove wireframe session replay capture - #908

Open
turnipdabeets wants to merge 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-remove-replay-wireframe
Open

turnipdabeets wants to merge 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-remove-replay-wireframe

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Part of the 4.0 stack, based on #871 (chore/v4-remove-deprecated). Ioannis asked to drop wireframe on Android too, matching posthog-ios 4.0 (PostHog/posthog-ios#907, then PostHog/posthog-ios#973 removed screenshotMode outright).

Android session replay has two capture modes. Wireframe is the default (screenshot = false). It serializes the View tree to JSON. Screenshot captures masked images. This PR removes wireframe, so replay always captures masked screenshots.

Why:

  • Wireframe leaks ph-no-capture containers. sdk-specs compliance/posthog-android.md n26 marks this: toWireframe() never checks isNoCapture() for a tagged container, so its children's text is still serialized in the default mode. The screenshot path masks a tagged view as one rect.
  • Wireframe doesn't see Compose. A ComposeView was one box with a background color. The screenshot path masks Compose through semantics.
  • One capture path instead of two. The recent privacy and alignment work (mask verification, keyboard pan, discard handling) only ever covered the screenshot path.

What changes

Before (default config) After
View-based screen wireframe tree (childWireframes, style, text masked with *) one screenshot wireframe with masked base64, the same payload screenshot = true already sent
Compose screen an empty box recorded as screenshots
PostHogSessionReplayConfig.screenshot selects the mode removed
drawableConverter / PostHogDrawableConverter used by wireframe image capture removed; nothing else read them
$sdk_debug_replay_capture_mode wireframe / screenshot always screenshot

The full snapshot and meta events are the same as what screenshot mode sends today, and the meta href (window title) is unchanged. The player already handles both formats, so the server doesn't need any change.

Public API: removing the option, not deprecating it as a no-op, follows PostHog/posthog-ios#973. An app that set screenshot = false to keep pixels out of recordings now gets a compile error instead of silently uploading screenshots, so it has to choose: mask what's sensitive (ph-no-capture, Modifier.postHogMask(), maskAllImages / maskAllTextInputs) or turn replay off.

Core RRWireframe trimmed (e28cf3d4): it keeps only id, x, y, width, height, type and base64, matching iOS 4.0, and RRStyle is deleted. Each window sends one screenshot node, so the child-flattening tree diff becomes a single-node compare (update when the image or size changes). Screenshot frames no longer send an empty style: {}, the same shape iOS 4.0 sends. posthog-flutter's SnapshotSender passed style = RRStyle(), so it needs PostHog/posthog-flutter#684.

Deleted as orphaned: toWireframe() and its helpers (Drawable.base64/toRGBColor/toBitmap/copy, String.mask(), the theme background lookup) and the forceScreenshot parameter on generateSnapshot.

⚠️ Privacy / behavior notes for reviewers (also in the changeset)

For standard widgets the masking decision is unchanged: both modes used the same shouldMask* / isTextInputSensitive helpers. What changes is everything else:

  • Custom-drawn content can now appear. Wireframe sent a box for any view it didn't recognize (custom onDraw, maps, video, charts). Screenshots render the pixels, and masking only covers recognized widgets plus ph-no-capture / Modifier.postHogMask().
  • Compose apps start recording real content. That's quota and a consent/disclosure change for apps that had replay on but saw empty boxes.
  • Cost: more bandwidth and CPU per frame (PixelCopy + WebP encode).

Downstream PRs

Follow-ups (not in this PR)

💚 How did you test it?

  • :posthog-android:testDebugUnitTest and testWithoutCompose: pass. PostHogReplayIntegrationTest is 133/133.
    • Tests that set screenshot = true now get the same thing from the default config.
    • The race test that used a wireframe capture as the second, unguarded visibility caller now uses a second screenshot capture. Its name says so.
    • The capture-mode debugProperties tests are now one test, which checks that the default config reports screenshot.
    • The batch-request.json event-shape snapshot changes only on that one property.
  • spotlessCheck, apiCheck and the sample app build pass.
  • Published posthog 7.0.0 / posthog-android 4.0.0 from this branch to a local Maven repo and built KMP's downstream PR against it: config tests pass, the sample builds.
  • Not run: on-device manual replay.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (draft docs PRs above; sdk-specs follow-up)
  • No breaking change or entry added to the changelog. (Breaking; added to the 4.0 changeset.)

If releasing new changes

  • Ran pnpm changeset to generate a changeset file (added a bullet to the existing .changeset/android-v4-major.md)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code (Opus 5.5) at the maintainer's request, as a follow-up on the 4.0 stack that mirrors the iOS wireframe removal.
  • Also removed drawableConverter, which was never deprecated, because without wireframe it does nothing. Easy to put back as a deprecated no-op if we'd rather.
  • Kept core RRWireframe / RRStyle unchanged so posthog-flutter keeps compiling.

🤖 Generated with Claude Code

Session replay always records masked screenshots, the mode
`screenshot = true` already used. Remove `PostHogSessionReplayConfig.screenshot`,
and `drawableConverter` / `PostHogDrawableConverter`, which only the
wireframe builder read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

PostHog Review · Standard · reviewed 697f9c0: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

@veria-ai

veria-ai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

With wireframe capture gone, each window sends a single screenshot node,
so the tree fields, RRStyle and the child-flattening diff are unused.
Screenshot frames no longer carry an empty `style`, matching iOS 4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

PostHog Review · Standard · reviewed e28cf3d: 1 Must fix.

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

PostHog Review · Standard · reviewed 37df5a2: 1 Must fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment on lines 763 to +767
val wireframe =
if (useScreenshot) {
view.toScreenshotWireframe(
window,
status.drawState,
) ?: return false
} else {
view.toWireframe() ?: return false
}

// if the decorView has no backgroundColor, we use the theme color
// no need to do this if we are capturing a screenshot
if (wireframe.style?.backgroundColor == null && !useScreenshot) {
context.theme?.toRGBColor()?.let {
wireframe.style?.backgroundColor = it
}
}
view.toScreenshotWireframe(
window,
status.drawState,
) ?: return 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.

P1 · Mask Compose images without accessibility descriptions before capturing pixels

With default replay settings, Compose Image(contentDescription = null) now uploads its pixels despite maskAllImages = true. Compose omits semantics for these images, and findMaskableComposeWidgets identifies images only through ContentDescription; wireframes previously omitted their pixels. Mask these images or conservatively redact their Compose region.

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.

[P1] Mask Compose images independently of accessibility descriptions

Relationship: Existing screenshot defect exposed by default Compose pixel capture
Underlying cause: PostHogReplayIntegration.kt:1278 and 1304–1305

With replay enabled and maskAllImages = true, an ordinary bitmap-backed Compose image is emitted without a mask:

Image(
    bitmap = bitmap.asImageBitmap(),
    contentDescription = null,
    modifier = Modifier.size(100.dp),
)

Expected: mask the image or conservatively suppress unsafe capture—not treat absent accessibility metadata as an unmask instruction.

Image discovery relies solely on SemanticsProperties.ContentDescription. The actual null-description image in the regression supplies neither that property nor Role. Discovery succeeds without poisoning but omits the measured image rectangle. A Role.Image check alone would therefore not fix this case.

Executed regression: Real Compose activity, actual decorated-window capture, Compose 1.11.4 and Robolectric API 35:

Case Base default Reviewed head
Null description, image masking enabled PASS; no image acquisition FAIL; image-region marker remains unmasked
Non-null description, masking enabled PASS PASS; masked
Non-null description, masking disabled PASS PASS; intentionally unmasked
Null description plus postHogMask() PASS PASS; masked

The image bounds were (0,56)–(100,156). Walks completed without poison. The three controls isolate image discovery rather than general Compose setup or masking failure.

This contradicts the local maskAllImages option and canonical privacy Behavior 8. Android’s semantics documentation explains that semantics describes meaning rather than drawing and may omit composables. Exact version behavior was verified through the executed public Image API.

Consider reliable image discovery or a conservative masking fallback for Compose regions whose image content cannot be enumerated. Retain all four controls. Explicit postHogMask() is a supported, tested workaround, not a replacement for automatic image masking.

The base default wireframe path did not acquire Compose image pixels. Screenshot-only capture therefore exposes content existing default replay did not upload; this is not attributed to the latest masking fixes or DTO trim.

Validation limits: Controlled PixelCopy acquisition exercised production mask discovery, masking, encoding and fake-core capture against the actual window.decorView. This does not establish physical-device acquisition, literal secret transmission, real HTTP/backend delivery or player rendering.

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.

Fixing on main in #898: with maskAllImages, the Compose mask pass walks the layout tree and masks painter-backed nodes, so it doesn't rely on ContentDescription or Role. It covers the contentDescription = null case and keeps the vector, postHogUnmask() and masking-off cases unmasked. It reaches 4.0 when main is merged into the 4.0 branches.

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

The animated-view and idle surface-scan fixes are verified. Two default screenshot privacy gaps remain: overflowing descendants of masked containers and Compose images without descriptions. Details are inline and in the existing Compose-image discussion.

AI-assisted review.

wireframe.style?.backgroundColor = it
}
}
view.toScreenshotWireframe(

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.

[P1] Preserve descendant masking beneath an unclipped masked container

Relationship: Existing screenshot defect exposed by the default-mode transition
Underlying cause: PostHogReplayIntegration.kt:1075–1077

With replay enabled and default maskAllTextInputs = true, adding ph-no-capture to an unclipped container can expose its overflowing child text. Expected: retain the container mask and cover the matching descendants.

A supported example:

val child = TextView(context).apply { text = "secret" }
val wrapper = FrameLayout(context).apply {
    tag = "ph-no-capture"
    clipChildren = false
    clipToPadding = false
    addView(child)
}

// Attached hierarchy, with the child visibly outside its parent:
wrapper.layout(0, 0, 100, 20)
child.layout(0, 30, 100, 50)

findMaskableWidgets() records only the tagged container’s rectangle and stops descending. The child consequently loses the automatic text mask it receives without the parent tag.

Executed regression:

  • Native Canvas verifies that the child actually paints outside the wrapper.
  • Layout is settled and ancestors are attached/visible.
  • Wrapper rectangle: (0,56)–(100,76).
  • Child rectangle: (0,86)–(100,106).
  • Base default serializes "******", not "secret".
  • Head fails in both alignment-verification modes: the child-region marker remains unmasked.
  • Removing only the wrapper tag makes the head control pass.

This is neither an animation race nor unrecognized custom drawing. The new poison rejection does not help because the stable tagged parent prevents descendant discovery.

The canonical privacy contract explicitly requires full matched-subtree discovery and the wrapper rectangle alongside matching descendant rectangles: Behavior 4 and the multiple-descendant scenario.

Consider retaining the wrapper mask while continuing descendant coverage, including visible overflow and inherited masking where necessary, while preserving supported unmask precedence. Retain the tagged, verified-tagged and untagged controls.

The screenshot limitation predates this PR, but the base default protected this concrete case. Making screenshots mandatory exposes it to existing replay-enabled applications.

Validation limits: Executed under Robolectric API 26 against the actual window.decorView, with controlled PixelCopy acquisition and production masking, encoding and fake-core capture. Native Canvas independently verifies the overflow rendering. This does not establish physical-device acquisition, literal secret transmission, real HTTP/backend delivery or player rendering.

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.

Fixing on main in #914: a ph-no-capture view and its visible descendants are all masked now, and ph-no-mask descendants still take precedence. It reaches 4.0 when main is merged into the 4.0 branches.

This branch has not been deployed

No deployments
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