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
60 changes: 43 additions & 17 deletions Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,11 @@
import Foundation
import OSLog
@_exported import OpenTelemetryApi
///
/// Configuration options for the Observability plugin.
///
/// - serviceName The service name for the application. Defaults to the app package name if not set.
/// - serviceVersion The version of the service. Defaults to the app version if not set.
/// - otlpEndpoint The OTLP exporter endpoint. Defaults to LaunchDarkly endpoint.
/// - backendUrl The backend URL for non-OTLP operations. Defaults to LaunchDarkly url.
/// - resourceAttributes Additional resource attributes to include in telemetry data.
/// - customHeaders Custom headers to include with OTLP exports.
/// - sessionBackgroundTimeout Session timeout if app is backgrounded. Defaults to 15 minutes. 15 * 60
/// - isDebug Enables verbose telemetry logging if true as well as other debug functionality. Defaults to false.
/// - disableLogs Disables logs if true. Defaults to false.
/// - disableTraces Disables traces if true. Defaults to false.
/// - disableMetrics Disables metrics if true. Defaults to false.
/// - logAdapter The log adapter to use. Defaults to using the LaunchDarkly SDK's LDTimberLogging.adapter(). ///Use LDAndroidLogging.adapter() to use the Android logging adapter.
/// - loggerName The name of the logger to use. Defaults to "LaunchDarklyObservabilityPlugin".
///

