Repository navigation
chore!: remove wireframe session replay capture - #907
Conversation
|
[Medium risk] Removes wireframe session replay, switches to screenshot-only capture. The PR is not safe to merge until default screenshot capture preserves sensitive-content masking and Reviews (1) · Last reviewed commit: "chore!: remove wireframe session replay ..." |
|
@ioannisj - is this something we want? |
posthog-ios Compliance ReportDate: 2026-10-09 16:11:43 UTC
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 2971ms |
| Format Validation.Event Has Uuid | ✅ | 2751ms |
| Format Validation.Event Has Lib Properties | ✅ | 354ms |
| Format Validation.Distinct Id Is String | ✅ | 2766ms |
| Format Validation.Token Is Present | ✅ | 2758ms |
| Format Validation.Custom Properties Preserved | ✅ | 2725ms |
| Format Validation.Event Has Timestamp | ✅ | 2750ms |
| Retry Behavior.Retries On 503 | ✅ | 11661ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4771ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2353ms |
| Retry Behavior.Respects Retry After Header | ✅ | 7797ms |
| Retry Behavior.Implements Backoff | ✅ | 21781ms |
| Retry Behavior.Retries On 500 | ✅ | 8777ms |
| Retry Behavior.Retries On 502 | ✅ | 5387ms |
| Retry Behavior.Retries On 504 | ✅ | 8816ms |
| Retry Behavior.Max Retries Respected | ❌ | 21875ms |
| Deduplication.Generates Unique Uuids | ✅ | 2977ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 8723ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 16791ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7975ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 2795ms |
| Deduplication.Different Events Have Different Uuids | ❌ | 244ms |
| Compression.Sends Gzip When Enabled | ✅ | 2622ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 2757ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 250ms |
| Batch Format.Multiple Events Batched Together | ✅ | 2923ms |
| Error Handling.Does Not Retry On 403 | ✅ | 5228ms |
| Error Handling.Does Not Retry On 413 | ✅ | 4744ms |
| Error Handling.Retries On 408 | ✅ | 9125ms |
Failures
retry_behavior.max_retries_respected
Expected 4 requests, got 6
deduplication.different_events_have_different_uuids
Need at least 2 events
Feature_Flags Tests
✅ 16/16 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 2818ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 2825ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 2911ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 2841ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 2836ms |
| Request Payload.Groups Round Trip | ✅ | 2760ms |
| Request Payload.Groups Default To Empty Object | ✅ | 2866ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ✅ | 2800ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 2737ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 2833ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 2797ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 78ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 2811ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 5409ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 2782ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 3054ms |
Yeah I think we can assume for now that wireframe mode will be dropped, and we can make a final call before releasing. Will follow up internally |
9bfe6bc to
ed579d8
Compare
Marking ready for review. May hold off on merging into 4.0 branch though , just incase. |
PR overviewThis PR removes wireframe session replay capture and updates how view controller names are included in replay snapshot metadata. One issue has been addressed, and one remains open: controller display titles can be recorded verbatim in replay metadata despite text-input masking. If those titles contain customer or document names, replay viewers could see information that users may expect to be masked. Open issues (1)
Fixed/addressed: 1 · PR risk: 3/10 |
e1cc8cf to
5b6c50e
Compare
ed579d8 to
fb34746
Compare
dustinbyrne
left a comment
There was a problem hiding this comment.
The screenshot-only migration and retained compatibility setter look good for v4. The major changeset documents the intentional behavior change, and existing CI exercises default UIKit/SwiftUI capture. Consider following up on the two pre-existing screenshot-mode masking cases noted inline.
AI-assisted review.
| } | ||
| return | ||
| } else { | ||
| scheduleSettledCapture(window: window, screenName: screenName, postHog: postHog) |
There was a problem hiding this comment.
[P2] Include the mask for a full-window no-capture leaf
Non-blocking follow-up for the existing screenshot path.
Changed location: PostHog/Replay/PostHogReplayIntegration.swift:1539
Underlying omission: PostHog/Replay/PostHogReplayIntegration.swift:1063–1078
Making scheduleSettledCapture unconditional exposes an existing screenshot masking omission to apps that previously used the default wireframe path.
A full-window custom leaf tagged ph-no-capture contributes no rectangle. When its absolute rectangle equals window.frame, the walk only sets maskDescendants = true; a leaf has no descendants to receive that mask.
A supported UIKit setup is:
// SensitiveCanvasView draws its content in draw(_:) and has no child UIViews.
let controller = UIViewController()
let sensitiveView = SensitiveCanvasView(frame: window.bounds)
sensitiveView.accessibilityIdentifier = "ph-no-capture"
controller.view = sensitiveView
window.rootViewController = controller
window.makeKeyAndVisible()
let config = PostHogConfig(projectToken: projectToken)
config.sessionReplay = true
PostHogSDK.shared.setup(config)Trigger conditions: Enabled local/project replay; normal swizzling-enabled native UIKit capture; a settled, visible foreground window at origin zero; the leaf fills the window and draws sensitive content itself; no independent explicit mask covers that content.
Expected: The explicitly excluded drawing is redacted before encoding.
Head behavior implied by source: No mask is collected for this leaf, so its visible drawing can enter the screenshot without SDK redaction.
The source timeline is:
- Enabled replay subscribes to layout notifications at
PostHogReplayIntegration.swift:331–338. snapshot()now chooses screenshot capture without mode opt-in at:1515–1539.- The full-window leaf contributes no region at
:1065–1074. - Two empty, unchanged mask samples select the fidelity renderer through
PostHogMaskSettleAnalysis.swift:157–168. UIView+Util.swift:141–146renders the visible hierarchy.- With an empty mask list,
RRWireframe.swift:80–90encodes the unredacted image.
Base/head causality: Base snapshot() selected generateSnapshot unless screenshotMode was explicitly enabled (base/PostHog/Replay/PostHogReplayIntegration.swift:1685–1716). Base toWireframe() serialized this custom leaf’s geometry/style, not its custom drawing or a screenshot (:1427–1560). Opted-in screenshot users already had this omission; this diff exposes it to default-config users.
Contract: The privacy specification at 09acafd4718792a5bd1d13f6208410db224c1330 requires explicit masks and mask application before encoding/upload. Its no-capture acceptance scenario excludes the marked content. The changeset expressly recommends tagging sensitive custom drawing ph-no-capture.
Impact: Sensitive custom drawing may be recorded despite an explicit exclusion. This is not the intentionally accepted exposure of untagged custom views.
Smallest recommendation: Consider collecting the leaf’s own region when viewRect == windowRect and it has no subviews, including the inherited-mask case. For example, extend the existing append condition to cover !hasSubViews. Preserve the descendant-based behavior for existing full-window containers, where selective ph-no-mask descendants are already tested. This leaf correction does not establish complete masking of every nonleaf container’s own drawing.
Test sketch (not executed)
Add inside PostHogMaskFailClosedTest, using its existing makeSut, teardown, makeWindow, and file-local CustomDrawnSecretView:
@Test("a full-window no-capture leaf contributes its own mask")
func fullWindowNoCaptureLeaf() throws {
let sut = makeSut()
defer { teardown(sut) }
let secret = CustomDrawnSecretView(
frame: CGRect(x: 0, y: 0, width: 320, height: 640)
)
secret.accessibilityIdentifier = "ph-no-capture"
let window = makeWindow(containing: secret)
try #require(secret.subviews.isEmpty)
try #require(secret.toAbsoluteRect(window).equalTo(window.frame))
let rects = try #require(
sut.integration.collectMaskableRects(in: window)
)
#expect(rects.contains(secret.toPresentationRect(window)))
}Harness verification: These helpers and the custom drawing fixture exist at PostHogTests/PostHogMaskFailClosedTest.swift:38–42, 66–89. The fixture is a test subclass, not claimed production code. The test’s unshown window is suitable for testing mask collection; it does not demonstrate rendered pixels or upload.
- Expected: The leaf’s region is present.
- Actual implied by head: The collected array is empty.
- After the leaf correction: The region is present.
- Execution limitation: This regression sketch was not executed; the outcome is inferred from source.
This seam test also exposes the old opted-in screenshot omission; it is not a differential base/default test by itself. Default-path causality comes from the base/head source comparison and the existing layout-capture test.
For public-entry pixel confirmation, extend PostHogReplayLayoutCaptureTest with a visible full-window custom root drawing a deterministic interior patch. Reuse its real PostHogSDK.with(config) setup and $snapshot interception, decode the WebP/JPEG data URL, and assert an interior sensitive pixel is masked. Its existing assertions only check screenshot type, nonempty image data, and href (PostHogReplayLayoutCaptureTest.swift:110–114).
Suggested validation after correction: make testOniOSSimulator and make testPresentationMasks.
There was a problem hiding this comment.
worth noting that this is existing behavior and not a regression, not blocking
There was a problem hiding this comment.
Fixed in f2e1105 — a full-window ph-no-capture view with no subviews now masks itself; full-window containers still pass the mask down to their children. Regression test added in PostHogMaskFailClosedTest.
1383528 to
3e050a7
Compare
f2e1105 to
dc3d0fa
Compare
| } | ||
| // The replay meta `href`: the visible screen, with SwiftUI hosting generics unwrapped. | ||
| let screenName = UIViewController.ph_topViewController(base: window.rootViewController) | ||
| .flatMap(UIViewController.getViewControllerName) |
There was a problem hiding this comment.
Low: Controller titles bypass replay masking
For plain/storyboard controllers, getViewControllerName falls back to UIViewController.title, which is human-readable display text also used by navigation items. (developer.apple.com) When that title contains a customer or document name, this new path writes it verbatim into the first snapshot's data.href (sanitize only strips SwiftUI wrappers), letting a replay viewer read it despite maskAllTextInputs = true and captureScreenViews = false; the previous screenshot path left screenName unset.
Use a type-derived identifier without the display-title fallback, or require an explicitly safe replay name before including href.
Session replay on iOS now always captures masked screenshots. Wireframe mode skipped SwiftUI-rooted windows entirely and leaked ph-no-capture containers (sdk-specs n26). `screenshotMode` is kept as a deprecated no-op so wrappers that set it keep compiling. The replay meta href is now resolved from the visible screen on every capture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Wireframe masked secure fields regardless of ph-no-mask; screenshot capture stopped the walk at the unmasked ancestor. Now that screenshots are the only mode, keep secure-entry inputs masked in that subtree, matching Android's password handling and the session-replay-privacy spec. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3e050a7 to
7bb79ca
Compare
…mask cover ancestor A full-window view tagged ph-no-capture with no subviews now contributes its own mask instead of relying on descendants it doesn't have. When a ph-no-mask ancestor sits above a selected presentation cover, secure-entry fields inside the cover are still masked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dc3d0fa to
eef10c8
Compare
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog refused this pull request on two gates. The deny-list gate matched deps_toolchain, most likely because it touches PostHog.xcodeproj/project.pbxproj and the Makefile. The tier gate classified it as T2-never, since it is a cross-cutting chore of 721 lines across 26 files that removes a whole capture path and changes replay behavior.
The size gate passed, so splitting the PR is not required, but it could help. Moving the project file and Makefile edits into a separate PR may keep the replay changes clear of the deny-list. Otherwise, ask a human reviewer to look at it.
- 👍 on the PR from greptile-apps[bot].
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: deps_toolchain |
| size | ✓ | 702L, 23F substantive, 721L/26F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (721L, 26F, cross-cutting, chore) |
| stamphog 2.4.1 | .stamphog/policy.yml @ unknown · reviewed head eef10c8 |
A plain UIViewController's title is display text and can contain user data, so the replay meta href now uses the controller's type name only. $screen naming still falls back to the title. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
💡 Motivation and Context
Part of the 4.0 stack, based on #873 (
chore/v4-remove-deprecated). Ioannis confirmed we can assume wireframe is dropped for v4, with a final call before release.iOS session replay has two capture modes. Wireframe is the default (
screenshotMode = false): it serializes the UIKit view tree to JSON. Screenshot captures masked images. This PR removes wireframe so replay always captures masked screenshots.Why:
ph-no-capturecontainers. sdk-specscompliance/posthog-ios.mdn26 marks iOS ❌ for this: a tagged plainUIViewstill has its children's text serialized..postHogMask()/.postHogNoMask()never applied in wireframe mode either. Screenshot mode masks a tagged container as one rect and honors the modifier registry.What changes
childWireframes,style, text with*masking)screenshotwireframe with maskedbase64(same payload screenshot mode already sent)screenshotModetrue, writes are ignoredscreenshotModeBackgroundCapturescreenshotMode = true$sdk_debug_replay_capture_modewireframe/screenshotscreenshothref$screenname sanitizer (SwiftUI reports its view name, notUIHostingController<…>)The screenshot frame (type 2) is byte-identical to what
screenshotMode = truesends today; the meta event (type 4) now also carrieshref. The web player handles both formats, so no server change is needed.Keeping
href: the old screenshot path never sent it. Without this change, default UIKit users would lose it, and ingestion builds a session's URL list from it (hrefFrominnodejs/src/ingestion/pipelines/sessionreplay/rrweb-types.ts).snapshot()now resolves it withph_topViewController+getViewControllerName, likecaptureBridgeSnapshotdoes. That's slightly more accurate than before: it names the visible child screen, not the container.Public API:
screenshotModestays, deprecated, rather than being removed. It was never deprecated in 3.x, unlikemaskPhotoLibraryImages/debouncerDelay, which #873 removes. Both RN plugins setscreenshotMode = trueand KMP forwards itsscreenshotoption, so all three keep compiling with only a warning. Removal can happen in the next major. The API snapshot changes only in signature shape ({ get set }).Deleted as orphaned:
RRStyle,String.mask(),UIColor/CGColor.toRGBString(),RRWireframe's tree, text and style fields, and theUIHostingControllermarker protocol. None are public.Secure fields under
ph-no-mask: screenshot capture used to stop the masking walk at aph-no-mask/.postHogNoMask()ancestor, so a secure-entry field inside it was recorded. Wireframe masked it. Now that screenshots are the only mode,findMaskableWidgetsstill masks secure-entry inputs in that subtree. This matches Android (password input types stay masked under an unmasked ancestor) and the session-replay-privacy spec's "password/secure text fields remain masked".For standard UIKit widgets the masking decision is unchanged: both modes call the same
is*Sensitivehelpers. What changes is everything else:draw(_:), CoreText,CATextLayer,MKMapView, video, Metal, PDF, charts, Texture nodes. Screenshots render the pixels, and masking only covers recognized widgets plusph-no-capture/.postHogMask().WKWebViewis rendered in full when bothmaskAllTextInputsandmaskAllImagesare off. Wireframe sent aweb_viewplaceholder.Divergence from other SDKs
screenshotdefaults tofalse), so iOS and Android now differ on the default. KMP's commonscreenshotflag becomes iOS-ignored.Follow-ups (not in this PR)
href:captureBridgeSnapshotstill sends the unsanitized name (e.g.UIHostingController<…>), as it does onrelease/4.0. Left alone to avoid changing Flutter/RNhrefvalues in this PR.screenshotMode = true), the KMPscreenshotdoc, and the sdk-specs config list and n26 entry.💚 How did you test it?
PostHogReplayLayoutCaptureTest. It drives the real layout → throttledsnapshot()path with the default config, once with a UIKit root and once with a SwiftUIUIHostingControllerroot, and asserts ascreenshotwireframe with image data plus the UIKit metahref.make testPresentationMasksjob (already in CI), with a zero-test guard added to the Makefile.release/4.0it fails for both roots: UIKit produces a wireframe tree with notype/base64, and SwiftUI produces no snapshot. On this branch it passes in both case orders across 4 runs.PostHogReactNativeMaskingTestcase: a secureUITextFieldunder aph-no-maskancestor is masked (fails without the fix, passes with it).PostHogSDKTest/PostHogEventSnapshotTests. Removed the Flutter-host capture-mode test, since the branch it covered is gone.make lint(no new warnings),make apiCheck,make testOniOSSimulatorandmake testPresentationMaskspass.make test(macOS): the only failures are the 13PostHogStorageTestgroup-container cases, which fail identically onrelease/4.0on this machine.make buildExamples:buildExamplesPlatformspasses.buildExampleXCFrameworkcan't resolve the local package from a worktree whose directory isn't namedposthog-ios; that's environmental, and CI covers it. The example changes are one deletedscreenshotMode = trueline each.📝 Checklist
majorchangeset added.)If releasing new changes
pnpm changesetto generate a changeset fileThe changeset is
majorand was written by hand.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
hrefand expanding the changeset.screenshotModeto keep the RN/KMP wrappers compiling. Easy to flip if we'd rather hard-remove it in 4.0.🤖 Generated with Claude Code