Skip to content

fix(replay): mask content drawn outside a view's bounds - #976

Merged
turnipdabeets merged 3 commits into
chore/v4-remove-deprecatedfrom
fix/v4-replay-masking-gaps
Oct 30, 2026
Merged

turnipdabeets merged 3 commits into
chore/v4-remove-deprecatedfrom
fix/v4-replay-masking-gaps

Conversation

@turnipdabeets

Copy link
Copy Markdown
Contributor

💡 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:

Finding Before Fix
Unclipped UIImageView (.scaleAspectFill, clipsToBounds = false) image pixels outside the bounds were uploaded the mask grows to where the image is drawn for its content mode (aspect-fill and the natural-size modes such as .center and .topLeft), across every image source the view can draw
UILabel with an image NSTextAttachment, maskAllTextInputs = false, maskAllImages = true the attachment was uploaded unmasked labels and text views holding a non-symbol, non-asset image attachment are masked when maskAllImages is on (an attachment whose image can't be read is masked)
Unclipped label with an offset CALayer shadow the shadow's readable glyphs were uploaded the mask grows by the shadow's offset plus twice its radius when the layer doesn't clip and has no shadowPath
Unclipped UIButton with its title moved outside by titleEdgeInsets the title was uploaded a masked button also walks its subviews, so a title or image laid out past the button gets its own mask

The image and shadow cases share one helper: MaskedRegion now 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. For UITextView it reads textStorage, so the text isn't copied.

Not changed:

  • UITextField drops NSTextAttachments 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.
  • Masks aren't clipped to a clipping ancestor. An unclipped aspect-fill image inside a clipping cell can now get a mask that spills into the neighbouring cells. That fails closed, and it only happens when the image actually overflows.
  • A shadow on an unmasked ancestor that contains masked text (for example a transparent card with a layer shadow) can still repeat that text's shape. None of the findings covered this, and fixing it would mean carrying shadow state through the hierarchy walk.
  • Whole-label masking for attachments also covers the label's text when only maskAllImages is 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:

  • aspect-fill image unclipped (fails before, passes after) and clipped (mask stays on bounds: no over-masking)
  • .topLeft image larger than its view
  • image attachment in a UILabel and a UITextView with only image masking on, plus a control: an SF Symbol attachment stays unmasked
  • label layer shadow unclipped (covered) and clipped (mask stays on bounds)
  • button title moved outside the button with titleEdgeInsets

Validation (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 queue add/flush race, Replay Queue flush, 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. The type_body_length warning on PostHogReplayIntegration was 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-group PostHogStorage backup exclusion tests. The sandboxed runs also crashed in the network-stub exception-steps tests. These are local environment failures, not caused by this change.
  • Not run: 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

  • 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 breaking change or entry added to the changelog.

If releasing new changes

  • Added a Fixed line to the existing .changeset/v4-major-release.md instead 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 and buildSdk runs were made from a copy of the tree in a directory named posthog-ios. No public API changes.

🤖 Generated with Claude Code

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>
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 27, 2026 17:24
@turnipdabeets turnipdabeets self-assigned this Oct 27, 2026
@turnipdabeets turnipdabeets mentioned this pull request Oct 27, 2026
2 of 15 tasks

@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 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.
@turnipdabeets turnipdabeets mentioned this pull request Oct 27, 2026
4 of 6 tasks
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-30T15:31:29.815536+00:00
Duration: 236892ms

⚠️ 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 ✅ 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 ioannisj 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.

🤖 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>
@turnipdabeets
turnipdabeets merged commit 5a16a00 into chore/v4-remove-deprecated Oct 30, 2026
43 of 44 checks passed
@turnipdabeets
turnipdabeets deleted the fix/v4-replay-masking-gaps branch October 30, 2026 15:18
turnipdabeets added a commit that referenced this pull request Oct 30, 2026
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.
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