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 + } +}