Skip to content

fix(replay): mask Compose Multiplatform views whole - #1074

Open
turnipdabeets wants to merge 3 commits into
mainfrom
fix/replay-mask-compose-multiplatform
Open

turnipdabeets wants to merge 3 commits into
mainfrom
fix/replay-mask-compose-multiplatform

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Closes #1072.

Session replay recorded the contents of Compose Multiplatform text fields readable, with the default masking options.

Compose Multiplatform draws its whole UI into a few views it creates in the androidx.compose.ui.window Kotlin package: a container, a Metal surface (CMPMetalLayer) and input views. There's no UIKit text field, label or image view per element, so the mask walk finds nothing to mask inside them. Before #896 (3.94.0), replay didn't capture Compose screens at all, because nothing triggered a UIKit layout. Since #896, it captures them, unmasked.

This masks those views whole when maskAllTextInputs or maskAllImages is on, the same way a WKWebView is masked, since the SDK can't see inside either:

  • findMaskableWidgets masks a Compose Multiplatform view whole, then keeps walking its subviews like the label, text view and image view branches, so an unclipped UIKit subview (an interop view, say) drawing outside it keeps its own mask, and a ph-no-capture one still masks every descendant.
  • The GPU inline mask path tags it .opaque.
  • ph-no-mask on the view or above it still opts out, since it's checked first.

Kotlin/Native registers these classes under names such as Sharedandroidx.compose.ui.window.ComposeContainerView3 (module name, Kotlin class name, counter), so they're matched by name: NSStringFromClass(viewClass).contains("androidx.compose.ui.window."). I read the names from a running Compose Multiplatform 1.12.2 app:

SampleAppandroidx.compose.ui.window.ComposeContainerView3
  SampleAppandroidx.compose.ui.window.BackgroundInputView12
  SampleAppandroidx.compose.ui.window.SurfaceMetalView7   (layer: CMPMetalLayer)
  SampleAppandroidx.compose.ui.window.OverlayInputView9

