From 6dfd692c3eacaf39bbe3c6c9b94a7d7674d5e2c9 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 26 May 2026 18:47:51 -0700 Subject: [PATCH 1/4] enhance session replay control with sampling and state management - Added `isRunning` property to track the current state of session replay. - Updated `start` method to accept an `ignoreSampling` parameter for debugging purposes. - Refactored `isEnabled` to manage session replay state more effectively. - Improved documentation for session replay options, including sampling behavior. --- .../API/LDReplay.swift | 23 ++++++--- .../API/SessionReplayOptions.swift | 5 ++ .../API/SessionReplayStartResult.swift | 19 +++++++ .../SessionReplay.swift | 18 ++++--- .../SessionReplaySampling.swift | 10 ++++ .../SessionReplayService.swift | 51 +++++++++++++++---- .../SessionReplaySamplingTests.swift | 29 +++++++++++ 7 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 Sources/LaunchDarklySessionReplay/API/SessionReplayStartResult.swift create mode 100644 Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift create mode 100644 Tests/SessionReplayTests/SessionReplaySamplingTests.swift diff --git a/Sources/LaunchDarklySessionReplay/API/LDReplay.swift b/Sources/LaunchDarklySessionReplay/API/LDReplay.swift index 753406ca..3b5f3860 100644 --- a/Sources/LaunchDarklySessionReplay/API/LDReplay.swift +++ b/Sources/LaunchDarklySessionReplay/API/LDReplay.swift @@ -14,23 +14,30 @@ public final class LDReplay { private init() { // privacy for singleton } - + + /// Starts or stops Session Replay. Setting this to `true` applies sampling. @MainActor public var isEnabled: Bool { get { client?.isEnabled ?? false } set { client?.isEnabled = newValue } } + + /// Whether Session Replay is currently running. + @MainActor + public var isRunning: Bool { + client?.isRunning ?? false + } - public func start() { - Task { @MainActor in - client?.start() - } + /// Starts Session Replay. Set `ignoreSampling` to `true` to force start for debugging. + @MainActor + @discardableResult + public func start(ignoreSampling: Bool = false) -> SessionReplayStartResult { + client?.start(ignoreSampling: ignoreSampling) ?? .unavailable } + @MainActor public func stop() { - Task { @MainActor in - client?.stop() - } + client?.stop() } } diff --git a/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift b/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift index 709a91c8..d98bc278 100644 --- a/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift +++ b/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift @@ -50,17 +50,22 @@ public struct SessionReplayOptions { } public var isEnabled: Bool + /// Probability from `0.0` to `1.0` that Session Replay starts when enabled. + /// Values less than or equal to zero never start; values greater than or equal to one always start. + public var sampleRate: Double public var compression: CompressionMethod = .overlayTiles() public var serviceName: String public var privacy = PrivacyOptions() public var log: OSLog public init(isEnabled: Bool = true, + sampleRate: Double = 1.0, serviceName: String = "sessionreplay-swift", privacy: PrivacyOptions = PrivacyOptions(), compression: CompressionMethod = .overlayTiles(), log: OSLog = OSLog(subsystem: "com.launchdarkly", category: "LaunchDarklySessionReplayPlugin")) { self.isEnabled = isEnabled + self.sampleRate = sampleRate self.serviceName = serviceName self.privacy = privacy self.compression = compression diff --git a/Sources/LaunchDarklySessionReplay/API/SessionReplayStartResult.swift b/Sources/LaunchDarklySessionReplay/API/SessionReplayStartResult.swift new file mode 100644 index 00000000..db138eb4 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/API/SessionReplayStartResult.swift @@ -0,0 +1,19 @@ +public enum SessionReplayStartResult: Equatable { + /// Session Replay was not installed or has not finished registering. + case unavailable + /// Session Replay is now running because this call started it. + case started + /// Session Replay was already running before this call. + case alreadyStarted + /// Session Replay stayed stopped because the session was sampled out. + case sampledOut + + public var isRunning: Bool { + switch self { + case .started, .alreadyStarted: + return true + case .unavailable, .sampledOut: + return false + } + } +} diff --git a/Sources/LaunchDarklySessionReplay/SessionReplay.swift b/Sources/LaunchDarklySessionReplay/SessionReplay.swift index edc3faa1..f21f60ae 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplay.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplay.swift @@ -40,7 +40,9 @@ public final class SessionReplay: Plugin { self.sessionReplayService = sessionReplayService sessionReplayHook.delegate = sessionReplayService if options.isEnabled { - start() + Task { @MainActor in + self.start() + } } } catch { os_log("%{public}@", log: options.log, type: .error, "Session Replay Service initialization failed with error: \(error)") @@ -51,16 +53,16 @@ public final class SessionReplay: Plugin { return [sessionReplayHook] } - public func start() { - Task { @MainActor in - sessionReplayService?.start() - } + /// Starts Session Replay. Set `ignoreSampling` to `true` to force start for debugging. + @MainActor + @discardableResult + public func start(ignoreSampling: Bool = false) -> SessionReplayStartResult { + sessionReplayService?.start(ignoreSampling: ignoreSampling) ?? .unavailable } + @MainActor public func stop() { - Task { @MainActor in - sessionReplayService?.stop() - } + sessionReplayService?.stop() } } diff --git a/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift b/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift new file mode 100644 index 00000000..0cc6ab6c --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift @@ -0,0 +1,10 @@ +import Foundation + +enum SessionReplaySampling { + static func shouldSample(sampleRate: Double, randomValue: () -> Double = { Double.random(in: 0..<1) }) -> Bool { + guard sampleRate > 0 else { return false } + guard sampleRate < 1 else { return true } + + return randomValue() < sampleRate + } +} diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index 86be2e48..d0d29926 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -10,13 +10,16 @@ import Common protocol SessionReplayServicing: AnyObject { @MainActor - func start() + func start(ignoreSampling: Bool) -> SessionReplayStartResult @MainActor func stop() @MainActor var isEnabled: Bool { get set } + + @MainActor + var isRunning: Bool { get } func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool) } @@ -50,19 +53,34 @@ final class SessionReplayService: SessionReplayServicing { var sessionReplayExporter: SessionReplayExporter let userInteractionManager: UserInteractionManager let log: OSLog + let sampleRate: Double var observabilityContext: ObservabilityContext @MainActor - var isEnabled = false { - didSet { - guard oldValue != isEnabled else { return } - if isEnabled { - internalStart() + private var _isEnabled = false + + @MainActor + private var _isRunning = false + + @MainActor + var isEnabled: Bool { + get { + _isEnabled + } + set { + _isEnabled = newValue + if newValue { + _ = start(ignoreSampling: false) } else { - internalStop() + stop() } } } + + @MainActor + var isRunning: Bool { + _isRunning + } private var cancellables = Set() @@ -74,6 +92,7 @@ final class SessionReplayService: SessionReplayServicing { } self.observabilityContext = observabilityContext self.log = observabilityContext.options.log + self.sampleRate = sessonReplayOptions.sampleRate let graphQLClient = GraphQLClient(endpoint: url, defaultHeaders: ["User-Agent": ObservabilitySDKInfo.userAgent()]) let captureService = ImageCaptureService(options: sessonReplayOptions) self.transportService = observabilityContext.transportService @@ -129,8 +148,17 @@ final class SessionReplayService: SessionReplayServicing { } @MainActor - func start() { - isEnabled = true + func start(ignoreSampling: Bool = false) -> SessionReplayStartResult { + guard !_isRunning else { return .alreadyStarted } + guard ignoreSampling || SessionReplaySampling.shouldSample(sampleRate: sampleRate) else { + os_log("LaunchDarkly Session Replay skipped by sampling.", log: log, type: .info) + return .sampledOut + } + + _isRunning = true + _isEnabled = true + internalStart() + return .started } @MainActor @@ -153,7 +181,10 @@ final class SessionReplayService: SessionReplayServicing { @MainActor func stop() { - isEnabled = false + _isEnabled = false + guard _isRunning else { return } + _isRunning = false + internalStop() } @MainActor diff --git a/Tests/SessionReplayTests/SessionReplaySamplingTests.swift b/Tests/SessionReplayTests/SessionReplaySamplingTests.swift new file mode 100644 index 00000000..909755b6 --- /dev/null +++ b/Tests/SessionReplayTests/SessionReplaySamplingTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import LaunchDarklySessionReplay + +struct SessionReplaySamplingTests { + @Test("sampleRate defaults to always sample") + func sampleRateDefaultsToAlwaysSample() { + #expect(SessionReplayOptions().sampleRate == 1.0) + #expect(SessionReplaySampling.shouldSample(sampleRate: 1.0, randomValue: { 0.99 })) + } + + @Test("sampleRate zero disables session replay") + func sampleRateZeroDisablesSessionReplay() { + #expect(SessionReplaySampling.shouldSample(sampleRate: 0.0, randomValue: { 0.0 }) == false) + } + + @Test("sampleRate samples when random value is below rate") + func sampleRateSamplesBelowRate() { + #expect(SessionReplaySampling.shouldSample(sampleRate: 0.5, randomValue: { 0.49 })) + #expect(SessionReplaySampling.shouldSample(sampleRate: 0.5, randomValue: { 0.5 }) == false) + } + + @Test("start result indicates whether session replay is running") + func startResultIndicatesRunningState() { + #expect(SessionReplayStartResult.started.isRunning) + #expect(SessionReplayStartResult.alreadyStarted.isRunning) + #expect(SessionReplayStartResult.sampledOut.isRunning == false) + #expect(SessionReplayStartResult.unavailable.isRunning == false) + } +} From a7cdcaad8fef4fe825da30720ce6c5679ce65642 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 26 May 2026 18:59:00 -0700 Subject: [PATCH 2/4] update readmy --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41f384c8..b1e9c667 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ let config = { () -> LDConfig in sessionBackgroundTimeout: 3)), SessionReplay(options: .init( isEnabled: true, + sampleRate: 1.0, privacy: .init( maskTextInputs: true, maskWebViews: false, @@ -179,7 +180,17 @@ final class AppDelegate: NSObject, UIApplicationDelegate { ### Manual Start -By default, Session Replay starts recording as soon as the SDK is initialized if `isEnabled` is set to `true`. If you want to initialize Session Replay without activating recording immediately (e.g., to wait for user consent or a specific event), set `isEnabled` to `false` in the options: +By default, Session Replay attempts to start recording as soon as the SDK is initialized if `isEnabled` is set to `true`. The `sampleRate` option controls whether that attempt actually starts recording. Use a value from `0.0` to `1.0`, where `0.0` never records and `1.0` always records. + +```swift +SessionReplay(options: .init( + isEnabled: true, + sampleRate: 0.25, + // ... other options +)) +``` + +If you want to initialize Session Replay without activating recording immediately (e.g., to wait for user consent or a specific event), set `isEnabled` to `false` in the options: ```swift SessionReplay(options: .init( @@ -188,12 +199,36 @@ SessionReplay(options: .init( )) ``` -You can then activate recording later by setting `LDReplay.shared.isEnabled` to `true`. +You can then attempt to activate recording later by setting `LDReplay.shared.isEnabled` to `true`. This still applies sampling. + ```swift // From a SwiftUI View or @MainActor isolated class LDReplay.shared.isEnabled = true ``` +To inspect the outcome, use `start()` and check the returned `SessionReplayStartResult`, or read `LDReplay.shared.isRunning` to see whether Session Replay is actually recording: + +```swift +let result = LDReplay.shared.start() + +switch result { +case .started, .alreadyStarted: + // Session Replay is running. +case .sampledOut: + // Session Replay is enabled, but this session was not selected by sampleRate. +case .unavailable: + // Session Replay has not been registered. +} + +let isRecording = LDReplay.shared.isRunning +``` + +For debugging, you can bypass sampling for a manual start: + +```swift +LDReplay.shared.start(ignoreSampling: true) +``` + #### Privacy Options Configure privacy settings to control what data is captured: From 46e6e21f6d7c5bfed84a9d2da81ed93bb66c1d33 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 26 May 2026 19:22:58 -0700 Subject: [PATCH 3/4] fix isEnabled --- Sources/LaunchDarklySessionReplay/SessionReplay.swift | 2 +- Sources/LaunchDarklySessionReplay/SessionReplayService.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LaunchDarklySessionReplay/SessionReplay.swift b/Sources/LaunchDarklySessionReplay/SessionReplay.swift index f21f60ae..a82cdb4e 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplay.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplay.swift @@ -41,7 +41,7 @@ public final class SessionReplay: Plugin { sessionReplayHook.delegate = sessionReplayService if options.isEnabled { Task { @MainActor in - self.start() + sessionReplayService.isEnabled = true } } } catch { diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index d0d29926..3638185c 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -149,6 +149,7 @@ final class SessionReplayService: SessionReplayServicing { @MainActor func start(ignoreSampling: Bool = false) -> SessionReplayStartResult { + _isEnabled = true guard !_isRunning else { return .alreadyStarted } guard ignoreSampling || SessionReplaySampling.shouldSample(sampleRate: sampleRate) else { os_log("LaunchDarkly Session Replay skipped by sampling.", log: log, type: .info) @@ -156,7 +157,6 @@ final class SessionReplayService: SessionReplayServicing { } _isRunning = true - _isEnabled = true internalStart() return .started } From 9caea5cb77f93a826f9e1482a82feb329316873b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 27 May 2026 17:44:25 -0700 Subject: [PATCH 4/4] remeber decision --- .../SessionReplaySampling.swift | 25 +++++++++++++++++++ .../SessionReplayService.swift | 8 +++++- .../SessionReplaySamplingTests.swift | 16 ++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift b/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift index 0cc6ab6c..4dd8cb96 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift @@ -8,3 +8,28 @@ enum SessionReplaySampling { return randomValue() < sampleRate } } + +/// Tracks whether sampling has been decided for the current enable cycle. +internal struct SessionReplaySamplingSession { + private(set) var decisionMade = false + + /// Returns `true` when capture should start; `false` when the session stays sampled out. + mutating func shouldStartCapture( + ignoreSampling: Bool, + sampleRate: Double, + randomValue: () -> Double = { Double.random(in: 0..<1) } + ) -> Bool { + if ignoreSampling { + return true + } + if decisionMade { + return false + } + decisionMade = true + return SessionReplaySampling.shouldSample(sampleRate: sampleRate, randomValue: randomValue) + } + + mutating func reset() { + decisionMade = false + } +} diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index 3638185c..a0583086 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -62,12 +62,16 @@ final class SessionReplayService: SessionReplayServicing { @MainActor private var _isRunning = false + @MainActor + private var samplingSession = SessionReplaySamplingSession() + @MainActor var isEnabled: Bool { get { _isEnabled } set { + guard _isEnabled != newValue else { return } _isEnabled = newValue if newValue { _ = start(ignoreSampling: false) @@ -151,7 +155,8 @@ final class SessionReplayService: SessionReplayServicing { func start(ignoreSampling: Bool = false) -> SessionReplayStartResult { _isEnabled = true guard !_isRunning else { return .alreadyStarted } - guard ignoreSampling || SessionReplaySampling.shouldSample(sampleRate: sampleRate) else { + + guard samplingSession.shouldStartCapture(ignoreSampling: ignoreSampling, sampleRate: sampleRate) else { os_log("LaunchDarkly Session Replay skipped by sampling.", log: log, type: .info) return .sampledOut } @@ -182,6 +187,7 @@ final class SessionReplayService: SessionReplayServicing { @MainActor func stop() { _isEnabled = false + samplingSession.reset() guard _isRunning else { return } _isRunning = false internalStop() diff --git a/Tests/SessionReplayTests/SessionReplaySamplingTests.swift b/Tests/SessionReplayTests/SessionReplaySamplingTests.swift index 909755b6..de7a90e1 100644 --- a/Tests/SessionReplayTests/SessionReplaySamplingTests.swift +++ b/Tests/SessionReplayTests/SessionReplaySamplingTests.swift @@ -26,4 +26,20 @@ struct SessionReplaySamplingTests { #expect(SessionReplayStartResult.sampledOut.isRunning == false) #expect(SessionReplayStartResult.unavailable.isRunning == false) } + + @Test("sampling decision is not re-evaluated after sampled out") + func samplingDecisionIsPersistedUntilReset() { + var session = SessionReplaySamplingSession() + #expect(session.shouldStartCapture(ignoreSampling: false, sampleRate: 0.25, randomValue: { 0.99 }) == false) + #expect(session.shouldStartCapture(ignoreSampling: false, sampleRate: 0.25, randomValue: { 0.0 }) == false) + session.reset() + #expect(session.shouldStartCapture(ignoreSampling: false, sampleRate: 0.25, randomValue: { 0.0 })) + } + + @Test("ignoreSampling bypasses persisted sampled-out decision") + func ignoreSamplingBypassesPersistedDecision() { + var session = SessionReplaySamplingSession() + #expect(session.shouldStartCapture(ignoreSampling: false, sampleRate: 0.25, randomValue: { 0.99 }) == false) + #expect(session.shouldStartCapture(ignoreSampling: true, sampleRate: 0.25, randomValue: { 0.99 })) + } }