Skip to content

feat: mask content inline in GPU screenshot capture - #966

Open
ioannisj wants to merge 26 commits into
feat/replay-gpu-mirror-capturefrom
feat/replay-gpu-inline-masking
Open

ioannisj wants to merge 26 commits into
feat/replay-gpu-mirror-capturefrom
feat/replay-gpu-inline-masking

Conversation

@ioannisj

@ioannisj ioannisj commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Stacked on #911, so review that one first. This PR only changes what masked content looks like on the experimental GPU capture path (screenshotModeGPUCapture). The default path is unchanged, byte for byte.

On #911 the GPU path masks the same way the default path does: the frame is read back, then each masked region is painted over as a black rect. This PR masks the mirror itself before the render instead, so masked content is redacted in place, at its own z-order. A button or toolbar on top of masked content stays visible instead of disappearing under a black rect.

How each kind of content is masked:

  • UIKit text is drawn as * in the view's own font, colour and position. Labels, buttons and text views keep whitespace (John Smith → **** *****, like rrweb's maskText), and text fields and secure fields get one * per character (like rrweb's maskInputValue). Ruby annotations are dropped. The * bitmaps are drawn off the main thread and cached, and until one is ready that text shows as per-line bars. Text over 4096 UTF-16 units, or whose bitmap would go over 4 MB, stays as bars.
  • Text that can't be read (SwiftUI, CATextLayer, React Native) shows as rows of * sized from its bounds, in the label colour.
  • Images are black.
  • Masked .drawingGroup() and Canvas content is black, like on the default path.
  • Web views, sandboxed views and controls are black, with nothing under them drawn.
  • ph-no-capture views are one black block over their bounds, no chrome.
  • Auto-masked UIKit views keep their chrome: background colours, borders and corners stay, and only drawn content is redacted.
  • Anything that can't be masked in place still goes through the existing rect pass, unchanged.

postHogMask() on the GPU path

postHogMask() only reports a rect, so there's no view to redact. On this path everything painted under that rect before the modifier's own overlay is masked, chrome included: text as above, images and colour layers black. That includes shapes and contents drawn past their layer's bounds and shadows cast into the rect from outside it. A layer with filters is replaced whole, never covered in part. UI painted after it (a button over masked text, a sheet over a masked card) stays visible. When a non-zero zPosition could change the paint order on the reporter's branch, every intersecting layer is masked.

Portals

A portal on the GPU path draws a second, separate copy of its source. Every inline redaction is applied to all copies of the same source, so a postHogMask() overlay next to a source also masks the copy the portal shows somewhere else. A copy that doesn't line up with its source is blacked out whole.

Behaviour changes from the default path

  • * text gives away text length, and for labels word lengths and line breaks too. Web masking gives away the same.
  • Auto-masked views keep their chrome, where the default path draws the whole view as a black rect. So colour-encoded data built from plain coloured views (a chart made of bars, a status badge) inside an auto-masked container is uploaded. ph-no-capture views are still blacked out whole.

Over-masking

  • Rounded cards inside postHogMask() go black as a square block, and so do ph-no-capture views.
  • Text fields lose their rounded border, only the * remain.
  • A sheet's material over a masked area samples the black mask, so its top edge shows a dark gradient.

Spec note

session-replay-privacy says screenshot recorders paint opaque masks over the screenshot. Images and blocked views are opaque here, but masked text is drawn into the screenshot as * instead. No masked text reaches the upload, but the wording doesn't cover it. If inline masking stays past the experiment, I'll propose a spec amendment.

Not in this PR

💚 How did you test it?

PostHogGPUInlineMaskTest and PostHogGPUSwiftUIMaskTest are new, and PostHogGPUCaptureTest is updated. They cover text (ruby annotations, the length and bitmap caps), images, ph-no-capture blocks, backdrops, postHogMask() extents (chrome, paint order, zPosition, shapes drawn past their bounds, sheets over an extent), portal copies and first-frame behaviour. Leaks are checked by pixel colour: magenta content must not survive masking.

The golden image suite also gets a GPU set (make gpuMaskSnapshots, make recordGPUMaskSnapshots): the existing 28 scenarios through the GPU path, plus 7 GPU-only ones (postHogMask() chrome, paint order, a sheet over an extent, a ph-no-capture card, photos under a blur panel, portal copies, partial covers). CI runs it in the same job as the existing goldens, on the same pins.

  • On the iOS 26.5 simulator, all 120 tests in the three GPU suites and the 89 tests in the default masking suites pass, and both golden sets pass.
  • make lint shows no errors in the changed files.
  • On the simulator (Debug), the postHogMask() extent pass adds about 2.0 to 2.4 ms to the first main-thread turn.

📝 Checklist

  • 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

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)