Compose Multiplatform always runs from Kotlin/Native, so this reaches apps that use posthog-kmp and native apps that embed a Compose screen from a shared KMP module. posthog-kmp 0.11 adopts posthog-ios 4.0 (PostHog/posthog-kmp#108), so it gets this through the next main merge into the v4 branch.

sdk-specs: follows session-replay-privacy rule 11 (fail closed: don't upload known-sensitive unredacted content). Android masks Compose content element by element through its semantics tree. iOS masks the whole Compose view, which is coarser.

💚 How did you test it?

End to end on an iPhone 14 Pro Max and on the iOS simulator (iPhone 17 Pro, iOS 26.4). I built the posthog-kmp sample against this branch as a local Swift package, with a Compose screen holding a TextField (SECRET-PASSWORD-123) and an OutlinedTextField. Replay uploads went through a local logging proxy, and I decoded the screenshots:

Masking Result
Default (maskAllTextInputs, maskAllImages on) The Compose view is one black block, on the device (at the latest commit) and the simulator. One frame was uploaded, since a fully masked screen doesn't change.
Both off Every frame shows the screen with both fields readable (57 frames on the device, 29 on the simulator), so the black frame comes from the mask and not a capture failure.
Before this change, on the 4.0 branch Fields readable with default masking.

Unit tests: PostHogComposeMultiplatformMaskingTest registers UIView subclasses under Kotlin/Native-style names with objc_allocateClassPair. It covers masking under each option, no mask with both off, ph-no-mask, the surface without its container, an unclipped text field drawn outside the Compose view, the descendants of a ph-no-capture Compose view, and a class name that only looks similar. They pass in CI. They don't run locally, because XCTest on this machine hangs before connecting on every simulator, including for unchanged suites.

build-for-testing (iOS simulator), make lint and make checkTestRegistration pass.

📝 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. (No SDK docs mention Compose. posthog-kmp's docs say to tag the Compose view ph-no-capture until this ships.)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code (Claude Opus 5.5) found this while releasing posthog-kmp 0.11: Compose screens went from no replay frames on posthog-ios 3.64.1 to readable frames on 4.0, which traced back to #896.

It followed the existing web view precedent instead of adding a public option. It matched views by Kotlin package name rather than by the CMPMetalLayer class, because the package covers the container and overlays too, and doesn't depend on how a given Compose version renders. Only Compose Multiplatform 1.12.2 was checked. It also chose not to make isNoCapture() match these views, since that would turn off tap autocapture too.

🤖 Generated with Claude Code

Compose Multiplatform draws its UI into a few views in the
androidx.compose.ui.window Kotlin package (a container, a Metal surface
and input views), with no UIKit view per text field, text or image. The
mask walk found nothing to mask inside them, so since #896 started
capturing Compose screens, a Compose TextField was recorded readable with
the default masking options.

Match those views by their Kotlin/Native class name and mask them whole
when text or image masking is on, the way a WKWebView is masked. The GPU
inline mask path tags them opaque too. ph-no-mask still opts out.

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

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-11-14T20:50:29.313449+00:00
Duration: 223760ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 3440ms
Format Validation.Event Has Uuid ✅ 2641ms
Format Validation.Event Has Lib Properties ✅ 2661ms
Format Validation.Distinct Id Is String ✅ 2708ms
Format Validation.Token Is Present ✅ 2676ms
Format Validation.Custom Properties Preserved ✅ 2828ms
Format Validation.Event Has Timestamp ✅ 301ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2639ms
Retry Behavior.Retries On 503 ✅ 11854ms
Retry Behavior.Does Not Retry On 400 ✅ 2248ms
Retry Behavior.Does Not Retry On 401 ✅ 4750ms
Retry Behavior.Respects Retry After Header ✅ 7745ms
Retry Behavior.Implements Backoff ✅ 15375ms
Retry Behavior.Retries On 500 ✅ 9257ms
Retry Behavior.Retries On 502 ✅ 9236ms
Retry Behavior.Retries On 504 ✅ 9529ms
Retry Behavior.Max Retries Respected ❌ 21405ms
Deduplication.Generates Unique Uuids ✅ 2816ms
Deduplication.Preserves Uuid On Retry ✅ 9138ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10429ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7856ms
Deduplication.No Duplicate Events In Batch ✅ 2813ms
Deduplication.Different Events Have Different Uuids ✅ 2880ms
Compression.Sends Gzip When Enabled ✅ 2839ms
Batch Format.Uses Proper Batch Structure ✅ 2816ms
Batch Format.Flush With No Events Sends Nothing ✅ 330ms
Batch Format.Multiple Events Batched Together ✅ 2917ms
Error Handling.Does Not Retry On 403 ✅ 4762ms
Error Handling.Does Not Retry On 413 ✅ 4782ms
Error Handling.Retries On 408 ✅ 9230ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 6

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2775ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2698ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2806ms
Request Payload.Flags Request Omits Authorization Header ✅ 2793ms
Request Payload.Token In Flags Body Matches Init ✅ 2713ms
Request Payload.Groups Round Trip ✅ 2746ms
Request Payload.Groups Default To Empty Object ✅ 2785ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2696ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2778ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2637ms
Request Lifecycle.No Flags Request On Init Alone ✅ 78ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2686ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5506ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2686ms
Retry Behavior.Retries Flags On 502 ✅ 2686ms
Retry Behavior.Retries Flags On 504 ✅ 2749ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3000ms

@turnipdabeets
turnipdabeets marked this pull request as ready for review November 14, 2026 01:56
@turnipdabeets
turnipdabeets requested a review from a team as a code owner November 14, 2026 01:56
@veria-ai

veria-ai Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

PR overview

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

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

Reviews

@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 a2f3d88: 1 Must fix.

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.

A Compose Multiplatform view tagged ph-no-capture, or under a no-capture
ancestor, is left to the end of the walk again, which masks the view and
every descendant, so an unclipped subview outside its rect stays masked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mask a Compose Multiplatform view whole and then keep walking its
subviews, as for labels, text views and image views, so an unclipped
UIKit subview drawing outside its rect, such as an interop text field,
keeps its own mask. Replaces the no-capture special case from the
previous commit, which the walk now covers.

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

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.

Session replay doesn't mask text fields inside a Compose Multiplatform view

1 participant