Skip to content
Merged
Show file tree
Hide file tree
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 Sep 30, 2026
f423166
feat(surveys): move surveys onto the shared run-loop publisher so not…
ioannisj Sep 30, 2026
85623f9
chore: add changeset for the run-loop capture scheduler
ioannisj Sep 30, 2026
8c601f1
Merge branch 'main' into explore/813-runloop-replay-scheduler
ioannisj Oct 2, 2026
8710c71
fix(replay): wake the capture backoff when the session changes
ioannisj Oct 5, 2026
db68cde
Merge branch 'main' into explore/813-runloop-replay-scheduler
ioannisj Oct 5, 2026
98ec4ca
fix(replay): clamp the capture interval so a zero throttleDelay can't…
ioannisj Oct 27, 2026
66f8e4a
fix(replay): capture touchless screen changes while replay is backed off
ioannisj Oct 27, 2026
0325e2f
Merge remote-tracking branch 'origin/main' into explore/813-runloop-r…
ioannisj Nov 3, 2026
0156565
fix(replay): include text and background color in the structure finge…
ioannisj Nov 5, 2026
573e0fd
docs(replay): mention wireframe dedup and drop surveys from enableSwi…
ioannisj Nov 5, 2026
2eb89b4
fix(replay): keep an inactive session when capturing idle frames
ioannisj Nov 5, 2026
a8241ad
fix(replay): read only model values in the structure fingerprint walk
ioannisj Nov 5, 2026
d17867b
fix(replay): retry a detected change when its capture is dropped
ioannisj Nov 5, 2026
ef1b16a
fix(replay): resend a window's frame after another window sent the la…
ioannisj Nov 5, 2026
a18024c
Merge remote-tracking branch 'origin/main' into explore/813-runloop-r…
ioannisj Nov 5, 2026
eba8365
test(replay): use the scoped clock in the idle session test
ioannisj Nov 5, 2026
cbbe38f
chore: bump the run-loop replay changeset to minor
ioannisj Nov 5, 2026
f5ad1e7
fix(replay): see SwiftUI updates in the backed-off structure fingerprint
ioannisj Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-jars-listen.md
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.
32 changes: 24 additions & 8 deletions PostHog.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

151 changes: 151 additions & 0 deletions PostHog/ApplicationRunLoopPublisher.swift
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(())
Comment thread
ioannisj marked this conversation as resolved.
Resolved
}

#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
140 changes: 0 additions & 140 deletions PostHog/ApplicationViewLayoutPublisher.swift

This file was deleted.

4 changes: 2 additions & 2 deletions PostHog/DI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ enum DI {
#endif

#if os(iOS)
// publishes global view layout events within a throttle interval (UIView.layoutSubviews)
lazy var viewLayoutPublisher: ViewLayoutPublishing = ApplicationViewLayoutPublisher.shared
// publishes main run-loop capture opportunities (CFRunLoopObserver)
lazy var runLoopPublisher: RunLoopOpportunityPublishing = ApplicationRunLoopPublisher.shared
#endif

#if os(iOS) || os(macOS)
Expand Down
2 changes: 1 addition & 1 deletion PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?

/// Enable method swizzling for SDK functionality that depends on it
///
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay) will not be installed.
///
/// Note: Disabling swizzling will limit session rotation logic to only detect application open and background events.
/// Session rotation will still work, just with reduced granularity for detecting user activity.
Expand Down
4 changes: 4 additions & 0 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3833,6 +3833,10 @@ let maxRetryDelay = 30.0
func getReplayIntegration() -> PostHogReplayIntegration? {
getIntegration()
}

func getSurveysIntegration() -> PostHogSurveyIntegration? {
getIntegration()
}
#endif

#if os(iOS) || os(macOS) || os(tvOS)
Expand Down
8 changes: 6 additions & 2 deletions PostHog/PostHogSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,16 @@ import Foundation
- readOnly: When true, bypasses all session management logic and returns
the current session id without modifications.
Defaults to false.
- keepingInactiveSession: When true, an inactive foreground session stays current, for
reads that aren't user activity. The maximum session length still applies.
Defaults to false.

- Returns: Returns the existing session id, or a new one after performing validity checks
*/
func getSessionId(
at timeNow: Date = now(),
readOnly: Bool = false
readOnly: Bool = false,
keepingInactiveSession: Bool = false
) -> String? {
let timestamp = timeNow.timeIntervalSince1970
let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock {
Expand All @@ -124,7 +128,7 @@ import Foundation
}

// Check if session has passed maximum inactivity length
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
if let lastActive, isBackgrounded || !keepingInactiveSession, isExpired(timestamp, lastActive, sessionActivityThreshold) {
return isBackgrounded
? clearSession(reason: .sessionTimeout)
: rotateSession(at: timeNow, reason: .sessionTimeout)
Expand Down
Loading
Loading