Skip to content

chore!: remove wireframe session replay capture - #907

Merged
turnipdabeets merged 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-remove-replay-wireframe
Oct 9, 2026
Merged

turnipdabeets merged 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-remove-replay-wireframe

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Wireframe skipped SwiftUI-rooted apps completely. It logged "SwiftUI snapshot not supported, enable screenshotMode." and recorded nothing.
  • Wireframe leaks ph-no-capture containers. sdk-specs compliance/posthog-ios.md n26 marks iOS ❌ for this: a tagged plain UIView still 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.
  • It halves the replay code we maintain. One capture path instead of two. The privacy work of the last few releases (settle check, presentation covers, fail-closed masking) only ever covered the screenshot path.

What changes

Before (default config) After
UIKit screen wireframe tree (childWireframes, style, text with * masking) one screenshot wireframe with masked base64 (same payload screenshot mode already sent)
SwiftUI-rooted window nothing recorded recorded as screenshots
screenshotMode selects the mode deprecated no-op: reads true, writes are ignored
screenshotModeBackgroundCapture ignored unless screenshotMode = true applies on its own
$sdk_debug_replay_capture_mode wireframe / screenshot always screenshot
Replay meta href root VC name (wireframe), never sent (screenshot) visible screen name, via the native-screen bridge's lookup plus the $screen name sanitizer (SwiftUI reports its view name, not UIHostingController<…>)

The screenshot frame (type 2) is byte-identical to what screenshotMode = true sends today; the meta event (type 4) now also carries href. 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 (hrefFrom in nodejs/src/ingestion/pipelines/sessionreplay/rrweb-types.ts). snapshot() now resolves it with ph_topViewController + getViewControllerName, like captureBridgeSnapshot does. That's slightly more accurate than before: it names the visible child screen, not the container.

Public API: screenshotMode stays, deprecated, rather than being removed. It was never deprecated in 3.x, unlike maskPhotoLibraryImages / debouncerDelay, which #873 removes. Both RN plugins set screenshotMode = true and KMP forwards its screenshot option, 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 the UIHostingController marker protocol. None are public.

Secure fields under ph-no-mask: screenshot capture used to stop the masking walk at a ph-no-mask / .postHogNoMask() ancestor, so a secure-entry field inside it was recorded. Wireframe masked it. Now that screenshots are the only mode, findMaskableWidgets still 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".

⚠️ Privacy / behavior notes for reviewers (also in the changeset)

For standard UIKit widgets the masking decision is unchanged: both modes call the same is*Sensitive helpers. What changes is everything else:

  • Custom-drawn content is now visible. Wireframe sent a colored box for any view type it didn't recognize: custom draw(_:), CoreText, CATextLayer, MKMapView, video, Metal, PDF, charts, Texture nodes. Screenshots render the pixels, and masking only covers recognized widgets plus ph-no-capture / .postHogMask().
  • SwiftUI-rooted apps start recording. That's quota and a consent/disclosure change for apps that had replay on but saw empty recordings.
  • WKWebView is rendered in full when both maskAllTextInputs and maskAllImages are off. Wireframe sent a web_view placeholder.
  • Cost: more bandwidth and CPU per frame. Unchanged frames are now deduplicated, which wireframe frames never were.

Divergence from other SDKs

  • Android keeps both modes (screenshot defaults to false), so iOS and Android now differ on the default. KMP's common screenshot flag becomes iOS-ignored.
  • The session-replay-privacy spec describes wireframe as one native capture style but doesn't require it. So this satisfies the spec, and it should let n26 flip once re-audited.

Follow-ups (not in this PR)

  • Bridge href: captureBridgeSnapshot still sends the unsanitized name (e.g. UIHostingController<…>), as it does on release/4.0. Left alone to avoid changing Flutter/RN href values in this PR.
  • Docs: posthog.com iOS replay docs (they tell people to set screenshotMode = true), the KMP screenshot doc, and the sdk-specs config list and n26 entry.
  • Existing screenshot-mode issues this makes default, out of scope here:
    • The replay encode queue has no backpressure: render is guarded by the in-flight flag, encoding isn't. While writing the test, full-screen WebP encodes took about 1–7 s each on the simulator. Not measured on device.

