diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklyObservability.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklyObservability.xcscheme
new file mode 100644
index 0000000..44dc89c
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklyObservability.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklySessionReplay.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklySessionReplay.xcscheme
new file mode 100644
index 0000000..055a0c3
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchDarklySessionReplay.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme
new file mode 100644
index 0000000..91ea3f3
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj
index f85ebdb..280726a 100644
--- a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj
+++ b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj
@@ -260,6 +260,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -288,6 +289,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
diff --git a/ExampleApp/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ExampleApp/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index f0b4cbc..f9f157e 100644
--- a/ExampleApp/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ExampleApp/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "3d3d89bbf615611a1c3f621477012eafd151e17d3368be33ef104ea60601496c",
+ "originHash" : "4a5cccf0a865c6d66690b969da8d793114025347075398e0d7fe81080a7357cd",
"pins" : [
{
"identity" : "datacompression",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/launchdarkly/ios-client-sdk.git",
"state" : {
- "branch" : "v10",
- "revision" : "068a42287c64d01d0034daec37bbe6155722ab5e"
+ "revision" : "ae57f26fdb6a38cb65b8918ace75ca3a19a88043",
+ "version" : "10.0.0"
}
},
{
diff --git a/ExampleApp/ExampleApp/AppDelegate.swift b/ExampleApp/ExampleApp/AppDelegate.swift
index 28ca503..92dca11 100644
--- a/ExampleApp/ExampleApp/AppDelegate.swift
+++ b/ExampleApp/ExampleApp/AppDelegate.swift
@@ -1,71 +1,17 @@
import UIKit
-import LaunchDarkly
import LaunchDarklyObservability
-let mobileKey = "mob-48fd3788-eab7-4b72-b607-e41712049dbd"
-let config = { () -> LDConfig in
- var config = LDConfig(
- mobileKey: mobileKey,
- autoEnvAttributes: .enabled
- )
- config.plugins = [
- Observability(
- options: .init(
- otlpEndpoint: "http://localhost:4318",
- sessionBackgroundTimeout: 3,
- isDebug: true,
- logs: .enabled,
- traces: .enabled,
- metrics: .enabled
- )
- )
- ]
- /*
- config.plugins = [
- Observability(
- options: .init(
-// otlpEndpoint: "http://localhost:4318",
- sessionBackgroundTimeout: 3,
- isDebug: true,
- disableLogs: false,
- disableTraces: false,
- disableMetrics: false
- )
- )
- ]
- */
- return config
-}()
-
-let context = { () -> LDContext in
- var contextBuilder = LDContextBuilder(
- key: "12345"
- )
- contextBuilder.kind("user")
- do {
- return try contextBuilder.build().get()
- } catch {
- abort()
- }
-}()
-
final class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
+ ) -> Bool {
+ return true
+ }
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
- LDClient.start(
- config: config,
- context: context,
- startWaitSeconds: 5.0,
- completion: { (timedOut: Bool) -> Void in
- if timedOut {
- // Client may not have the most recent flags for the configured context
- } else {
- // Client has received flags for the configured context
- }
- }
- )
return true
}
}
diff --git a/ExampleApp/ExampleApp/Client.swift b/ExampleApp/ExampleApp/Client.swift
index 6f03db6..22499a4 100644
--- a/ExampleApp/ExampleApp/Client.swift
+++ b/ExampleApp/ExampleApp/Client.swift
@@ -36,7 +36,7 @@ struct Client {
}
}()
- func start() {
+ init() {
LDClient.start(
config: config,
context: context,
diff --git a/ExampleApp/ExampleApp/ExampleAppApp.swift b/ExampleApp/ExampleApp/ExampleAppApp.swift
index 656c67d..9b81e2a 100644
--- a/ExampleApp/ExampleApp/ExampleAppApp.swift
+++ b/ExampleApp/ExampleApp/ExampleAppApp.swift
@@ -25,9 +25,6 @@ struct ExampleAppApp: App {
}
}
.environmentObject(browser)
- .onAppear {
- client.start()
- }
}
}
}
diff --git a/ExampleApp/ExampleApp/Instrumentation/Manual/TraceView.swift b/ExampleApp/ExampleApp/Instrumentation/Manual/TraceView.swift
index 0788568..341f92e 100644
--- a/ExampleApp/ExampleApp/Instrumentation/Manual/TraceView.swift
+++ b/ExampleApp/ExampleApp/Instrumentation/Manual/TraceView.swift
@@ -42,7 +42,6 @@ struct TraceView: View {
.task(id: started) {
guard started else {
span?.end()
- _ = LDObserve.shared.flush()
return name = ""
}
span = LDObserve.shared.startSpan(name: name, attributes: [:])
diff --git a/Package.swift b/Package.swift
index 6f959c1..7f6d234 100644
--- a/Package.swift
+++ b/Package.swift
@@ -22,6 +22,11 @@ let package = Package(
.package(url: "https://github.com/mw99/DataCompression", from: "3.8.0")
],
targets: [
+ // C target (no Swift files here)
+ .target(
+ name: "ObjCBridge",
+ publicHeadersPath: "."
+ ),
.target(name: "Common",
dependencies: [
.product(name: "DataCompression", package: "DataCompression"),
@@ -30,6 +35,7 @@ let package = Package(
name: "Observability",
dependencies: [
"Common",
+ "ObjCBridge",
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift", condition: .when(platforms: [.iOS, .tvOS])),
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift", condition: .when(platforms: [.iOS, .tvOS])),
.product(name: "Installations", package: "KSCrash", condition: .when(platforms: [.iOS, .tvOS])),
diff --git a/Sources/Common/Store.swift b/Sources/Common/Store.swift
new file mode 100644
index 0000000..b058823
--- /dev/null
+++ b/Sources/Common/Store.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+public typealias ReducerFn = (inout S, A) -> Void
+public final class Store {
+ public var state: S
+ private let reducer: ReducerFn
+
+ public init(
+ state: S,
+ reducer: @escaping ReducerFn
+ ) {
+ self.state = state
+ self.reducer = reducer
+ }
+
+ public func dispatch(_ action: A) {
+ reducer(&state, action)
+ }
+}
diff --git a/Sources/ObjCBridge/StartupMetricsInit.m b/Sources/ObjCBridge/StartupMetricsInit.m
new file mode 100644
index 0000000..68e26b0
--- /dev/null
+++ b/Sources/ObjCBridge/StartupMetricsInit.m
@@ -0,0 +1,7 @@
+// StartupMetricsInit.c
+extern void SwiftStartupMetricsInitialize(void);
+
+__attribute__((constructor))
+static void StartupMetricsEarlyInit(void) {
+ SwiftStartupMetricsInitialize();
+}
diff --git a/Sources/Observability/Api/Options.swift b/Sources/Observability/Api/Options.swift
index 636f719..c9a2406 100644
--- a/Sources/Observability/Api/Options.swift
+++ b/Sources/Observability/Api/Options.swift
@@ -36,6 +36,7 @@ public struct Options {
case memory
case memoryWarnings
case cpu
+ case launchTimes
}
public var serviceName: String
public var serviceVersion: String
@@ -54,6 +55,7 @@ public struct Options {
public var log: OSLog
public var crashReporting: FeatureFlag
public var autoInstrumentation: Set
+ let launchMeter = LaunchMeter()
public init(
serviceName: String = "observability-swift",
diff --git a/Sources/Observability/AutoInstrumentation/Launch/AppStartTime.swift b/Sources/Observability/AutoInstrumentation/Launch/AppStartTime.swift
new file mode 100644
index 0000000..b33df75
--- /dev/null
+++ b/Sources/Observability/AutoInstrumentation/Launch/AppStartTime.swift
@@ -0,0 +1,31 @@
+//
+// AppStartTime.swift
+// swift-launchdarkly-observability
+//
+// Created by Mario Canto on 03/11/25.
+//
+
+
+import Foundation
+
+@objcMembers
+public final class AppStartTime: NSObject {
+ public struct AppStartStats {
+ public let startTime: TimeInterval
+ public let startDate: Date
+ }
+
+ /// Captures uptime when initialized.
+ public static var stats: AppStartStats = {
+ let t = ProcessInfo.processInfo.systemUptime
+ let d = Date()
+
+ return .init(startTime: t, startDate: d)
+ }()
+}
+
+// Expose a function Swift can call from C
+@_silgen_name("SwiftStartupMetricsInitialize")
+public func SwiftStartupMetricsInitialize() {
+ _ = AppStartTime.stats
+}
diff --git a/Sources/Observability/AutoInstrumentation/Launch/LaunchMeter.swift b/Sources/Observability/AutoInstrumentation/Launch/LaunchMeter.swift
new file mode 100644
index 0000000..2060f56
--- /dev/null
+++ b/Sources/Observability/AutoInstrumentation/Launch/LaunchMeter.swift
@@ -0,0 +1,195 @@
+#if canImport(UIKit)
+import Foundation
+import UIKit
+import Common
+
+enum LaunchType {
+ case cold, warm
+
+ var description: String {
+ switch self {
+ case .cold: return "cold"
+ case .warm: return "warm"
+ }
+ }
+}
+struct LaunchStats: Hashable {
+ let startTime: Date
+ let endTime: Date
+ let elapsedTime: Double
+ let launchType: LaunchType
+}
+
+typealias DidGetStatistics = ([LaunchStats]) -> Void
+public final class LaunchMeter {
+ struct State {
+ var launchStartUpDate: Date
+ var launchStartUptime: TimeInterval
+ var launchEndUpDate: Date?
+ var launchEndUptime: TimeInterval?
+ var lastBackgroundUptime: TimeInterval?
+
+ var lastWillEnterForegroundTime: TimeInterval?
+ var lastWillEnterForegroundDate: Date?
+ var isFirstLaunchInProcess = true
+ var hasRenderedFirstFrame = false
+ var launchType = LaunchType.cold
+ var lastLaunchDuration: TimeInterval = 0.0
+ var statistics = [LaunchStats]()
+ }
+ enum Action {
+ case didEnterBackground(currentUptime: TimeInterval, currentUpDate: Date)
+ case appDidBecomeActive(currentUptime: TimeInterval, currentUpDate: Date)
+ case willEnterForegroundNotification(currentUptime: TimeInterval, currentUpDate: Date)
+ case displayLinkDidFrameUpdate(currentUptime: TimeInterval, currentUpDate: Date)
+
+ case releaseBuffer
+ }
+ private let store: Store
+
+ private var displayLink: CADisplayLink?
+ private let processInfo: ProcessInfo
+
+ private var observers = [UUID: DidGetStatistics]()
+
+
+ public init() {
+ let processInfo = ProcessInfo.processInfo
+ self.store = .init(
+ state: .init(launchStartUpDate: AppStartTime.stats.startDate, launchStartUptime: AppStartTime.stats.startTime),
+ reducer: LaunchMeter.reduce(state:action:)
+ )
+ self.processInfo = processInfo
+ self.addObservers()
+ }
+
+ deinit {
+ displayLink?.invalidate()
+ displayLink = nil
+ observers.removeAll()
+ NotificationCenter.default.removeObserver(
+ self,
+ name: UIApplication.didBecomeActiveNotification,
+ object: nil
+ )
+ NotificationCenter.default.removeObserver(
+ self,
+ name: UIApplication.didEnterBackgroundNotification,
+ object: nil
+ )
+ NotificationCenter.default.removeObserver(
+ self,
+ name: UIApplication.willEnterForegroundNotification,
+ object: nil
+ )
+ }
+
+ @discardableResult
+ func subscribe(block: @escaping DidGetStatistics) -> UUID {
+ let id = UUID()
+ observers[id] = block
+ let statistics = store.state.statistics
+ block(statistics)
+ return id
+ }
+
+ func releaseBuffer() {
+ store.dispatch(.releaseBuffer)
+ }
+
+ private func addObservers() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(appDidBecomeActive),
+ name: UIApplication.didBecomeActiveNotification,
+ object: nil
+ )
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(didEnterBackground),
+ name: UIApplication.didEnterBackgroundNotification,
+ object: nil
+ )
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(willEnterForegroundNotification),
+ name: UIApplication.willEnterForegroundNotification,
+ object: nil
+ )
+ }
+
+ @objc private func willEnterForegroundNotification() {
+ store.dispatch(.willEnterForegroundNotification(currentUptime: processInfo.systemUptime, currentUpDate: Date()))
+ }
+
+ @objc private func didEnterBackground() {
+ displayLink?.invalidate()
+ displayLink = nil
+ store.dispatch(.didEnterBackground(currentUptime: processInfo.systemUptime, currentUpDate: Date()))
+ }
+
+ @objc private func appDidBecomeActive() {
+ let currentUptime = processInfo.systemUptime
+ store.dispatch(.appDidBecomeActive(currentUptime: currentUptime, currentUpDate: Date()))
+ self.displayLink = CADisplayLink(target: self, selector: #selector(frameUpdate))
+ self.displayLink?.add(to: .main, forMode: .common)
+ }
+
+ @objc private func frameUpdate() {
+ guard !store.state.hasRenderedFirstFrame else { return }
+ store.dispatch(.displayLinkDidFrameUpdate(currentUptime: processInfo.systemUptime, currentUpDate: Date()))
+ displayLink?.invalidate()
+ displayLink = nil
+
+ guard let statistics = store.state.statistics.last else { return }
+ observers.values.forEach { $0([statistics]) }
+ }
+
+ // MARK: - Update State
+ private static func reduce(state: inout State, action: Action) {
+ switch action {
+ case .displayLinkDidFrameUpdate(let currentUptime, let currentUpDate):
+ state.hasRenderedFirstFrame = true
+ state.launchEndUpDate = currentUpDate
+
+ guard !state.isFirstLaunchInProcess else {
+ state.lastLaunchDuration = currentUptime - state.launchStartUptime
+ let statistics = LaunchStats(
+ startTime: state.launchStartUpDate,
+ endTime: currentUpDate,
+ elapsedTime: state.lastLaunchDuration,
+ launchType: state.launchType
+ )
+ state.isFirstLaunchInProcess = false
+ return state.statistics.append(statistics)
+ }
+
+ state.lastLaunchDuration = currentUptime - (state.lastWillEnterForegroundTime ?? 0.0)
+ state.launchType = .warm
+
+ guard
+ let lastWillEnterForegroundDate = state.lastWillEnterForegroundDate,
+ let launchEndUpDate = state.launchEndUpDate
+ else { return }
+
+ let statistics = LaunchStats(
+ startTime: lastWillEnterForegroundDate,
+ endTime: launchEndUpDate,
+ elapsedTime: state.lastLaunchDuration,
+ launchType: state.launchType
+ )
+ state.statistics.append(statistics)
+
+ case .didEnterBackground(let currentUptime, _):
+ state.lastBackgroundUptime = currentUptime
+ case .willEnterForegroundNotification(let currentUptime, let currentUpDate):
+ state.lastWillEnterForegroundTime = currentUptime
+ state.lastWillEnterForegroundDate = currentUpDate
+ case .appDidBecomeActive(_, _):
+ state.hasRenderedFirstFrame = false
+ case .releaseBuffer:
+ state.statistics.removeAll()
+ }
+ }
+}
+#endif
diff --git a/Sources/Observability/Client/ObservabilityClientFactory.swift b/Sources/Observability/Client/ObservabilityClientFactory.swift
index c8187a3..86418ce 100644
--- a/Sources/Observability/Client/ObservabilityClientFactory.swift
+++ b/Sources/Observability/Client/ObservabilityClientFactory.swift
@@ -87,6 +87,18 @@ public struct ObservabilityClientFactory {
autoInstrumentation.append(NetworkInstrumentationManager(options: options, tracer: decorator, session: sessionManager))
}
tracer = decorator
+ if options.autoInstrumentation.contains(.launchTimes) {
+ options.launchMeter.subscribe { statistics in
+ for element in statistics {
+ let span = decorator.startSpan(
+ name: "AppStart",
+ attributes: ["start.type": .string(element.launchType.description)],
+ startTime: element.startTime
+ )
+ span.end(time: element.endTime)
+ }
+ }
+ }
} else {
tracer = NoOpTracer()
}
diff --git a/Sources/Observability/Traces/TracerDecorator.swift b/Sources/Observability/Traces/TracerDecorator.swift
index c63c388..e1c9fbc 100644
--- a/Sources/Observability/Traces/TracerDecorator.swift
+++ b/Sources/Observability/Traces/TracerDecorator.swift
@@ -93,3 +93,23 @@ extension TracerDecorator: TracesApi {
return true
}
}
+
+/// Internal method used to set span start date
+extension TracerDecorator {
+ func startSpan(name: String, attributes: [String : AttributeValue], startTime: Date = Date()) -> any Span {
+ let builder = tracer.spanBuilder(spanName: name)
+
+ if let parent = OpenTelemetry.instance.contextProvider.activeSpan {
+ builder.setParent(parent)
+ }
+
+ attributes.forEach {
+ builder.setAttribute(key: $0.key, value: $0.value)
+ }
+ builder.setStartTime(time: startTime)
+ let span = builder.startSpan()
+ span.setAttributes(attributes)
+
+ return span
+ }
+}