Repository navigation
fix(replay): mask content drawn outside a view's bounds - #976
Conversation
Screenshot masks only covered each masked view's bounds, so content the view draws past them stayed visible: an unclipped image view's image under aspect-fill or a natural-size content mode, and an unclipped layer shadow (which repeats text). Mask rects now grow by what the view draws past its bounds. A masked UIButton now also walks its subviews, so a title or image laid out past the button by insets gets its own mask. Labels and text views that hold a non-symbol, non-asset image attachment are masked when maskAllImages is on, since no image view exists for the attachment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed ebbe233: 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.
…ment A label or text view masked only for its image attachment returned before visiting its subviews, so an unclipped image overflowing it lost the mask it had before.
posthog-ios Compliance ReportDate: 2026-10-30T15:31:29.815536+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 2998ms |
| Format Validation.Event Has Uuid | ✅ | 2810ms |
| Format Validation.Event Has Lib Properties | ✅ | 2628ms |
| Format Validation.Distinct Id Is String | ✅ | 2765ms |
| Format Validation.Token Is Present | ✅ | 2753ms |
| Format Validation.Custom Properties Preserved | ✅ | 2670ms |
| Format Validation.Event Has Timestamp | ✅ | 2702ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 2692ms |
| Retry Behavior.Retries On 503 | ✅ | 11815ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4709ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 4817ms |
| Retry Behavior.Respects Retry After Header | ✅ | 7802ms |
| Retry Behavior.Implements Backoff | ✅ | 21743ms |
| Retry Behavior.Retries On 500 | ✅ | 8803ms |
| Retry Behavior.Retries On 502 | ✅ | 8767ms |
| Retry Behavior.Retries On 504 | ✅ | 5359ms |
| Retry Behavior.Max Retries Respected | ❌ | 21289ms |
| Deduplication.Generates Unique Uuids | ✅ | 3113ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 9423ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 16725ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 8275ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 3465ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 2978ms |
| Compression.Sends Gzip When Enabled | ✅ | 2854ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 2856ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 405ms |
| Batch Format.Multiple Events Batched Together | ✅ | 2974ms |
| Error Handling.Does Not Retry On 403 | ✅ | 4878ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2303ms |
| Error Handling.Retries On 408 | ✅ | 9292ms |
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 | ✅ | 2852ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 2788ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 2861ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 2920ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 2902ms |
| Request Payload.Groups Round Trip | ✅ | 2755ms |
| Request Payload.Groups Default To Empty Object | ✅ | 2772ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 2829ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 2913ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 2851ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 105ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 2786ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 5440ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 2820ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 2848ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 2831ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 2844ms |
ioannisj
left a comment
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
Should this target main rather than the 4.0 branch? 3.x apps with screenshotMode on have the same leak, main has the same mask seams this patches (MaskedRegion and toPresentationRect in PostHog/Replay/UIView+Util.swift), and the other three findings from that review (#967, #969, #970) went to main first and reached the v4 branch on the merge. Nothing in the diff depends on wireframe removal, so a rebase onto main with a normal patch changeset gets it into the next 3.92.x and the v4 branch picks it up on the next merge. Is there a reason to keep it on the branch?
Code-wise it reads right to me, the aspect-fill, top-left, shadow and button cases all trace to the rects I'd expect. One nit inline on the label and text view walks not carrying the view's own ph-no-capture flag the way the button walk does.
…t views Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5a16a00
into
chore/v4-remove-deprecated
Backport of #976 to 3.x. Screenshot masks only covered each masked view's bounds, so unclipped images, layer shadows, overflowing button titles and subviews of masked text stayed visible. Image attachments in text are masked when maskAllImages is on, in the screenshot walk only so wireframe text is unchanged.
💡 Motivation and Context
Addresses the four bot review findings on #873 (#873). With wireframes gone, session replay always uploads masked screenshots, so a mask has to cover everything a masked view draws. Before this change, masks covered only each view's bounds:
UIImageView(.scaleAspectFill,clipsToBounds = false).centerand.topLeft), across every image source the view can drawUILabelwith an imageNSTextAttachment,maskAllTextInputs = false,maskAllImages = truemaskAllImagesis on (an attachment whose image can't be read is masked)CALayershadowshadowPathUIButtonwith its title moved outside bytitleEdgeInsetsThe image and shadow cases share one helper:
MaskedRegionnow expands each view's (and each iOS 26 layer's) rect by the outsets the view draws past its bounds. When a view clips or has no overflow, the outsets are zero and the mask is unchanged. The added per-view work runs only for views that are already being masked: a few property reads, plus one content-mode calculation for unclipped image views. Attachment detection enumerates one attribute and runs only when text masking didn't already mask the view. ForUITextViewit readstextStorage, so the text isn't copied.Not changed:
UITextFielddropsNSTextAttachments on assignment. A probe on the iOS 26.4 simulator read back"Receipt "with no attachment character, so a text field can't draw one and has nothing to leak.maskAllImagesis on. Measuring the attachment's own rect would need TextKit layout on every frame.💚 How did you test it?
New Swift Testing cases in
PostHogMaskFailClosedTest(iOS-only). Each one failed before the fix and passes after it:.topLeftimage larger than its viewUILabeland aUITextViewwith only image masking on, plus a control: an SF Symbol attachment stays unmaskedtitleEdgeInsetsValidation (local, Xcode 26.4.1, iPhone 17 Pro simulator on iOS 26.4):
make testOniOSSimulator: all masking suites pass (Replay masking fails closed,Replay masking scenarios,flattened SwiftUI content, React Native, characterization, mask registry). The run as a whole exits non-zero because of timing-sensitive suites outside replay masking (PostHog logs queueadd/flush race,Replay Queueflush,PostHogSurveyEventsTest). The logs-queue race failed in all three runs, including the run before the fix. The other failures changed from run to run.make lint: passes. Thetype_body_lengthwarning onPostHogReplayIntegrationwas already on the base branch (1016 lines, now 1036).make buildSdk: passes.make test(macOS SPM, which compiles all of this out): fails the same way on this branch and on an unmodified base checkout, in the app-groupPostHogStorage backup exclusiontests. The sandboxed runs also crashed in the network-stub exception-steps tests. These are local environment failures, not caused by this change.make maskSnapshots/make testPresentationMasks. The pinned snapshot toolchain (Xcode 26.3, iOS 26.2) isn't installed here. A button's children inside its bounds add only masks that overlap the button's own mask, and the other changes leave a mask unchanged unless the view draws past its bounds, so I don't expect golden diffs. CI should confirm.git diff --check: clean.📝 Checklist
If releasing new changes
.changeset/v4-major-release.mdinstead of a new changeset🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code (Claude Opus 5.5) as a follow-up to the bot findings on #873. I confirmed each finding with a failing test before changing any code. For the unclipped image and the layer shadow, I chose one "drawn outsets" helper applied where mask rects are built, over separate fixes for each view type. The button fix walks the button's subviews, the same way image views are already handled. I tried extending attachment masking to
UITextField, found that UIKit removes attachments from text fields, and dropped it.The example subprojects reference the package as
../../posthog-ios, so simulator andbuildSdkruns were made from a copy of the tree in a directory namedposthog-ios. No public API changes.🤖 Generated with Claude Code