Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ let config = { () -> LDConfig in
sessionBackgroundTimeout: 3)),
SessionReplay(options: .init(
isEnabled: true,
sampleRate: 1.0,
privacy: .init(
maskTextInputs: true,
maskWebViews: false,
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
23 changes: 15 additions & 8 deletions Sources/LaunchDarklySessionReplay/API/LDReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
18 changes: 10 additions & 8 deletions Sources/LaunchDarklySessionReplay/SessionReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public final class SessionReplay: Plugin {
self.sessionReplayService = sessionReplayService
sessionReplayHook.delegate = sessionReplayService
if options.isEnabled {
start()
Task { @MainActor in
sessionReplayService.isEnabled = true
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
} catch {
os_log("%{public}@", log: options.log, type: .error, "Session Replay Service initialization failed with error: \(error)")
Expand All @@ -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()
}
}

35 changes: 35 additions & 0 deletions Sources/LaunchDarklySessionReplay/SessionReplaySampling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
}
}

/// 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
}
}
57 changes: 47 additions & 10 deletions Sources/LaunchDarklySessionReplay/SessionReplayService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -50,19 +53,38 @@ 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
private var samplingSession = SessionReplaySamplingSession()

@MainActor
var isEnabled: Bool {
get {
_isEnabled
}
set {
guard _isEnabled != newValue else { return }
_isEnabled = newValue
if newValue {
_ = start(ignoreSampling: false)
} else {
internalStop()
stop()
}
}
}

@MainActor
var isRunning: Bool {
_isRunning
}

private var cancellables = Set<AnyCancellable>()

Expand All @@ -74,6 +96,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
Expand Down Expand Up @@ -132,8 +155,18 @@ final class SessionReplayService: SessionReplayServicing {
}

@MainActor
func start() {
isEnabled = true
func start(ignoreSampling: Bool = false) -> SessionReplayStartResult {
_isEnabled = true
guard !_isRunning else { return .alreadyStarted }

guard samplingSession.shouldStartCapture(ignoreSampling: ignoreSampling, sampleRate: sampleRate) else {
os_log("LaunchDarkly Session Replay skipped by sampling.", log: log, type: .info)
return .sampledOut
}

_isRunning = true
internalStart()
return .started
Comment thread
cursor[bot] marked this conversation as resolved.
}

@MainActor
Expand All @@ -156,7 +189,11 @@ final class SessionReplayService: SessionReplayServicing {

@MainActor
func stop() {
isEnabled = false
_isEnabled = false
samplingSession.reset()
guard _isRunning else { return }
_isRunning = false
internalStop()
}

@MainActor
Expand Down
45 changes: 45 additions & 0 deletions Tests/SessionReplayTests/SessionReplaySamplingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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)
}

@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 }))
}
}
Loading