Repository navigation
feat(replay): schedule capture from a main run-loop observer instead of swizzling UIView layout #896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(replay): schedule capture from a main run-loop observer instead of swizzling UIView layout #896
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
8a374fb
feat(replay): schedule session replay capture from a main run-loop ob…
ioannisj f423166
feat(surveys): move surveys onto the shared run-loop publisher so not…
ioannisj 85623f9
chore: add changeset for the run-loop capture scheduler
ioannisj 8c601f1
Merge branch 'main' into explore/813-runloop-replay-scheduler
ioannisj 8710c71
fix(replay): wake the capture backoff when the session changes
ioannisj db68cde
Merge branch 'main' into explore/813-runloop-replay-scheduler
ioannisj 98ec4ca
fix(replay): clamp the capture interval so a zero throttleDelay can't…
ioannisj 66f8e4a
fix(replay): capture touchless screen changes while replay is backed off
ioannisj 0325e2f
Merge remote-tracking branch 'origin/main' into explore/813-runloop-r…
ioannisj 0156565
fix(replay): include text and background color in the structure finge…
ioannisj 573e0fd
docs(replay): mention wireframe dedup and drop surveys from enableSwi…
ioannisj 2eb89b4
fix(replay): keep an inactive session when capturing idle frames
ioannisj a8241ad
fix(replay): read only model values in the structure fingerprint walk
ioannisj d17867b
fix(replay): retry a detected change when its capture is dropped
ioannisj ef1b16a
fix(replay): resend a window's frame after another window sent the la…
ioannisj a18024c
Merge remote-tracking branch 'origin/main' into explore/813-runloop-r…
ioannisj eba8365
test(replay): use the scoped clock in the idle session test
ioannisj cbbe38f
chore: bump the run-loop replay changeset to minor
ioannisj f5ad1e7
fix(replay): see SwiftUI updates in the backed-off structure fingerprint
ioannisj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'posthog-ios': minor | ||
| --- | ||
|
|
||
| Session replay and surveys no longer swizzle `UIView.layoutSublayers(of:)`. Capture is now scheduled from an observer on the main run loop, and session replay skips rendering while the screen keeps producing identical frames. Wireframe mode now skips unchanged frames too, so idle screens send fewer `$snapshot` events. Surveys no longer require swizzling, so apps that set `enableSwizzling` to `false` now get surveys instead of having the integration skipped. |
Large diffs are not rendered by default.
Oops, something went wrong.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| #if os(iOS) | ||
| import Foundation | ||
|
|
||
| /// Publishes capture opportunities from the main run loop, so nothing has to swizzle | ||
| /// `UIView.layoutSublayers(of:)` to learn that the screen may have changed. | ||
| /// | ||
| /// An opportunity is a hint that capture is worth considering, not proof that a new frame exists. | ||
| /// Dedup, throttling and in-flight protection stay where they already live: the throttle on | ||
| /// `onOpportunity` and the renderer behind it. Nothing here is per-subscriber, so one subscriber's | ||
| /// capture policy cannot slow another's. | ||
| protocol RunLoopOpportunityPublishing: AnyObject { | ||
| var onOpportunity: PostHogThrottledMulticastCallback<Void> { get } | ||
|
|
||
| /// Whether an observer is currently registered. Main-confined; read it from the main thread. | ||
| var isObserving: Bool { get } | ||
| } | ||
|
|
||
| final class ApplicationRunLoopPublisher: RunLoopOpportunityPublishing { | ||
| static let shared = ApplicationRunLoopPublisher() | ||
|
|
||
| var onOpportunity: PostHogThrottledMulticastCallback<Void> { callbacks } | ||
| private var callbacks: PostHogThrottledMulticastCallback<Void>! | ||
|
|
||
| // Main-confined. Read and written only from blocks already running on the main thread, which is | ||
| // also where the observer callback runs, so the hot path takes no lock. | ||
| private var observer: CFRunLoopObserver? | ||
| private var didConsiderCurrentCycle = false | ||
|
|
||
| var isObserving: Bool { | ||
| observer != nil | ||
| } | ||
|
|
||
| init() { | ||
| // Initialize before publication; Swift lazy properties are not safe on concurrent first access. | ||
| // The count is not captured: `reconcile()` reads the multicast's live count instead, so a | ||
| // hop that lands out of order still resolves to the latest demand. | ||
| callbacks = PostHogThrottledMulticastCallback<Void> { [weak self] _ in | ||
| self?.reconcileOnMain() | ||
| } | ||
| } | ||
|
|
||
| deinit { | ||
| // The run loop retains the observer, so it outlives us unless it is invalidated here. | ||
| // Reading the main-confined field off main is safe only here: `deinit` runs after the last | ||
| // reference is gone, so nothing else can be mutating it, and invalidating a local copy | ||
| // needs no hop back to main — a hop would have to capture `self` and resurrect it. | ||
| // Invalidate alone is enough; it also removes the observer from every run loop it is in. | ||
| if let installed = observer { | ||
| CFRunLoopObserverInvalidate(installed) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Lifecycle | ||
|
|
||
| private func onMain(_ body: @escaping (ApplicationRunLoopPublisher) -> Void) { | ||
| if Thread.isMainThread { | ||
| body(self) | ||
| } else { | ||
| DispatchQueue.main.async { [weak self] in | ||
| guard let self else { return } | ||
| body(self) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func reconcileOnMain() { | ||
| onMain { publisher in publisher.reconcile() } | ||
| } | ||
|
|
||
| /// Reconciles the observer against the latest recorded demand. Idempotent, so repeated or | ||
| /// reordered hops converge instead of racing. | ||
| private func reconcile() { | ||
| let wanted = callbacks.subscriberCount > 0 | ||
|
|
||
| guard wanted else { | ||
| uninstall() | ||
| return | ||
| } | ||
| guard observer == nil else { return } | ||
|
|
||
| didConsiderCurrentCycle = false | ||
| install() | ||
| } | ||
|
|
||
| private func install() { | ||
| // CFRunLoopObserver callbacks for the same activity fire in ascending order, and Core | ||
| // Animation's commit observer sits at 2_000_000. A larger order therefore runs after that | ||
| // commit in practice — the relative ordering of another framework's observer is not a | ||
| // documented guarantee, so capture must not depend on it for correctness. | ||
| let activities: CFRunLoopActivity = [.afterWaiting, .beforeWaiting, .exit] | ||
| guard let observer = CFRunLoopObserverCreateWithHandler( | ||
| nil, | ||
| activities.rawValue, | ||
| true, | ||
| 2_100_000, | ||
| { [weak self] _, activity in | ||
| self?.handle(activity) | ||
| } | ||
| ) else { | ||
| // Nothing retries this: without the observer no subscriber is ever offered another | ||
| // capture opportunity, so say so rather than failing silently. | ||
| hedgeLog("[Run Loop] Could not create the main run loop observer - session replay and surveys get no more capture opportunities") | ||
| return | ||
| } | ||
|
|
||
| self.observer = observer | ||
| // commonModes so tracking modes (scrolling, gestures) are covered as well as the default mode. | ||
| CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) | ||
| } | ||
|
|
||
| private func uninstall() { | ||
| guard let observer else { return } | ||
| self.observer = nil | ||
| didConsiderCurrentCycle = false | ||
| // Invalidate removes the observer from every run loop it was added to, so an explicit | ||
| // CFRunLoopRemoveObserver first would be redundant. | ||
| CFRunLoopObserverInvalidate(observer) | ||
| } | ||
|
|
||
| // MARK: - Observer callback | ||
|
|
||
| private func handle(_ activity: CFRunLoopActivity) { | ||
| // Waking up begins a new cycle. `.beforeWaiting` and `.exit` can both land in one cycle, so | ||
| // the flag keeps that cycle to a single opportunity. | ||
| if activity.contains(.afterWaiting) { | ||
| didConsiderCurrentCycle = false | ||
| return | ||
| } | ||
| // A backgrounded app has nothing to capture for anybody. Read rather than tracked, so a | ||
| // publisher installed mid-background is paused without waiting for the next notification. | ||
| guard !DI.main.appLifecyclePublisher.isInBackground, !didConsiderCurrentCycle else { return } | ||
| didConsiderCurrentCycle = true | ||
| #if TESTING | ||
| emittedOpportunities += 1 | ||
| #endif | ||
| onOpportunity.invoke(()) | ||
| } | ||
|
|
||
| #if TESTING | ||
| /// Feeds the callback directly, bypassing the run loop, so per-cycle dedup can be driven | ||
| /// deterministically from a test. | ||
| func simulateActivity(_ activity: CFRunLoopActivity) { | ||
| handle(activity) | ||
| } | ||
|
|
||
| /// Counts emitted opportunities at the source, so a test can assert without waiting on the | ||
| /// multicast's main-queue delivery. | ||
| private(set) var emittedOpportunities = 0 | ||
| #endif | ||
| } | ||
| #endif | ||
This file was deleted.
Oops, something went wrong.
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
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.