Skip to content

feat(replay): internal session-replay snapshot hook for out-of-engine capture#714

Merged
turnipdabeets merged 3 commits into
mainfrom
feat-native-platform
Jul 14, 2026
Merged

feat(replay): internal session-replay snapshot hook for out-of-engine capture#714
turnipdabeets merged 3 commits into
mainfrom
feat-native-platform

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

First-party wrapper SDKs (starting with posthog-flutter) need to record native screens that cover their engine-rendered UI — paywalls and other view controllers presented on top of a Flutter surface. The wrapper runs its own out-of-engine occlusion detection and needs to drive a one-shot capture of the current native window on its own cadence.

This adds an SPI (@_spi(PostHogInternal)) hook for that. It is not public API: it carries no source/binary stability guarantees and shares snapshot state with the normal timer-driven capture, so only a coordinating first-party SDK should call it.

Consumed by PostHog/posthog-flutter#473. PostHog/posthog-flutter#151 is the end-user issue this enables fixing.

🔨 Changes

  • PostHogSDK.captureSessionReplaySnapshot(afterScreenUpdates:) — SPI entry point; callable from any thread, hops to main for the capture, returns whether a frame was captured so the caller can retry.
  • PostHogReplayIntegration.captureBridgeSnapshot(settlePresentation:) — renders and enqueues the current key window's screenshot. settlePresentation passes afterScreenUpdates: true (used only on an episode's first frame — it flickers secure text fields, so steady-state ticks avoid it).
  • Bridged frames carry the covering screen's name: the capture resolves the window's topmost presented view controller and passes its name as the meta href, and an episode's first frame re-arms the meta event so every episode opens with one. Native (non-bridge) capture is unaffected — the re-arm is gated on the bridge-only first-frame flag.
  • renderAndEnqueueScreenshot now returns Bool and bails (return false) on a failed capture, so nothing is enqueued and the caller can retry.
  • First-frame re-arm also resets lastImageHash, so a recurring native screen always re-sends its opening frame under the always-on screenshot dedup (fix(replay): skip re-sending unchanged screenshots in screenshot mode #711).

💚 How did you test it?

  • Exercised end-to-end from posthog-flutter's example app on the iOS simulator: native paywall presented over Flutter, both standard presentation and Superwall-style own-window presentation. The episode held for the full duration and captured the native screen.
  • swiftformat clean; compiled via the posthog-flutter example app build against this branch.
  • There is no unit coverage for the bridge path (it needs a host window); the driving logic is tested on the Flutter side (native_occlusion_episode_test.dart over there).

📝 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

Autonomy: Human-driven (agent-assisted)

  • Implemented with Claude Code (Opus 4.8 and Fable 5 sessions) driven by @turnipdabeets, alongside the matching posthog-android hook and the posthog-flutter feature that consumes both.
  • Design decisions: SPI over public API (no stability commitment for a wrapper-only hook); reuse of the existing screenshot path + masking rather than a parallel pipeline; afterScreenUpdates only on episode-first frames because it visibly flickers secure text fields; per-episode meta re-arm added after review so replay names the covering native screen instead of keeping the stale wrapper screen name.
  • A multi-agent review pass over the combined feature verified the episode/dedup interactions and confirmed the meta re-arm cannot affect native (non-bridge) capture.

…gine capture

Adds PostHogSDK.captureSessionReplaySnapshot(afterScreenUpdates:), gated behind
@_spi(PostHogInternal), so first-party wrapper SDKs (posthog-flutter) can capture
the current native window on their own cadence to record native screens that
cover an out-of-engine UI. Shares the existing screenshot path and masking; not
a public API. The bridge screenshot path dedups unchanged frames (hash of the
encoded image) so a static native screen on a fixed cadence is not re-sent every
tick, matching the wireframe path's change-driven behavior.

Bridged episodes emit their own meta: an episode's first frame re-arms the meta
event, which carries the covering view controller's name, mirroring the Android
bridge. Native (non-bridge) capture is unaffected — the re-arm is gated on the
bridge-only first-frame flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Security Review

The bridge can capture an unintended key window in multi-window setups, which may record UI outside the wrapper's intended native overlay.

Reviews (1): Last reviewed commit: "feat(replay): add internal session-repla..." | Re-trigger Greptile

Comment thread PostHog/Replay/PostHogReplayIntegration.swift Outdated
Comment thread PostHog/Replay/PostHogReplayIntegration.swift
Comment thread PostHog/Replay/PostHogReplayIntegration.swift
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-07-14 20:23:09 UTC
Duration: 223724ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2722ms
Format Validation.Event Has Uuid 2800ms
Format Validation.Event Has Lib Properties 267ms
Format Validation.Distinct Id Is String 2829ms
Format Validation.Token Is Present 353ms
Format Validation.Custom Properties Preserved 2652ms
Format Validation.Event Has Timestamp 891ms
Retry Behavior.Retries On 503 11722ms
Retry Behavior.Does Not Retry On 400 4752ms
Retry Behavior.Does Not Retry On 401 4688ms
Retry Behavior.Respects Retry After Header 8014ms
Retry Behavior.Implements Backoff 21333ms
Retry Behavior.Retries On 500 9236ms
Retry Behavior.Retries On 502 9201ms
Retry Behavior.Retries On 504 9509ms
Retry Behavior.Max Retries Respected 21823ms
Deduplication.Generates Unique Uuids 2972ms
Deduplication.Preserves Uuid On Retry 9250ms
Deduplication.Preserves Uuid And Timestamp On Retry 16176ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7817ms
Deduplication.No Duplicate Events In Batch 3103ms
Deduplication.Different Events Have Different Uuids 3180ms
Compression.Sends Gzip When Enabled 500ms
Batch Format.Uses Proper Batch Structure 2864ms
Batch Format.Flush With No Events Sends Nothing 263ms
Batch Format.Multiple Events Batched Together 2918ms
Error Handling.Does Not Retry On 403 4920ms
Error Handling.Does Not Retry On 413 4761ms
Error Handling.Retries On 408 9296ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2736ms
Request Payload.Flags Request Uses V2 Query Param 2697ms
Request Payload.Flags Request Hits Flags Path Not Decide 2749ms
Request Payload.Flags Request Omits Authorization Header 2826ms
Request Payload.Token In Flags Body Matches Init 2848ms
Request Payload.Groups Round Trip 2687ms
Request Payload.Groups Default To Empty Object 2800ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2732ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2756ms
Request Payload.Disable Geoip Omitted Defaults To False 2724ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2809ms
Request Lifecycle.No Flags Request On Init Alone 76ms
Request Lifecycle.No Flags Request On Normal Capture 457ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5444ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2854ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2908ms

afterScreenUpdates applies until the episode's first frame is captured
(it carries the meta/hash re-arm), not just the first attempt; off-main
calls block on a synchronous main-queue hop and must not come from a
queue the main thread can wait on.

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

LG left a couple of comments

Comment thread PostHog/Replay/PostHogReplayIntegration.swift Outdated
Comment thread PostHog/PostHogSDK.swift Outdated
@turnipdabeets
turnipdabeets merged commit 2ee22f5 into main Jul 14, 2026
43 checks passed
@turnipdabeets
turnipdabeets deleted the feat-native-platform branch July 14, 2026 22:05
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