💚 How did you test it?

  • New PostHogReplayLayoutCaptureTest. It drives the real layout → throttled snapshot() path with the default config, once with a UIKit root and once with a SwiftUI UIHostingController root, and asserts a screenshot wireframe with image data plus the UIKit meta href.
    • It needs a foreground scene, so it runs in the app-hosted make testPresentationMasks job (already in CI), with a zero-test guard added to the Makefile.
    • On release/4.0 it fails for both roots: UIKit produces a wireframe tree with no type/base64, and SwiftUI produces no snapshot. On this branch it passes in both case orders across 4 runs.
  • New PostHogReactNativeMaskingTest case: a secure UITextField under a ph-no-mask ancestor is masked (fails without the fix, passes with it).
  • Updated capture-mode expectations in PostHogSDKTest / PostHogEventSnapshotTests. Removed the Flutter-host capture-mode test, since the branch it covered is gone.
  • make lint (no new warnings), make apiCheck, make testOniOSSimulator and make testPresentationMasks pass.
  • make test (macOS): the only failures are the 13 PostHogStorageTest group-container cases, which fail identically on release/4.0 on this machine.
  • make buildExamples: buildExamplesPlatforms passes. buildExampleXCFramework can't resolve the local package from a worktree whose directory isn't named posthog-ios; that's environmental, and CI covers it. The example changes are one deleted screenshotMode = true line each.
  • Not run: on-device manual replay.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (posthog.com / KMP / sdk-specs follow-ups listed above)
  • No breaking change or entry added to the changelog. (Breaking; major changeset added.)

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

The changeset is major and was written by hand.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code (Opus 5.5) at the maintainer's request, as a separate draft PR on the 4.0 stack.
  • Reviewed with separate correctness, compatibility, privacy and test-coverage agent passes:
    • The compatibility pass led to keeping href and expanding the changeset.
    • The test pass found the new test needed an app host and was order-dependent. The root cause was the test flooding the serial encode queue, fixed by pacing and draining it.
  • Chose a deprecated no-op over removing screenshotMode to keep the RN/KMP wrappers compiling. Easy to flip if we'd rather hard-remove it in 4.0.

🤖 Generated with Claude Code

@turnipdabeets turnipdabeets self-assigned this Oct 5, 2026
@greptile-apps

greptile-apps Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[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 href updates across screen changes.

Reviews (1) · Last reviewed commit: "chore!: remove wireframe session replay ..."

Comment thread PostHog/Replay/PostHogReplayIntegration.swift
Comment thread PostHog/Replay/PostHogReplayIntegration.swift
Comment thread PostHog/Replay/PostHogReplayIntegration.swift
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@ioannisj - is this something we want?

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-09 16:11:43 UTC
Duration: 223225ms

⚠️ Some Tests Failed

43/45 tests passed, 2 failed


Capture Tests

⚠️ 27/29 tests passed, 2 failed

View Details
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

@ioannisj

ioannisj commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@ioannisj - is this something we want?

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

@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-replay-wireframe branch from 9bfe6bc to ed579d8 Compare October 6, 2026 18:00
@turnipdabeets
turnipdabeets changed the base branch from release/4.0 to chore/v4-remove-deprecated October 6, 2026 18:00
@turnipdabeets
turnipdabeets marked this pull request as ready for review October 6, 2026 19:35
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 6, 2026 19:35
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@ioannisj - is this something we want?

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

Marking ready for review. May hold off on merging into 4.0 branch though , just incase.

Comment thread PostHog/Replay/PostHogReplayIntegration.swift
@veria-ai

veria-ai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

PR overview

This 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

@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-deprecated branch from e1cc8cf to 5b6c50e Compare October 7, 2026 01:44
@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-replay-wireframe branch from ed579d8 to fb34746 Compare October 7, 2026 02:05

@dustinbyrne dustinbyrne 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.

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)

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

  1. Enabled replay subscribes to layout notifications at PostHogReplayIntegration.swift:331–338.
  2. snapshot() now chooses screenshot capture without mode opt-in at :1515–1539.
  3. The full-window leaf contributes no region at :1065–1074.
  4. Two empty, unchanged mask samples select the fidelity renderer through PostHogMaskSettleAnalysis.swift:157–168.
  5. UIView+Util.swift:141–146 renders the visible hierarchy.
  6. With an empty mask list, RRWireframe.swift:80–90 encodes 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.

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.

worth noting that this is existing behavior and not a regression, not blocking

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread PostHog/Replay/PostHogReplayIntegration.swift
}
// The replay meta `href`: the visible screen, with SwiftUI hosting generics unwrapped.
let screenName = UIViewController.ph_topViewController(base: window.rootViewController)
.flatMap(UIViewController.getViewControllerName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

turnipdabeets and others added 2 commits October 9, 2026 11:50
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>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-deprecated branch from 3e050a7 to 7bb79ca Compare October 9, 2026 15:50
…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>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-replay-wireframe branch from dc3d0fa to eef10c8 Compare October 9, 2026 15:50

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@turnipdabeets
turnipdabeets merged commit 97a0017 into chore/v4-remove-deprecated Oct 9, 2026
45 checks passed
@turnipdabeets
turnipdabeets deleted the chore/v4-remove-replay-wireframe branch October 9, 2026 18:15
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.

3 participants