Repository navigation
fix(replay): mask Compose Multiplatform views whole - #1074
Open
turnipdabeets wants to merge 3 commits into
Open
turnipdabeets wants to merge 3 commits into
turnipdabeets wants to merge 3 commits into
Conversation
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>
5 of 15 tasks
Contributor
posthog-ios Compliance ReportDate: 2026-11-14T20:50:29.313449+00:00
|
| 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
marked this pull request as ready for review
November 14, 2026 01:56
Contributor
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 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.windowKotlin 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
maskAllTextInputsormaskAllImagesis on, the same way aWKWebViewis masked, since the SDK can't see inside either:findMaskableWidgetsmasks 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 aph-no-captureone still masks every descendant..opaque.ph-no-maskon 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: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
mainmerge into the v4 branch.sdk-specs: follows
session-replay-privacyrule 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 anOutlinedTextField. Replay uploads went through a local logging proxy, and I decoded the screenshots:maskAllTextInputs,maskAllImageson)Unit tests:
PostHogComposeMultiplatformMaskingTestregisters UIView subclasses under Kotlin/Native-style names withobjc_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 aph-no-captureCompose 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 lintandmake checkTestRegistrationpass.📝 Checklist
ph-no-captureuntil this ships.)If releasing new changes
pnpm changesetto 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
CMPMetalLayerclass, 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 makeisNoCapture()match these views, since that would turn off tap autocapture too.🤖 Generated with Claude Code