@ioannisj ioannisj self-assigned this Oct 26, 2026

@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 a4fd695: 4 Must fix, 2 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

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.

Comment thread PostHog/Replay/PostHogInlineMask.swift Resolved
Comment thread PostHog/Replay/PostHogInlineMask.swift Resolved
Comment thread PostHog/Replay/PostHogInlineMask.swift Resolved
Comment thread PostHog/Replay/PostHogInlineMask.swift Resolved
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-30T14:31:44.371717+00:00
Duration: 233458ms

⚠️ 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 ✅ 3446ms
Format Validation.Event Has Uuid ✅ 351ms
Format Validation.Event Has Lib Properties ✅ 2893ms
Format Validation.Distinct Id Is String ✅ 2755ms
Format Validation.Token Is Present ✅ 2750ms
Format Validation.Custom Properties Preserved ✅ 2722ms
Format Validation.Event Has Timestamp ✅ 2804ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2883ms
Retry Behavior.Retries On 503 ✅ 5587ms
Retry Behavior.Does Not Retry On 400 ✅ 4769ms
Retry Behavior.Does Not Retry On 401 ✅ 4831ms
Retry Behavior.Respects Retry After Header ✅ 7881ms
Retry Behavior.Implements Backoff ✅ 21826ms
Retry Behavior.Retries On 500 ✅ 5518ms
Retry Behavior.Retries On 502 ✅ 9346ms
Retry Behavior.Retries On 504 ✅ 8810ms
Retry Behavior.Max Retries Respected ❌ 21734ms
Deduplication.Generates Unique Uuids ✅ 2922ms
Deduplication.Preserves Uuid On Retry ✅ 9283ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 17105ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 8516ms
Deduplication.No Duplicate Events In Batch ✅ 2935ms
Deduplication.Different Events Have Different Uuids ✅ 2845ms
Compression.Sends Gzip When Enabled ✅ 2791ms
Batch Format.Uses Proper Batch Structure ✅ 2747ms
Batch Format.Flush With No Events Sends Nothing ✅ 441ms
Batch Format.Multiple Events Batched Together ✅ 2923ms
Error Handling.Does Not Retry On 403 ✅ 4701ms
Error Handling.Does Not Retry On 413 ✅ 4749ms
Error Handling.Retries On 408 ✅ 9224ms

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 ✅ 2952ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2843ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 3055ms
Request Payload.Flags Request Omits Authorization Header ✅ 2844ms
Request Payload.Token In Flags Body Matches Init ✅ 2884ms
Request Payload.Groups Round Trip ✅ 2967ms
Request Payload.Groups Default To Empty Object ✅ 2907ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2876ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2838ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2807ms
Request Lifecycle.No Flags Request On Init Alone ✅ 86ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2830ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5667ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2663ms
Retry Behavior.Retries Flags On 502 ✅ 2855ms
Retry Behavior.Retries Flags On 504 ✅ 2906ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 2970ms

@ioannisj
ioannisj added this pull request to stack #998 October 30, 2026 09:25
@ioannisj
ioannisj force-pushed the feat/replay-gpu-inline-masking branch from 9649f7a to 16517c5 Compare October 30, 2026 14:17
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Session replay mask snapshots

The masking snapshot job failed before rendering any snapshots (build, simulator, or infrastructure failure), so there are no diff images to compare. See the run logs for the failure.

@ioannisj
ioannisj marked this pull request as ready for review October 30, 2026 14:33
@ioannisj
ioannisj requested a review from a team as a code owner October 30, 2026 14:33

@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 16517c5: 3 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread PostHog/Replay/PostHogInlineMask.swift Resolved
@veria-ai

veria-ai Bot commented Oct 30, 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: 3 · PR risk: 0/10

@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 94ce0c1: 2 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

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.

@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 f58d153: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 4 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.

case .atPortal:
let elsewhere = PostHogPortalSource.elsewhere(source: source, portal: portal, root: walk.root)
walk.portalSources.append(PostHogPortalSource(view: view, elsewhere: elsewhere))
walk.portalSources.append(PostHogPortalSource(view: view, elsewhere: elsewhere, hasInPlaceCopy: walk.viewCopies[id] != nil))

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.

P2 · Determine in-place copy availability after the mirror walk

When a portal precedes its source with hidesSourceLayer = false, viewCopies[id] is still nil here even though the walk subsequently creates the in-place copy. wholePortals then adds a full-portal rectangle despite successful inline masking, blacking out unrelated content and controls painted above it. Resolve hasInPlaceCopy after the walk finishes.

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.

1 participant