Repository navigation
fix(replay): keep session replay masks aligned with the captured frame - #786
Merged
Merged
Conversation
Contributor
posthog-ios Compliance ReportDate: 2026-08-28 15:15:02 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
turnipdabeets
approved these changes
Aug 28, 2026
ioannisj
marked this pull request as ready for review
August 28, 2026 15:02
turnipdabeets
approved these changes
Aug 28, 2026
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Contributor
|
Reviews (1): Last reviewed commit: "test(replay): derive mask settle boundar..." | Re-trigger Greptile |
4 of 5 tasks
3 of 5 tasks
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
Session replay masks drift off the content they cover while a screen scrolls or animates. The mask rects get measured at one instant and the pixels get rendered at another, so on a fast scroll the redaction lands behind the text it's meant to hide.
Two things cause it:
toAbsoluteRect). During a Core Animation animation the model layer sits at its destination while the screenshot renders the presentation layer's in-flight position.drawHierarchylags the render server, so even correct rects can sit ahead of the pixels that actually ship.The base fix is
toPresentationRect: mask geometry now reads the presentation tree, the same source the screenshot renders from. Wireframe geometry stays ontoAbsoluteRect, it has no pixels to agree with.On top of that there are two mechanisms, both driven by the same measurement. Before each capture
scheduleSettledCapturesamples mask geometry, waits 25ms (roughly one display-pipeline depth), samples again, and bands the result by the largest per-mask displacement. All of it lives inPostHog/Replay/PostHogMaskSettleAnalysis.swift.1. Drop to the presentation-tree renderer on fast scrolling
The band picks the renderer:
drawHierarchy.drawHierarchy, with the masks inflated as described below.render(in:)against the presentation tree, whose masks can't disagree with its own pixels.Unreadable geometry fails closed into motion, so an owner that appeared or disappeared between the samples, a duplicate owner, a count mismatch, or a nil sample all take the aligned renderer rather than guessing. Pairing is by owner identity (
MaskedRegion), not by index, because index pairing would compare unrelated rects and report a false settle whenever the set changed shape.The cost of the motion band is real and it's in the changeset:
render(in:)draws blur, video and Metal flat, and it ignoreslayer.mask,maskViewand Core Animationfilters. Anything that must never appear in a recording needspostHogMask().2. Inflate masks across the two samples
In the drift band the frame keeps full fidelity and the masks absorb the movement instead. Each mask becomes the union of four rects: where it was, where it is, a lead of 1/16 of the measured displacement past the newer sample, and the older sample pushed back by the full displacement. That last one covers a position that was never sampled, since the display pipeline is deeper than a single settle window and the pixels on screen trail the older sample.
screenshotModeBackgroundCaptureuses the same idea without a threshold.performBracketedBackgroundCaptureputs the off-main render between two samples and masks the per-owner union, so the mask provably covers wherever the content sat while the render ran. An owner present in only one of those samples emits its rect as collected rather than dropping the frame, because a scroll that recycles cells changes the owner set constantly and dropping those frames would freeze the recording for the whole interaction.Defaults are deliberately conservative
The thresholds are set so that in practice only a fling changes anything. Measured on device across a 25ms window: slow drift moves about 1.5pt, a Core Animation animation about 4pt, a scroll fling 185pt or more. So ordinary movement stays on the renderer session replay has always used, and the flattened frames are limited to the part of a fling nobody is reading anyway. Every constant is tunable in one place with the reasoning next to it, so these are easy to move once feedback lands.
💚 How did you test it?
33 tests in
PostHogTests/PostHogMaskScenarioTest.swift, in two groups:postHogMask()andpostHogNoMask().mask-drift-demo.mp4
If you have a repro or a sample project that showed masks sliding off during a scroll, please run this branch against it and check the playback, particularly whether the flattened frames during a fling are an acceptable trade for aligned masks. That judgement is the part I'd most like a second opinion on.
📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)