From 6934cf1367c2ae584322337c61bbe4ab2485d685 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 13 Apr 2026 17:31:17 -0600 Subject: [PATCH 1/7] feat: app scenes support - unit tests --- .../MultiwindowPad/AppDelegate.swift | 100 +++++++--- .../MultiwindowPad/CatSceneDelegate.swift | 84 ++++---- .../MultiwindowPad/Config/Client.swift | 11 +- .../MultiwindowPad/SceneDelegate.swift | 183 +++++++++++++++--- .../Launch/LaunchMeter.swift | 5 +- .../Launch/LaunchTracker.swift | 136 +++++++------ .../Launch/LaunchTrackerTests.swift | 167 ++++++++++++---- 7 files changed, 493 insertions(+), 193 deletions(-) diff --git a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift index 93f0c03b..6bb97353 100644 --- a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift +++ b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift @@ -1,36 +1,92 @@ -// -// AppDelegate.swift -// MultiwindowPad -// -// Created by Wals, Donny on 25/10/2019. -// Copyright © 2019 Wals, Donny. All rights reserved. -// - import UIKit +import LaunchDarklyObservability @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { let client = Client() - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + if let activity = options.userActivities.first, activity.activityType == "com.donnywals.viewCat" { + return UISceneConfiguration(name: "Cat Detail", sessionRole: connectingSceneSession.role) + } + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} +} - return true - } +// MARK: - Scene Launch Tracking (demo) - // MARK: UISceneSession Lifecycle +enum SceneLaunchType: String { + case cold = "Cold Launch" + case warm = "Warm Launch" + case sceneCreation = "Scene Created" - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - if let activity = options.userActivities.first, activity.activityType == "com.donnywals.viewCat" { - return UISceneConfiguration(name: "Cat Detail", sessionRole: connectingSceneSession.role) + var color: UIColor { + switch self { + case .cold: return .systemBlue + case .warm: return .systemGreen + case .sceneCreation: return .systemOrange + } } +} - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } +struct SceneLaunchEvent { + let sceneID: String + let type: SceneLaunchType + let durationMs: Double + let date: Date +} - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } +extension Notification.Name { + static let sceneLaunchEventRecorded = Notification.Name("SceneLaunchEventRecorded") } +/// Shared log that classifies scene lifecycle events into cold / warm / sceneCreation launches +/// and stores them for display. Uses the same logic as the SDK's internal LaunchTracker. +final class SceneLaunchEventLog { + static let shared = SceneLaunchEventLog() + private init() {} + + private(set) var events: [SceneLaunchEvent] = [] + private var seenSceneIDs: Set = [] + private var hasRecordedColdLaunch = false + + /// Call from `sceneWillEnterForeground` and `sceneDidBecomeActive` to record one launch event. + /// - Parameters: + /// - sceneID: The persistent session identifier of the scene. + /// - foregroundUptime: `ProcessInfo.processInfo.systemUptime` captured in `sceneWillEnterForeground`. + /// - activateUptime: `ProcessInfo.processInfo.systemUptime` captured in `sceneDidBecomeActive`. + func record(sceneID: String, foregroundUptime: TimeInterval, activateUptime: TimeInterval) { + let type: SceneLaunchType + let startUptime: TimeInterval + + if !seenSceneIDs.contains(sceneID) { + seenSceneIDs.insert(sceneID) + if !hasRecordedColdLaunch { + hasRecordedColdLaunch = true + type = .cold + // For cold launch measure from the earliest captured process-start uptime. + startUptime = AppStartTime.stats.startTime + } else { + type = .sceneCreation + startUptime = foregroundUptime + } + } else { + type = .warm + startUptime = foregroundUptime + } + + let durationMs = max(activateUptime - startUptime, 0) * 1000 + let shortID = String(sceneID.prefix(8)) + let event = SceneLaunchEvent(sceneID: shortID, type: type, durationMs: durationMs, date: Date()) + events.append(event) + NotificationCenter.default.post(name: .sceneLaunchEventRecorded, object: event) + } +} diff --git a/MultiSceneExampleApp/MultiwindowPad/CatSceneDelegate.swift b/MultiSceneExampleApp/MultiwindowPad/CatSceneDelegate.swift index 36b193fd..f71428a4 100644 --- a/MultiSceneExampleApp/MultiwindowPad/CatSceneDelegate.swift +++ b/MultiSceneExampleApp/MultiwindowPad/CatSceneDelegate.swift @@ -1,48 +1,58 @@ -// -// CatSceneDelegate.swift -// MultiwindowPad -// -// Created by Wals, Donny on 29/10/2019. -// Copyright © 2019 Wals, Donny. All rights reserved. -// - import UIKit class CatSceneDelegate: UIResponder, UISceneDelegate { - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - let detail: CatDetailViewController - if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity, - let identifier = activity.targetContentIdentifier { - detail = CatDetailViewController(catName: identifier) - } else { - detail = CatDetailViewController(catName: "default") + var window: UIWindow? + + private var foregroundUptime: TimeInterval? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + let detail: CatDetailViewController + if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity, + let identifier = activity.targetContentIdentifier { + detail = CatDetailViewController(catName: identifier) + } else { + detail = CatDetailViewController(catName: "default") + } + + if let windowScene = scene as? UIWindowScene { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = detail + window.backgroundColor = .white + self.window = window + window.makeKeyAndVisible() + } } - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = detail - window.backgroundColor = .white - self.window = window - window.makeKeyAndVisible() + func sceneWillEnterForeground(_ scene: UIScene) { + foregroundUptime = ProcessInfo.processInfo.systemUptime } - } - override func restoreUserActivityState(_ activity: NSUserActivity) { - super.restoreUserActivityState(activity) - print("WILL RESTORE") - } + func sceneDidBecomeActive(_ scene: UIScene) { + let activateUptime = ProcessInfo.processInfo.systemUptime + if let foregroundUptime { + SceneLaunchEventLog.shared.record( + sceneID: scene.session.persistentIdentifier, + foregroundUptime: foregroundUptime, + activateUptime: activateUptime + ) + self.foregroundUptime = nil + } + LaunchStatsOverlayView.install(in: window) + } - func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { - return scene.userActivity - } + override func restoreUserActivityState(_ activity: NSUserActivity) { + super.restoreUserActivityState(activity) + } - func sceneDidDisconnect(_ scene: UIScene) { - scene.session.stateRestorationActivity = scene.userActivity - } + func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { + return scene.userActivity + } - func sceneWillResignActive(_ scene: UIScene) { - scene.session.stateRestorationActivity = scene.userActivity - } + func sceneDidDisconnect(_ scene: UIScene) { + scene.session.stateRestorationActivity = scene.userActivity + } + + func sceneWillResignActive(_ scene: UIScene) { + scene.session.stateRestorationActivity = scene.userActivity + } } diff --git a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift index 9cfdb0e5..e82f9f55 100644 --- a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift +++ b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift @@ -16,16 +16,7 @@ struct Client { logsApiLevel: .info, tracesApi: .enabled, metricsApi: .enabled, - crashReporting: .disabled, - autoInstrumentation: [.urlSession, .userTaps, .memory, .cpu, .memoryWarnings], - instrumentation: .init( - urlSession: .enabled, - userTaps: .enabled, - memory: .enabled, - memoryWarnings: .enabled, - cpu: .disabled, - launchTimes: .enabled - ) + instrumentation: .init(launchTimes: .enabled) ) ) ] diff --git a/MultiSceneExampleApp/MultiwindowPad/SceneDelegate.swift b/MultiSceneExampleApp/MultiwindowPad/SceneDelegate.swift index 0c3294fc..56b7e8ac 100644 --- a/MultiSceneExampleApp/MultiwindowPad/SceneDelegate.swift +++ b/MultiSceneExampleApp/MultiwindowPad/SceneDelegate.swift @@ -1,29 +1,164 @@ -// -// SceneDelegate.swift -// MultiwindowPad -// -// Created by Wals, Donny on 25/10/2019. -// Copyright © 2019 Wals, Donny. All rights reserved. -// - import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = CatsOverviewViewController() - window.backgroundColor = .white - self.window = window - window.makeKeyAndVisible() - } - } + var window: UIWindow? + + private var foregroundUptime: TimeInterval? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + window.rootViewController = CatsOverviewViewController() + window.backgroundColor = .white + self.window = window + window.makeKeyAndVisible() + } + + func sceneWillEnterForeground(_ scene: UIScene) { + foregroundUptime = ProcessInfo.processInfo.systemUptime + } + + func sceneDidBecomeActive(_ scene: UIScene) { + let activateUptime = ProcessInfo.processInfo.systemUptime + if let foregroundUptime { + SceneLaunchEventLog.shared.record( + sceneID: scene.session.persistentIdentifier, + foregroundUptime: foregroundUptime, + activateUptime: activateUptime + ) + self.foregroundUptime = nil + } + LaunchStatsOverlayView.install(in: window) + } } +// MARK: - Launch Stats Overlay + +/// A floating panel added to each scene's window that shows all recorded launch events. +final class LaunchStatsOverlayView: UIView { + private let titleLabel = UILabel() + private let stackView = UIStackView() + private var observer: NSObjectProtocol? + + static func install(in window: UIWindow?) { + guard let window else { return } + // Remove any existing overlay before adding a fresh one. + window.subviews.compactMap { $0 as? LaunchStatsOverlayView }.forEach { $0.removeFromSuperview() } + let overlay = LaunchStatsOverlayView() + overlay.translatesAutoresizingMaskIntoConstraints = false + window.addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.leadingAnchor.constraint(equalTo: window.safeAreaLayoutGuide.leadingAnchor, constant: 12), + overlay.trailingAnchor.constraint(equalTo: window.safeAreaLayoutGuide.trailingAnchor, constant: -12), + overlay.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -12) + ]) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + backgroundColor = UIColor.systemBackground.withAlphaComponent(0.93) + layer.cornerRadius = 12 + layer.borderWidth = 0.5 + layer.borderColor = UIColor.separator.cgColor + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.12 + layer.shadowRadius = 6 + layer.shadowOffset = CGSize(width: 0, height: -2) + + titleLabel.text = "Launch Stats" + titleLabel.font = .systemFont(ofSize: 12, weight: .semibold) + titleLabel.textColor = .secondaryLabel + + stackView.axis = .vertical + stackView.spacing = 5 + stackView.addArrangedSubview(titleLabel) + addSubview(stackView) + + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 10), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10) + ]) + + refresh() + + observer = NotificationCenter.default.addObserver( + forName: .sceneLaunchEventRecorded, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refresh() + } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } + + private func refresh() { + stackView.arrangedSubviews.dropFirst().forEach { $0.removeFromSuperview() } + + let events = SceneLaunchEventLog.shared.events.suffix(5) + if events.isEmpty { + let label = UILabel() + label.text = "No events yet" + label.font = .systemFont(ofSize: 12) + label.textColor = .tertiaryLabel + stackView.addArrangedSubview(label) + return + } + + for event in events { + stackView.addArrangedSubview(makeRow(for: event)) + } + } + + private func makeRow(for event: SceneLaunchEvent) -> UIView { + let badge = makeBadge(title: event.type.rawValue, color: event.type.color) + + let durationLabel = UILabel() + durationLabel.text = String(format: "%.0f ms", event.durationMs) + durationLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + durationLabel.textColor = .label + + let sceneLabel = UILabel() + sceneLabel.text = "scene: \(event.sceneID)" + sceneLabel.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + sceneLabel.textColor = .secondaryLabel + sceneLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let row = UIStackView(arrangedSubviews: [badge, durationLabel, sceneLabel]) + row.axis = .horizontal + row.spacing = 8 + row.alignment = .center + return row + } + + private func makeBadge(title: String, color: UIColor) -> UIView { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + label.backgroundColor = color + label.layer.cornerRadius = 4 + label.clipsToBounds = true + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.widthAnchor.constraint(equalToConstant: 96), + label.heightAnchor.constraint(equalToConstant: 20) + ]) + return label + } +} diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchMeter.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchMeter.swift index e52b5964..3a831ef2 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchMeter.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchMeter.swift @@ -6,12 +6,13 @@ import UIKit #endif enum LaunchType { - case cold, warm - + case cold, warm, sceneCreation + var description: String { switch self { case .cold: return "cold" case .warm: return "warm" + case .sceneCreation: return "sceneCreation" } } } diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift index 1a001e24..445b2db9 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift @@ -10,102 +10,120 @@ final class LaunchTracker { let sceneID: String? let systemUptime: TimeInterval } + struct LaunchInfo: Hashable { let sceneID: String? let start: TimeInterval let end: TimeInterval let type: LaunchType } + + struct PendingLaunch { + let startTime: TimeInterval + let type: LaunchType + } + struct State { - var sceneStartTimes: [String: TimeInterval] - var hasRecordedColdLaunch = false + /// Scenes that have a start time recorded but haven't activated yet. + var pendingSceneStarts: [String: PendingLaunch] + /// Scene IDs we have seen at least once (used to distinguish first-time vs returning scenes). + var seenSceneIDs: Set + var hasRecordedColdLaunch: Bool var buffer: [LaunchInfo] - + init( - sceneStartTimes: [String : TimeInterval] = [:], + pendingSceneStarts: [String: PendingLaunch] = [:], + seenSceneIDs: Set = [], hasRecordedColdLaunch: Bool = false, buffer: [LaunchInfo] = [] ) { - self.sceneStartTimes = sceneStartTimes + self.pendingSceneStarts = pendingSceneStarts + self.seenSceneIDs = seenSceneIDs self.hasRecordedColdLaunch = hasRecordedColdLaunch self.buffer = buffer } } + enum Action: Equatable { - case sceneDidBecomeActive(SceneData) + /// Fires when a scene enters the foreground. For a brand-new scene this also determines + /// whether the launch is cold or sceneCreation; for a returning scene it is warm. case sceneWillEnterForeground(SceneData) + /// Fires when a scene becomes interactive — marks the end of the launch window. + case sceneDidBecomeActive(SceneData) + /// Called after buffered items have been sent to the tracing backend. case launchInfoItemsWereTraced([LaunchInfo]) } - + static func reduce(state: inout State, action: Action) { switch action { - case .sceneDidBecomeActive(let sceneData): - guard let id = sceneData.sceneID, let startUptime = state.sceneStartTimes[id] else { - return - } - let endUptime = sceneData.systemUptime - let launchType: LaunchType = state.hasRecordedColdLaunch ? .warm : .cold - // Mark cold launch recorded once - if !state.hasRecordedColdLaunch { - state.hasRecordedColdLaunch = true - } - let launchInfo = LaunchInfo(sceneID: id, start: startUptime, end: endUptime, type: launchType) - state.buffer.append(launchInfo) case .sceneWillEnterForeground(let sceneData): - guard let id = sceneData.sceneID else { - return + guard let id = sceneData.sceneID else { return } + + if !state.seenSceneIDs.contains(id) { + // First time we see this scene — cold or sceneCreation. + state.seenSceneIDs.insert(id) + if !state.hasRecordedColdLaunch { + state.hasRecordedColdLaunch = true + state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .cold) + } else { + state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .sceneCreation) + } + } else { + // Scene has been active before — warm launch. + // Guard against duplicate events while a measurement is already in progress. + guard state.pendingSceneStarts[id] == nil else { return } + state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .warm) } - state.sceneStartTimes[id] = sceneData.systemUptime + + case .sceneDidBecomeActive(let sceneData): + guard let id = sceneData.sceneID, + let pending = state.pendingSceneStarts[id] else { return } + let launchInfo = LaunchInfo( + sceneID: id, + start: pending.startTime, + end: sceneData.systemUptime, + type: pending.type + ) + state.buffer.append(launchInfo) + state.pendingSceneStarts.removeValue(forKey: id) + case .launchInfoItemsWereTraced(let items): state.buffer.removeAll(where: { items.contains($0) }) } - } - + private var cancellables = Set() private let store: Store - + var state: State { store.state } - + init(initialState: State = .init()) { let store = Store(state: initialState, reducer: LaunchTracker.reduce(state:action:)) - self.store = store - self.subscribeToSceneNotifications(usingStore: store) } } extension LaunchTracker { - func trace( - using tracingApi: TraceClient - ) async { + func trace(using tracingApi: TraceClient) async { await MainActor.run { [weak self] in guard let self else { return } let bufferedItems = store.state.buffer + let currentUptime = ProcessInfo.processInfo.systemUptime + let now = Date() bufferedItems.forEach { item in tracingApi .startSpan( name: "AppStart", attributes: [ - "start.type": .string( - item.type.description - ), - "duration": .double( - item.end - item.start - ) + "start.type": .string(item.type.description), + "duration": .double(item.end - item.start) ], - startTime: Date( - timeIntervalSinceNow: -item.start - ) - ) - .end( - time: Date( - timeIntervalSinceNow: -item.end - ) + startTime: now.addingTimeInterval(item.start - currentUptime) ) + .end(time: now.addingTimeInterval(item.end - currentUptime)) } store.dispatch(.launchInfoItemsWereTraced(bufferedItems)) } @@ -114,33 +132,35 @@ extension LaunchTracker { extension LaunchTracker { func subscribeToSceneNotifications(usingStore store: Store) { - NotificationCenter.default.publisher(for: UIScene.didActivateNotification) + NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification) .subscribe(on: RunLoop.main) .receive(on: RunLoop.main) .sink { notification in guard let scene = notification.object as? UIScene else { return } - let sceneData = SceneData( - sceneID: scene.session.persistentIdentifier, - systemUptime: ProcessInfo.processInfo.systemUptime - ) - store.dispatch(.sceneDidBecomeActive(sceneData)) + let id = scene.session.persistentIdentifier + // Substitute the process-start uptime for cold launches so the measured + // duration covers the full time from when the process was created. + let isFirstTime = !store.state.seenSceneIDs.contains(id) + let isColdLaunch = isFirstTime && !store.state.hasRecordedColdLaunch + let systemUptime = isColdLaunch + ? AppStartTime.stats.startTime + : ProcessInfo.processInfo.systemUptime + let sceneData = SceneData(sceneID: id, systemUptime: systemUptime) + store.dispatch(.sceneWillEnterForeground(sceneData)) } .store(in: &cancellables) - - NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification) + + NotificationCenter.default.publisher(for: UIScene.didActivateNotification) .subscribe(on: RunLoop.main) .receive(on: RunLoop.main) .sink { notification in guard let scene = notification.object as? UIScene else { return } - - let systemUptime = store.state.hasRecordedColdLaunch ? ProcessInfo.processInfo.systemUptime : AppStartTime.stats.startTime let sceneData = SceneData( sceneID: scene.session.persistentIdentifier, - systemUptime: systemUptime + systemUptime: ProcessInfo.processInfo.systemUptime ) - store.dispatch(.sceneWillEnterForeground(sceneData)) + store.dispatch(.sceneDidBecomeActive(sceneData)) } .store(in: &cancellables) } } - diff --git a/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift b/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift index cc7a2422..b7d35a6f 100644 --- a/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift +++ b/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift @@ -7,112 +7,199 @@ import Testing @Suite struct LaunchTrackerReducerTests { + // MARK: - Cold launch + @Test func testColdLaunch() throws { var state = LaunchTracker.State() - // GIVEN: app just started (cold) let startUptime: TimeInterval = 1.0 let endUptime: TimeInterval = 2.0 let sceneID = "ABC" - // WHEN: scene enters foreground + // First willEnterForeground on a fresh state → cold LaunchTracker.reduce( state: &state, - action: .sceneWillEnterForeground( - .init(sceneID: sceneID, systemUptime: startUptime) - ) + action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: startUptime)) ) - // THEN: start time should be recorded from AppStartTime.stats.startTime - #expect(state.sceneStartTimes[sceneID] != nil) + #expect(state.pendingSceneStarts[sceneID]?.type == .cold) + #expect(state.pendingSceneStarts[sceneID]?.startTime == startUptime) + #expect(state.hasRecordedColdLaunch) + #expect(state.seenSceneIDs.contains(sceneID)) - // WHEN: scene becomes active LaunchTracker.reduce( state: &state, - action: .sceneDidBecomeActive( - .init(sceneID: sceneID, systemUptime: endUptime) - ) + action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: endUptime)) ) - // THEN: launch info should be added #expect(state.buffer.count == 1) let launch = try #require(state.buffer.first) #expect(launch.type == .cold) - #expect(launch.start == state.sceneStartTimes[sceneID]) + #expect(launch.start == startUptime) #expect(launch.end == endUptime) + + // Pending entry must be cleared after activation + #expect(state.pendingSceneStarts[sceneID] == nil) } + // MARK: - Warm launch @Test func testWarmLaunch() throws { - var state = LaunchTracker.State(hasRecordedColdLaunch: true) + // Scene has been seen before → next willEnterForeground is a warm launch + var state = LaunchTracker.State( + seenSceneIDs: ["XYZ"], + hasRecordedColdLaunch: true + ) let startUptime: TimeInterval = 5.0 let endUptime: TimeInterval = 6.0 let sceneID = "XYZ" - // Record start LaunchTracker.reduce( state: &state, - action: .sceneWillEnterForeground( - .init(sceneID: sceneID, systemUptime: startUptime) - ) + action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: startUptime)) ) - // Record end + #expect(state.pendingSceneStarts[sceneID]?.type == .warm) + LaunchTracker.reduce( state: &state, - action: .sceneDidBecomeActive( - .init(sceneID: sceneID, systemUptime: endUptime) - ) + action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: endUptime)) ) #expect(state.buffer.count == 1) let launch = try #require(state.buffer.first) #expect(launch.type == .warm) - #expect(launch.start == state.sceneStartTimes[sceneID]) + #expect(launch.start == startUptime) #expect(launch.end == endUptime) } + // MARK: - Scene creation @Test - func testSceneStartTimesStoredCorrectly() { - var state = LaunchTracker.State() - let sceneID = "123" + func testSceneCreationLaunch() throws { + // App already running (cold launch recorded), a brand-new scene appears + var state = LaunchTracker.State( + seenSceneIDs: ["EXISTING"], + hasRecordedColdLaunch: true + ) + + let startUptime: TimeInterval = 10.0 + let endUptime: TimeInterval = 11.5 + let newSceneID = "NEW" LaunchTracker.reduce( state: &state, - action: .sceneWillEnterForeground( - .init(sceneID: sceneID, systemUptime: 10.0) - ) + action: .sceneWillEnterForeground(.init(sceneID: newSceneID, systemUptime: startUptime)) ) - #expect(state.sceneStartTimes.keys.contains(sceneID)) + #expect(state.pendingSceneStarts[newSceneID]?.type == .sceneCreation) + #expect(state.seenSceneIDs.contains(newSceneID)) + + LaunchTracker.reduce( + state: &state, + action: .sceneDidBecomeActive(.init(sceneID: newSceneID, systemUptime: endUptime)) + ) + + #expect(state.buffer.count == 1) + + let launch = try #require(state.buffer.first) + #expect(launch.type == .sceneCreation) + #expect(launch.start == startUptime) + #expect(launch.end == endUptime) } + // MARK: - Multiple concurrent scenes @Test - func testLaunchInfoRemovedByTrace() throws { + func testMultipleConcurrentScenes() throws { var state = LaunchTracker.State() - let item = LaunchTracker.LaunchInfo( - sceneID: "A", - start: 1.0, - end: 2.0, - type: .cold + // First scene — cold + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: "S1", systemUptime: 1.0))) + // Second scene appears before the first activates — sceneCreation + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: "S2", systemUptime: 1.2))) + + #expect(state.pendingSceneStarts["S1"]?.type == .cold) + #expect(state.pendingSceneStarts["S2"]?.type == .sceneCreation) + + // Both activate + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: "S1", systemUptime: 2.0))) + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: "S2", systemUptime: 2.5))) + + #expect(state.buffer.count == 2) + let types = Set(state.buffer.map(\.type)) + #expect(types.contains(.cold)) + #expect(types.contains(.sceneCreation)) + } + + // MARK: - Duplicate willEnterForeground is a no-op while pending + + @Test + func testDuplicateWillEnterForegroundIgnoredWhilePending() { + var state = LaunchTracker.State( + seenSceneIDs: ["ABC"], + hasRecordedColdLaunch: true ) + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: "ABC", systemUptime: 5.0))) + let firstStartTime = state.pendingSceneStarts["ABC"]?.startTime + + // Fire again before didBecomeActive — must not override the start time + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: "ABC", systemUptime: 9.0))) + + #expect(state.pendingSceneStarts["ABC"]?.startTime == firstStartTime) + } + + // MARK: - didBecomeActive without preceding willEnterForeground is a no-op + + @Test + func testDidBecomeActiveWithoutPendingStartIsIgnored() { + var state = LaunchTracker.State() + + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: "ABC", systemUptime: 5.0))) + + #expect(state.buffer.isEmpty) + } + + // MARK: - Traced items are removed from buffer + + @Test + func testLaunchInfoRemovedByTrace() throws { + var state = LaunchTracker.State() + + let item = LaunchTracker.LaunchInfo(sceneID: "A", start: 1.0, end: 2.0, type: .cold) state.buffer = [item] - LaunchTracker.reduce( - state: &state, - action: .launchInfoItemsWereTraced([item]) - ) + LaunchTracker.reduce(state: &state, action: .launchInfoItemsWereTraced([item])) #expect(state.buffer.isEmpty) } + + // MARK: - Full cold → warm lifecycle for the same scene + + @Test + func testColdThenWarmForSameScene() throws { + var state = LaunchTracker.State() + let sceneID = "SCENE" + + // Cold launch + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: 1.0))) + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: 2.0))) + + #expect(state.buffer.first?.type == .cold) + + // Scene goes background → foreground again + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: 100.0))) + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: 100.3))) + + #expect(state.buffer.count == 2) + #expect(state.buffer.last?.type == .warm) + #expect(state.buffer.last?.start == 100.0) + } } #endif From 649730aa71464129e565db000c7622524fa849cb Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Tue, 21 Apr 2026 12:34:41 -0600 Subject: [PATCH 2/7] chore: update multi-scene example app with latests changes from sdk --- .../xcschemes/MultiwindowPad.xcscheme | 2 +- .../MultiwindowPad/AppDelegate.swift | 1 + .../MultiwindowPad/Config/Client.swift | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme index 07ec1d5f..638d2a49 100644 --- a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme +++ b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme @@ -58,7 +58,7 @@ diff --git a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift index 6bb97353..6d8c6bbb 100644 --- a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift +++ b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift @@ -6,6 +6,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let client = Client() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + LDObserve.shared.start() return true } diff --git a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift index e82f9f55..03604ac1 100644 --- a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift +++ b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift @@ -1,5 +1,6 @@ import UIKit import LaunchDarklyObservability +import LaunchDarklySessionReplay struct Client { let config = { () -> LDConfig in @@ -18,6 +19,22 @@ struct Client { metricsApi: .enabled, instrumentation: .init(launchTimes: .enabled) ) + ), + SessionReplay( + options: .init( + isEnabled: true, + privacy: .init( + maskTextInputs: true, + maskWebViews: false, + maskImages: false, + maskAccessibilityIdentifiers: [ + "email-field", + "password-field", + "card-brand-chip", + "10" + ], + ) + ) ) ] return config From cbcd8dfd72ef9f207475507795515564b1b667cf Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Tue, 21 Apr 2026 17:11:29 -0600 Subject: [PATCH 3/7] fix: improve o(n*m) to o(n) --- .../AutoInstrumentation/Launch/LaunchTracker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift index 445b2db9..a556880e 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift @@ -88,7 +88,7 @@ final class LaunchTracker { state.pendingSceneStarts.removeValue(forKey: id) case .launchInfoItemsWereTraced(let items): - state.buffer.removeAll(where: { items.contains($0) }) + state.buffer = Array(Set(state.buffer).subtracting(items)) } } From adb57974517bfea7eadc53d8b90ff5c21a470014 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Tue, 21 Apr 2026 17:14:33 -0600 Subject: [PATCH 4/7] refactor: optimize buffer item removal using Set for improved performance --- .../AutoInstrumentation/Launch/LaunchTracker.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift index a556880e..5a126bf0 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift @@ -88,7 +88,8 @@ final class LaunchTracker { state.pendingSceneStarts.removeValue(forKey: id) case .launchInfoItemsWereTraced(let items): - state.buffer = Array(Set(state.buffer).subtracting(items)) + let traced = Set(items) + state.buffer.removeAll { traced.contains($0) } } } From 6d71f7aa9a7eb10328627d8013a8e750c32ee851 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Tue, 21 Apr 2026 17:24:43 -0600 Subject: [PATCH 5/7] refactor: update cold launch handling to use process start time for improved accuracy --- .../Launch/LaunchTracker.swift | 19 +++++++++---------- .../Launch/LaunchTrackerTests.swift | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift index 5a126bf0..7d740f35 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift @@ -64,7 +64,11 @@ final class LaunchTracker { state.seenSceneIDs.insert(id) if !state.hasRecordedColdLaunch { state.hasRecordedColdLaunch = true - state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .cold) + // Process-start uptime so duration spans from process creation, not foreground notification time. + state.pendingSceneStarts[id] = PendingLaunch( + startTime: AppStartTime.stats.startTime, + type: .cold + ) } else { state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .sceneCreation) } @@ -138,15 +142,10 @@ extension LaunchTracker { .receive(on: RunLoop.main) .sink { notification in guard let scene = notification.object as? UIScene else { return } - let id = scene.session.persistentIdentifier - // Substitute the process-start uptime for cold launches so the measured - // duration covers the full time from when the process was created. - let isFirstTime = !store.state.seenSceneIDs.contains(id) - let isColdLaunch = isFirstTime && !store.state.hasRecordedColdLaunch - let systemUptime = isColdLaunch - ? AppStartTime.stats.startTime - : ProcessInfo.processInfo.systemUptime - let sceneData = SceneData(sceneID: id, systemUptime: systemUptime) + let sceneData = SceneData( + sceneID: scene.session.persistentIdentifier, + systemUptime: ProcessInfo.processInfo.systemUptime + ) store.dispatch(.sceneWillEnterForeground(sceneData)) } .store(in: &cancellables) diff --git a/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift b/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift index b7d35a6f..e8551836 100644 --- a/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift +++ b/Tests/ObservabilityTests/AutoInstrumentation/Launch/LaunchTrackerTests.swift @@ -13,18 +13,19 @@ struct LaunchTrackerReducerTests { func testColdLaunch() throws { var state = LaunchTracker.State() - let startUptime: TimeInterval = 1.0 - let endUptime: TimeInterval = 2.0 + let processStart = AppStartTime.stats.startTime + let foregroundUptime = processStart + 0.5 + let endUptime = processStart + 1.5 let sceneID = "ABC" - // First willEnterForeground on a fresh state → cold + // First willEnterForeground on a fresh state → cold (start time comes from AppStartTime, not sceneData) LaunchTracker.reduce( state: &state, - action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: startUptime)) + action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: foregroundUptime)) ) #expect(state.pendingSceneStarts[sceneID]?.type == .cold) - #expect(state.pendingSceneStarts[sceneID]?.startTime == startUptime) + #expect(state.pendingSceneStarts[sceneID]?.startTime == processStart) #expect(state.hasRecordedColdLaunch) #expect(state.seenSceneIDs.contains(sceneID)) @@ -37,7 +38,7 @@ struct LaunchTrackerReducerTests { let launch = try #require(state.buffer.first) #expect(launch.type == .cold) - #expect(launch.start == startUptime) + #expect(launch.start == processStart) #expect(launch.end == endUptime) // Pending entry must be cleared after activation @@ -186,12 +187,14 @@ struct LaunchTrackerReducerTests { func testColdThenWarmForSameScene() throws { var state = LaunchTracker.State() let sceneID = "SCENE" + let processStart = AppStartTime.stats.startTime // Cold launch - LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: 1.0))) - LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: 2.0))) + LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: processStart + 0.1))) + LaunchTracker.reduce(state: &state, action: .sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: processStart + 1.0))) #expect(state.buffer.first?.type == .cold) + #expect(state.buffer.first?.start == processStart) // Scene goes background → foreground again LaunchTracker.reduce(state: &state, action: .sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: 100.0))) From 809617b0cd77fc4f5acc94e037f7c701f7887835 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 26 May 2026 19:49:12 -0700 Subject: [PATCH 6/7] Using Secrets.config --- ExampleApp/ExampleApp.xcodeproj/project.pbxproj | 10 ++++++++++ .../xcshareddata/xcschemes/ExampleApp.xcscheme | 12 ------------ ExampleApp/ExampleApp/Client.swift | 12 ++++++++++-- ExampleApp/ExampleApp/Env.swift | 11 ----------- .../MultiwindowPad.xcodeproj/project.pbxproj | 8 ++++---- .../xcshareddata/xcschemes/MultiwindowPad.xcscheme | 12 ------------ .../MultiwindowPad/Config/Client.swift | 12 ++++++++++-- MultiSceneExampleApp/MultiwindowPad/Config/Env.swift | 11 ----------- MultiSceneExampleApp/MultiwindowPad/Info.plist | 6 ++++++ TestApp/Sources/AppDelegate.swift | 2 +- TestApp/TestApp.xcodeproj/project.pbxproj | 2 +- {TestApp => TestAppShared}/Secrets.xcconfig.example | 0 12 files changed, 42 insertions(+), 56 deletions(-) delete mode 100644 ExampleApp/ExampleApp/Env.swift delete mode 100644 MultiSceneExampleApp/MultiwindowPad/Config/Env.swift rename {TestApp => TestAppShared}/Secrets.xcconfig.example (100%) diff --git a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj index 3e4ed12e..776bc3d7 100644 --- a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj +++ b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ../TestAppShared/Secrets.xcconfig; sourceTree = ""; }; E7904AC42E6A523D00A15337 /* ExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -39,6 +40,7 @@ E7904ABB2E6A523D00A15337 = { isa = PBXGroup; children = ( + AA00000000000000000001 /* Secrets.xcconfig */, E7904AC62E6A523D00A15337 /* ExampleApp */, E7E464C62F3A9DFF0080DEF9 /* Frameworks */, E7904AC52E6A523D00A15337 /* Products */, @@ -266,6 +268,7 @@ }; E7904AD02E6A523E00A15337 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA00000000000000000001 /* Secrets.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -274,6 +277,9 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_backendUrl = "$(backendUrl)"; + INFOPLIST_KEY_mobileKey = "$(mobileKey)"; + INFOPLIST_KEY_otlpEndpoint = "$(otlpEndpoint)"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -296,6 +302,7 @@ }; E7904AD12E6A523E00A15337 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA00000000000000000001 /* Secrets.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -304,6 +311,9 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_backendUrl = "$(backendUrl)"; + INFOPLIST_KEY_mobileKey = "$(mobileKey)"; + INFOPLIST_KEY_otlpEndpoint = "$(otlpEndpoint)"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme b/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme index 2cec90d6..a8679b70 100644 --- a/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme +++ b/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme @@ -50,18 +50,6 @@ ReferencedContainer = "container:ExampleApp.xcodeproj"> - - - - - - LDConfig in + guard let secrets = Bundle.main.infoDictionary, + let mobileKey = secrets["mobileKey"] as? String, !mobileKey.isEmpty else { + fatalError("Missing mobileKey in Info.plist. See TestAppShared/Secrets.xcconfig.example.") + } + let otlpEndpoint = secrets["otlpEndpoint"] as? String + let backendUrl = secrets["backendUrl"] as? String + var config = LDConfig( - mobileKey: Env.mobileKey, + mobileKey: mobileKey, autoEnvAttributes: .enabled ) config.plugins = [ Observability( options: .init( isEnabled: false, - otlpEndpoint: Env.otelHost, + otlpEndpoint: otlpEndpoint, + backendUrl: backendUrl, sessionBackgroundTimeout: 3, isDebug: true, logsApiLevel: .info, diff --git a/ExampleApp/ExampleApp/Env.swift b/ExampleApp/ExampleApp/Env.swift deleted file mode 100644 index 31235169..00000000 --- a/ExampleApp/ExampleApp/Env.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -struct Env { - static var mobileKey: String { - ProcessInfo.processInfo.environment["MOBILE_KEY"] ?? "" - } - - static var otelHost: String { - ProcessInfo.processInfo.environment["OPTL_ENDPOINT"] ?? "" - } -} diff --git a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/project.pbxproj b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/project.pbxproj index 9f79eb0f..d8acc5e9 100644 --- a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/project.pbxproj +++ b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 65E170A423632ABD00F5FDA1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65E170A223632ABD00F5FDA1 /* LaunchScreen.storyboard */; }; E7B1A2C32EC7E01300B157F3 /* LaunchDarklyObservability in Frameworks */ = {isa = PBXBuildFile; productRef = E7B1A2C22EC7E01300B157F3 /* LaunchDarklyObservability */; }; E7B1A2C52EC7E01300B157F3 /* LaunchDarklySessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = E7B1A2C42EC7E01300B157F3 /* LaunchDarklySessionReplay */; }; - E7B1A2C92ECCB68500B157F3 /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B1A2C82ECCB68500B157F3 /* Env.swift */; }; E7B1A2CA2ECCB68500B157F3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B1A2C72ECCB68500B157F3 /* Client.swift */; }; E7B1A2CD2ECD2DD700B157F3 /* LaunchDarklyObservability in Frameworks */ = {isa = PBXBuildFile; productRef = E7B1A2CC2ECD2DD700B157F3 /* LaunchDarklyObservability */; }; E7B1A2CF2ECD2DD700B157F3 /* LaunchDarklySessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = E7B1A2CE2ECD2DD700B157F3 /* LaunchDarklySessionReplay */; }; @@ -32,8 +31,8 @@ 65E1709D23632ABD00F5FDA1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65E170A323632ABD00F5FDA1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 65E170A523632ABD00F5FDA1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ../TestAppShared/Secrets.xcconfig; sourceTree = ""; }; E7B1A2C72ECCB68500B157F3 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; - E7B1A2C82ECCB68500B157F3 /* Env.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -54,6 +53,7 @@ 65E1708B23632ABC00F5FDA1 = { isa = PBXGroup; children = ( + AA00000000000000000001 /* Secrets.xcconfig */, 65E1709623632ABC00F5FDA1 /* MultiwindowPad */, 65E1709523632ABC00F5FDA1 /* Products */, ); @@ -87,7 +87,6 @@ isa = PBXGroup; children = ( E7B1A2C72ECCB68500B157F3 /* Client.swift */, - E7B1A2C82ECCB68500B157F3 /* Env.swift */, ); path = Config; sourceTree = ""; @@ -168,7 +167,6 @@ 65E1709823632ABC00F5FDA1 /* AppDelegate.swift in Sources */, 6564E23E2368955400E9AF7C /* CatsOverviewViewController.swift in Sources */, 6564E2422368978300E9AF7C /* CatDetailViewController.swift in Sources */, - E7B1A2C92ECCB68500B157F3 /* Env.swift in Sources */, E7B1A2CA2ECCB68500B157F3 /* Client.swift in Sources */, 6564E2402368975400E9AF7C /* CatSceneDelegate.swift in Sources */, 65E1709A23632ABC00F5FDA1 /* SceneDelegate.swift in Sources */, @@ -305,6 +303,7 @@ }; 65E170A923632ABD00F5FDA1 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA00000000000000000001 /* Secrets.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; @@ -325,6 +324,7 @@ }; 65E170AA23632ABD00F5FDA1 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA00000000000000000001 /* Secrets.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; diff --git a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme index 638d2a49..a0272cda 100644 --- a/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme +++ b/MultiSceneExampleApp/MultiwindowPad.xcodeproj/xcshareddata/xcschemes/MultiwindowPad.xcscheme @@ -50,18 +50,6 @@ ReferencedContainer = "container:MultiwindowPad.xcodeproj"> - - - - - - LDConfig in + guard let secrets = Bundle.main.infoDictionary, + let mobileKey = secrets["mobileKey"] as? String, !mobileKey.isEmpty else { + fatalError("Missing mobileKey in Info.plist. See TestAppShared/Secrets.xcconfig.example.") + } + let otlpEndpoint = secrets["otlpEndpoint"] as? String + let backendUrl = secrets["backendUrl"] as? String + var config = LDConfig( - mobileKey: Env.mobileKey, + mobileKey: mobileKey, autoEnvAttributes: .enabled ) config.plugins = [ Observability( options: .init( - otlpEndpoint: Env.otelHost, + otlpEndpoint: otlpEndpoint, + backendUrl: backendUrl, sessionBackgroundTimeout: 3, isDebug: true, logsApiLevel: .info, diff --git a/MultiSceneExampleApp/MultiwindowPad/Config/Env.swift b/MultiSceneExampleApp/MultiwindowPad/Config/Env.swift deleted file mode 100644 index 31235169..00000000 --- a/MultiSceneExampleApp/MultiwindowPad/Config/Env.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -struct Env { - static var mobileKey: String { - ProcessInfo.processInfo.environment["MOBILE_KEY"] ?? "" - } - - static var otelHost: String { - ProcessInfo.processInfo.environment["OPTL_ENDPOINT"] ?? "" - } -} diff --git a/MultiSceneExampleApp/MultiwindowPad/Info.plist b/MultiSceneExampleApp/MultiwindowPad/Info.plist index 849ce53a..59304cf4 100644 --- a/MultiSceneExampleApp/MultiwindowPad/Info.plist +++ b/MultiSceneExampleApp/MultiwindowPad/Info.plist @@ -2,6 +2,12 @@ + mobileKey + $(mobileKey) + otlpEndpoint + $(otlpEndpoint) + backendUrl + $(backendUrl) NSUserActivityTypes com.donnywals.viewCat diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index fc555f39..231b0c8e 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -11,7 +11,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { ) -> Bool { guard let secrets = Bundle.main.infoDictionary, let mobileKey = secrets["mobileKey"] as? String, !mobileKey.isEmpty else { - fatalError("Missing mobileKey in Info.plist. See Secrets.xcconfig.example.") + fatalError("Missing mobileKey in Info.plist. See TestAppShared/Secrets.xcconfig.example.") } let otlpEndpoint = secrets["otlpEndpoint"] as? String let backendUrl = secrets["backendUrl"] as? String diff --git a/TestApp/TestApp.xcodeproj/project.pbxproj b/TestApp/TestApp.xcodeproj/project.pbxproj index 29906826..a18142f4 100644 --- a/TestApp/TestApp.xcodeproj/project.pbxproj +++ b/TestApp/TestApp.xcodeproj/project.pbxproj @@ -14,7 +14,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ../TestAppShared/Secrets.xcconfig; sourceTree = ""; }; E7904AC42E6A523D00A15337 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ diff --git a/TestApp/Secrets.xcconfig.example b/TestAppShared/Secrets.xcconfig.example similarity index 100% rename from TestApp/Secrets.xcconfig.example rename to TestAppShared/Secrets.xcconfig.example From a566891c60601c851538469edebb0d5d984349f8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 27 May 2026 17:40:15 -0700 Subject: [PATCH 7/7] classifier --- .../MultiwindowPad/AppDelegate.swift | 42 +++--- .../Launch/LaunchTracker.swift | 68 ++++----- .../Launch/SceneLaunchClassifier.swift | 129 ++++++++++++++++++ 3 files changed, 178 insertions(+), 61 deletions(-) create mode 100644 Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/SceneLaunchClassifier.swift diff --git a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift index 6d8c6bbb..b01a7a76 100644 --- a/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift +++ b/MultiSceneExampleApp/MultiwindowPad/AppDelegate.swift @@ -29,6 +29,14 @@ enum SceneLaunchType: String { case warm = "Warm Launch" case sceneCreation = "Scene Created" + init(_ classification: SceneLaunchClassification) { + switch classification { + case .cold: self = .cold + case .warm: self = .warm + case .sceneCreation: self = .sceneCreation + } + } + var color: UIColor { switch self { case .cold: return .systemBlue @@ -50,14 +58,13 @@ extension Notification.Name { } /// Shared log that classifies scene lifecycle events into cold / warm / sceneCreation launches -/// and stores them for display. Uses the same logic as the SDK's internal LaunchTracker. +/// and stores them for display. final class SceneLaunchEventLog { static let shared = SceneLaunchEventLog() private init() {} private(set) var events: [SceneLaunchEvent] = [] - private var seenSceneIDs: Set = [] - private var hasRecordedColdLaunch = false + private var classifier = SceneLaunchClassifier() /// Call from `sceneWillEnterForeground` and `sceneDidBecomeActive` to record one launch event. /// - Parameters: @@ -65,28 +72,19 @@ final class SceneLaunchEventLog { /// - foregroundUptime: `ProcessInfo.processInfo.systemUptime` captured in `sceneWillEnterForeground`. /// - activateUptime: `ProcessInfo.processInfo.systemUptime` captured in `sceneDidBecomeActive`. func record(sceneID: String, foregroundUptime: TimeInterval, activateUptime: TimeInterval) { - let type: SceneLaunchType - let startUptime: TimeInterval - - if !seenSceneIDs.contains(sceneID) { - seenSceneIDs.insert(sceneID) - if !hasRecordedColdLaunch { - hasRecordedColdLaunch = true - type = .cold - // For cold launch measure from the earliest captured process-start uptime. - startUptime = AppStartTime.stats.startTime - } else { - type = .sceneCreation - startUptime = foregroundUptime - } - } else { - type = .warm - startUptime = foregroundUptime + classifier.sceneWillEnterForeground(.init(sceneID: sceneID, systemUptime: foregroundUptime)) + guard let launchInfo = classifier.sceneDidBecomeActive(.init(sceneID: sceneID, systemUptime: activateUptime)) else { + return } - let durationMs = max(activateUptime - startUptime, 0) * 1000 + let durationMs = max(launchInfo.end - launchInfo.start, 0) * 1000 let shortID = String(sceneID.prefix(8)) - let event = SceneLaunchEvent(sceneID: shortID, type: type, durationMs: durationMs, date: Date()) + let event = SceneLaunchEvent( + sceneID: shortID, + type: SceneLaunchType(launchInfo.type), + durationMs: durationMs, + date: Date() + ) events.append(event) NotificationCenter.default.post(name: .sceneLaunchEventRecorded, object: event) } diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift index 7d740f35..adb5c953 100644 --- a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/LaunchTracker.swift @@ -6,22 +6,16 @@ import UIKit #endif final class LaunchTracker { - struct SceneData: Equatable { - let sceneID: String? - let systemUptime: TimeInterval - } + typealias SceneData = SceneLaunchClassifier.SceneData struct LaunchInfo: Hashable { let sceneID: String? let start: TimeInterval let end: TimeInterval - let type: LaunchType + let type: SceneLaunchClassification } - struct PendingLaunch { - let startTime: TimeInterval - let type: LaunchType - } + typealias PendingLaunch = SceneLaunchClassifier.PendingLaunch struct State { /// Scenes that have a start time recorded but haven't activated yet. @@ -57,39 +51,35 @@ final class LaunchTracker { static func reduce(state: inout State, action: Action) { switch action { case .sceneWillEnterForeground(let sceneData): - guard let id = sceneData.sceneID else { return } - - if !state.seenSceneIDs.contains(id) { - // First time we see this scene — cold or sceneCreation. - state.seenSceneIDs.insert(id) - if !state.hasRecordedColdLaunch { - state.hasRecordedColdLaunch = true - // Process-start uptime so duration spans from process creation, not foreground notification time. - state.pendingSceneStarts[id] = PendingLaunch( - startTime: AppStartTime.stats.startTime, - type: .cold - ) - } else { - state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .sceneCreation) - } - } else { - // Scene has been active before — warm launch. - // Guard against duplicate events while a measurement is already in progress. - guard state.pendingSceneStarts[id] == nil else { return } - state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .warm) - } + var classifierState = SceneLaunchClassifier.State( + pendingSceneStarts: state.pendingSceneStarts, + seenSceneIDs: state.seenSceneIDs, + hasRecordedColdLaunch: state.hasRecordedColdLaunch + ) + _ = SceneLaunchClassifier.reduce(state: &classifierState, action: .sceneWillEnterForeground(sceneData)) + state.pendingSceneStarts = classifierState.pendingSceneStarts + state.seenSceneIDs = classifierState.seenSceneIDs + state.hasRecordedColdLaunch = classifierState.hasRecordedColdLaunch case .sceneDidBecomeActive(let sceneData): - guard let id = sceneData.sceneID, - let pending = state.pendingSceneStarts[id] else { return } - let launchInfo = LaunchInfo( - sceneID: id, - start: pending.startTime, - end: sceneData.systemUptime, - type: pending.type + var classifierState = SceneLaunchClassifier.State( + pendingSceneStarts: state.pendingSceneStarts, + seenSceneIDs: state.seenSceneIDs, + hasRecordedColdLaunch: state.hasRecordedColdLaunch + ) + guard let launchInfo = SceneLaunchClassifier.reduce( + state: &classifierState, + action: .sceneDidBecomeActive(sceneData) + ) else { return } + state.pendingSceneStarts = classifierState.pendingSceneStarts + state.buffer.append( + LaunchInfo( + sceneID: launchInfo.sceneID, + start: launchInfo.start, + end: launchInfo.end, + type: launchInfo.type + ) ) - state.buffer.append(launchInfo) - state.pendingSceneStarts.removeValue(forKey: id) case .launchInfoItemsWereTraced(let items): let traced = Set(items) diff --git a/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/SceneLaunchClassifier.swift b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/SceneLaunchClassifier.swift new file mode 100644 index 00000000..167bbe32 --- /dev/null +++ b/Sources/LaunchDarklyObservability/AutoInstrumentation/Launch/SceneLaunchClassifier.swift @@ -0,0 +1,129 @@ +#if canImport(UIKit) +import Foundation + +public enum SceneLaunchClassification: Hashable { + case cold + case warm + case sceneCreation + + var description: String { + switch self { + case .cold: return "cold" + case .warm: return "warm" + case .sceneCreation: return "sceneCreation" + } + } + +} + +public struct SceneLaunchClassifier { + public struct SceneData: Equatable { + public let sceneID: String? + public let systemUptime: TimeInterval + + public init(sceneID: String?, systemUptime: TimeInterval) { + self.sceneID = sceneID + self.systemUptime = systemUptime + } + } + + public struct LaunchInfo: Hashable { + public let sceneID: String + public let start: TimeInterval + public let end: TimeInterval + public let type: SceneLaunchClassification + } + + struct PendingLaunch { + let startTime: TimeInterval + let type: SceneLaunchClassification + } + + struct State { + /// Scenes that have a start time recorded but haven't activated yet. + var pendingSceneStarts: [String: PendingLaunch] + /// Scene IDs we have seen at least once (used to distinguish first-time vs returning scenes). + var seenSceneIDs: Set + var hasRecordedColdLaunch: Bool + + init( + pendingSceneStarts: [String: PendingLaunch] = [:], + seenSceneIDs: Set = [], + hasRecordedColdLaunch: Bool = false + ) { + self.pendingSceneStarts = pendingSceneStarts + self.seenSceneIDs = seenSceneIDs + self.hasRecordedColdLaunch = hasRecordedColdLaunch + } + } + + enum Action: Equatable { + /// Fires when a scene enters the foreground. For a brand-new scene this also determines + /// whether the launch is cold or sceneCreation; for a returning scene it is warm. + case sceneWillEnterForeground(SceneData) + /// Fires when a scene becomes interactive — marks the end of the launch window. + case sceneDidBecomeActive(SceneData) + } + + var state: State + private let processStartUptime: TimeInterval + + public init(processStartUptime: TimeInterval = AppStartTime.stats.startTime) { + self.state = State() + self.processStartUptime = processStartUptime + } + + public mutating func sceneWillEnterForeground(_ sceneData: SceneData) { + Self.reduce(state: &state, action: .sceneWillEnterForeground(sceneData), processStartUptime: processStartUptime) + } + + public mutating func sceneDidBecomeActive(_ sceneData: SceneData) -> LaunchInfo? { + Self.reduce(state: &state, action: .sceneDidBecomeActive(sceneData), processStartUptime: processStartUptime) + } + + static func reduce( + state: inout State, + action: Action, + processStartUptime: TimeInterval = AppStartTime.stats.startTime + ) -> LaunchInfo? { + switch action { + case .sceneWillEnterForeground(let sceneData): + guard let id = sceneData.sceneID else { return nil } + + if !state.seenSceneIDs.contains(id) { + // First time we see this scene — cold or sceneCreation. + state.seenSceneIDs.insert(id) + if !state.hasRecordedColdLaunch { + state.hasRecordedColdLaunch = true + // Process-start uptime so duration spans from process creation, not foreground notification time. + state.pendingSceneStarts[id] = PendingLaunch( + startTime: processStartUptime, + type: .cold + ) + } else { + state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .sceneCreation) + } + } else { + // Scene has been active before — warm launch. + // Guard against duplicate events while a measurement is already in progress. + guard state.pendingSceneStarts[id] == nil else { return nil } + state.pendingSceneStarts[id] = PendingLaunch(startTime: sceneData.systemUptime, type: .warm) + } + + return nil + + case .sceneDidBecomeActive(let sceneData): + guard let id = sceneData.sceneID, + let pending = state.pendingSceneStarts[id] else { return nil } + let launchInfo = LaunchInfo( + sceneID: id, + start: pending.startTime, + end: sceneData.systemUptime, + type: pending.type + ) + state.pendingSceneStarts.removeValue(forKey: id) + return launchInfo + } + } +} +#endif