/// Configuration options for the LaunchDarkly Observability plugin.
///
/// Pass an instance to the plugin at initialisation to control the OTLP exporter
/// endpoint, telemetry levels, automatic instrumentation, and crash reporting.
public struct ObservabilityOptions {
public enum Defaults {
public static let otlpEndpoint = "https://otel.observability.app.launchdarkly.com:4318"
Expand Down Expand Up @@ -205,6 +192,45 @@ public struct ObservabilityOptions {
public var crashReporting: CrashReporting
public var instrumentation: Instrumentation

/// Creates a configuration for the Observability plugin.
///
/// - Parameters:
/// - isEnabled: Whether the plugin emits telemetry. When `false` the plugin is installed
/// but no logs, traces, or metrics are exported. Defaults to `true`.
/// - serviceName: The OpenTelemetry `service.name` attribute reported with every signal.
/// Defaults to `"observability-swift"`.
/// - serviceVersion: The OpenTelemetry `service.version` attribute reported with every
/// signal. Defaults to `"0.1.0"`.
/// - otlpEndpoint: The OTLP/HTTP exporter endpoint. `nil` or an empty string falls back
/// to ``Defaults/otlpEndpoint``.
/// - backendUrl: The backend URL used for non-OTLP operations (e.g. session metadata).
/// `nil` or an empty string falls back to ``Defaults/backendUrl``.
/// - contextFriendlyName: An optional human-readable name attached to the LaunchDarkly
/// context for this session. Defaults to `nil`.
/// - resourceAttributes: Additional OpenTelemetry resource attributes merged into every
/// signal. Defaults to an empty dictionary.
/// - customHeaders: Extra HTTP headers added to OTLP exports (e.g. for proxies or auth).
/// Defaults to an empty dictionary.
/// - tracingOrigins: Which outgoing request origins should propagate distributed tracing
/// headers. Defaults to ``TracingOriginsOption/disabled``.
/// - urlBlocklist: URL patterns to exclude from automatic URLSession instrumentation.
/// Defaults to an empty array.
/// - sessionBackgroundTimeout: How long the app may stay in the background before the
/// current session is ended. Defaults to 15 minutes.
/// - isDebug: Enables verbose internal logging and other debug behaviour. Defaults to
/// `false`.
/// - logsApiLevel: Minimum severity of logs forwarded to the OpenTelemetry logs pipeline.
/// Use ``LogLevel/none`` to disable logs entirely. Defaults to ``LogLevel/info``.
/// - tracesApi: Controls automatic trace generation (errors and spans). Use
/// ``AppTracing/disabled`` to turn tracing off. Defaults to ``AppTracing/enabled``.
/// - metricsApi: Controls metric export. Use ``AppMetrics/disabled`` to turn metrics
/// off. Defaults to ``AppMetrics/enabled``.
/// - log: The `OSLog` used for the plugin's own diagnostic output. Defaults to a logger
/// under subsystem `"com.launchdarkly"` and category `"LaunchDarklyObservabilityPlugin"`.
/// - crashReporting: Crash-reporting configuration, including which provider to use
/// (KSCrash or MetricKit). Defaults to ``CrashReporting/enabled`` (KSCrash).
/// - instrumentation: Per-feature toggles for automatic instrumentation (URLSession,
/// user taps, memory, CPU, launch times, …). Defaults to all features disabled.
public init(
isEnabled: Bool = true,
serviceName: String = "observability-swift",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,49 @@ struct SessionReplayViewRepresentable: UIViewRepresentable {
self.isIgnored = isIgnored
}

/// Marker view inserted by `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` /
/// `.ldPrivate(...)` SwiftUI modifiers.
///
/// Because `SessionReplayModifier` attaches itself via `.overlay()`, this
/// view ends up as a *sibling* of the modified content in the UIKit
/// hierarchy — not an ancestor. The view itself carries the explicit
/// mask/ignore state via associated objects; `MaskCollector` then detects
/// these markers at collection time, walks up to the lowest common
/// ancestor of the overlay branch and the content branch, and propagates
/// the explicit state to that ancestor so it reaches the modified
/// content.
class MaskView: UIView {
// Tracks how many markers are currently attached to a window so
// `MaskCollector` can skip its per-frame UIView walk entirely
// when the running app uses no `.ldMask()` / `.ldUnmask()` /
// `.ldIgnore()` modifiers.
//
// Mutated only on the main thread in `didMoveToWindow`. Reads
// happen on the screen-capture queue; a stale read at worst
// costs one frame of skipped/extra work, which is acceptable
// because the next capture will see the corrected value.
private static let liveMarkerLock = NSLock()
private static var liveMarkerCount: Int = 0
private var isCounted: Bool = false

static var hasLiveMarkers: Bool {
liveMarkerLock.lock()
defer { liveMarkerLock.unlock() }
return liveMarkerCount > 0
}

private static func incrementLiveMarkers() {
liveMarkerLock.lock()
liveMarkerCount += 1
liveMarkerLock.unlock()
}

private static func decrementLiveMarkers() {
liveMarkerLock.lock()
liveMarkerCount = max(0, liveMarkerCount - 1)
liveMarkerLock.unlock()
}

override func didMoveToSuperview() {
super.didMoveToSuperview()
// We want to make sure the wrapper view created by SwiftUI also doesn't intercept touches
Expand All @@ -34,6 +76,21 @@ struct SessionReplayViewRepresentable: UIViewRepresentable {
override func didMoveToWindow() {
super.didMoveToWindow()
superview?.isUserInteractionEnabled = false

let isAttached = window != nil
if isAttached, !isCounted {
isCounted = true
Self.incrementLiveMarkers()
} else if !isAttached, isCounted {
isCounted = false
Self.decrementLiveMarkers()
}
}

deinit {
if isCounted {
Self.decrementLiveMarkers()
}
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public struct RawFrame {
public final class ImageCaptureService {
private let maskingService = MaskApplier()
private let maskCollector: MaskCollector
private let maskStabilizer = MaskStabilizer()
private let windowCaptureManager = WindowCaptureManager()
@MainActor
private var shouldCapture = false
Expand Down Expand Up @@ -76,7 +77,7 @@ public final class ImageCaptureService {
var applyOperations = [[MaskOperation]]()
var areas = [OffsettedArea]()
for (before, after) in zip(maskOperationsBefore, maskOperationsAfter) {
if let newOperations = maskCollector.duplicateUnsimilar(before: before.maskOperations, after: after.maskOperations) {
if let newOperations = self.maskStabilizer.duplicateUnsimilar(before: before.maskOperations, after: after.maskOperations) {
areas.append(contentsOf: before.offsetRects)
applyOperations.append(newOperations)
} else {
Expand Down
158 changes: 158 additions & 0 deletions Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Foundation
import UIKit
import SwiftUI

/// Scans a UIView hierarchy for SwiftUI marker views inserted by
/// `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` and projects their
/// frames into the root layer's coordinate space.
///
/// Because `SessionReplayModifier` attaches its marker view via
/// `.overlay(...)`, the marker ends up as a *sibling* (or completely
/// disjoint, on iOS 26 Liquid Glass) of the modified content in the
/// UIKit hierarchy. Direct ancestor propagation therefore can't reach
/// the content. Instead, `MaskCollector` uses the rectangles returned
/// here as governing areas: any layer/view whose frame is contained in
/// one of these areas inherits the marker's explicit state.
final class MarkerScanner {
/// Aggregated explicit state combined from any number of SwiftUI
/// marker views whose areas contain the layer being evaluated.
struct MarkerOverride {
var mask: Bool?
var ignore: Bool?

/// Mask precedence: any `mask=true` wins; otherwise any `mask=false` wins.
/// Ignore is OR-combined.
mutating func combine(mask newMask: Bool?, ignore newIgnore: Bool?) {
if newMask == true {
mask = true
} else if newMask == false, mask != true {
mask = false
}
if newIgnore == true {
ignore = true
}
}
}

/// A SwiftUI marker view's projected frame in the root layer's
/// coordinate space, plus the explicit state the developer attached
/// to it via `.ldMask()` / `.ldUnmask()` / `.ldIgnore()`.
///
/// `.overlay()` always sizes the marker to the modified content's
/// rendered bounds, so this rectangle *is* the area the modifier is
/// supposed to govern — regardless of how SwiftUI flattens the
/// surrounding UIKit/CALayer hierarchy. During collection we apply
/// the marker's state to any layer whose own frame is contained
/// inside this rectangle.
struct MarkerArea {
var frameInRoot: CGRect
var mask: Bool?
var ignore: Bool?
}

/// Walks the UIView hierarchy under `rootView` and records:
/// 1. A `MarkerArea` for every `SessionReplayViewRepresentable.MaskView`,
/// whose rectangle is the marker's bounds projected into
/// `rPresentation`'s coordinate space.
/// 2. The set of UIViews that form the *overlay branch wrapper
/// chain* — the marker view itself plus every ancestor with
/// exactly one subview, walking up until we hit a multi-child
/// ancestor. These wrappers are co-located with the marker area
/// but contain no visible content of their own; the visit pass
/// must skip them or every wrapper would receive a duplicate
/// mask operation.
///
/// Because SwiftUI's `.overlay(...)` always sizes the marker to the
/// modified content's bounding box, this rectangle is exactly the
/// area the developer's `.ldMask()` / `.ldUnmask()` / `.ldIgnore()`
/// modifier governs — regardless of whether SwiftUI flattens that
/// content into a UIView sibling, a deeply nested UIView, or a pure
/// CALayer (iOS 26 Liquid Glass).
func scan(
in rootView: UIView,
rPresentation: CALayer
) -> (areas: [MarkerArea], overlayBranchViews: Set<ObjectIdentifier>) {
var areas: [MarkerArea] = []
var overlayBranchViews: Set<ObjectIdentifier> = []

// Iterative DFS using a reusable stack avoids the per-call
// closure/frame allocation of recursion on busy screens. A
// typical screen has 100-500 UIViews and this runs once per
// capture frame.
var stack: [UIView] = [rootView]
stack.reserveCapacity(64)
while let view = stack.popLast() {
if let marker = view as? SessionReplayViewRepresentable.MaskView,
marker.window != nil {
Self.recordOverlayBranch(of: marker, into: &overlayBranchViews)

let mask = SessionReplayAssociatedObjects.shouldMaskUIView(marker)
let ignore = SessionReplayAssociatedObjects.shouldIgnoreUIView(marker)
if mask != nil || ignore != nil {
// `MaskCollector.collectViewMasks` recurses through
// `rPresentation.sublayers`, so every `effectiveFrame`
// it later compares against is computed in pure
// presentation coordinates. We must project the
// marker through its own presentation layer too —
// otherwise during an active animation (e.g. a
// horizontal navigation push/pop) the `from:` chain
// reads model `transform`/`position` while the
// receiver `rPresentation` is mid-animation, the
// resulting `frameInRoot` lands in the wrong
// coordinate system, and `frameContains` checks fail
// for every visited layer until the animation
// finishes. `presentation()` returns nil when the
// layer isn't animating, in which case model and
// presentation are identical and the fallback is
// exact.
let markerLayer = marker.layer.presentation() ?? marker.layer
let frameInRoot = rPresentation.convert(markerLayer.bounds, from: markerLayer)
if frameInRoot.width > 0, frameInRoot.height > 0 {
areas.append(MarkerArea(frameInRoot: frameInRoot, mask: mask, ignore: ignore))
}
}
// `MaskView` is a leaf in our hierarchy — we never add
// subviews to it. UIKit also won't add any. Skip
// descending.
continue
}
stack.append(contentsOf: view.subviews)
}

return (areas, overlayBranchViews)
}

/// Adds the `MaskView` and the wrapper chain immediately above it
/// to `set`. The wrappers we want to skip are bridging views whose
/// only purpose is to host the marker itself; they are
/// distinguishable by two simultaneous properties:
///
/// 1. The parent has exactly one subview (the chain wrapper).
/// 2. The parent's bounds are the same size as the marker.
///
/// Property 1 alone is *not* enough: a real content host such as
/// SwiftUI's `CellHostingView` can also have a single subview (the
/// marker's wrapper) while still owning the actual rendered content
/// as sublayers — and its bounds are much larger than the marker.
/// Walking past that host would cause `visit` to skip the entire
/// content subtree, eliminating every mask op.
private static func recordOverlayBranch(
of marker: SessionReplayViewRepresentable.MaskView,
into set: inout Set<ObjectIdentifier>
) {
let markerSize = marker.bounds.size
let tolerance: CGFloat = 1.0

var current: UIView = marker
while true {
set.insert(ObjectIdentifier(current))
guard let parent = current.superview,
parent.subviews.count == 1,
abs(parent.bounds.width - markerSize.width) <= tolerance,
abs(parent.bounds.height - markerSize.height) <= tolerance else {
break
}
current = parent
}
}
}
Loading
Loading