From bbc1bb2da769e6bff1d00fbb7d23d6a8917700bf Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Sun, 26 Apr 2026 19:21:21 -0600 Subject: [PATCH 1/9] feat: app-lifecycle [ios] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the four standard lifecycle events (Application Installed/Updated/Opened/ Backgrounded), gated behind InitOptions.trackLifecycleEvents (default true). Events flow through the standard track() path so they pick up enrichment, identity, and dispatcher batching like any other event. Cold launch compares persisted (version, build) — stored under metarouter:lifecycle:* in UserDefaults, separate from identity keys so reset() cannot wipe install/update state — to the current bundle to decide whether to emit Installed, Updated, or neither, then emits Opened with from_background:false when the process is in foreground at emit time. Resume: only background → active emits Application Opened{from_background:true}; inactive → active transitions (Control Center, FaceID prompt, system alert) are suppressed. Background: Application Backgrounded is enqueued at the front of onBackgroundAsync so it lands in the same flushToDisk() drain. Adds AnalyticsInterface.handleDeepLink(url:sourceApplication:) — host calls it from didFinishLaunchingWithOptions / SceneDelegate; buffered values attach to the next Application Opened (one-shot). Bumps SDK version to 1.5.0. --- Sources/MetaRouter/Version.swift | 2 +- .../analytics/AnalyticsClient.swift | 70 ++++ .../analytics/AnalyticsInterface.swift | 8 + .../MetaRouter/analytics/AnalyticsProxy.swift | 6 + .../MetaRouter/analytics/InitOptions.swift | 11 +- .../MetaRouter/identity/IdentityStorage.swift | 11 + .../lifecycle/LifecycleEventEmitter.swift | 218 +++++++++++ .../MetaRouter/utils/LifecycleStorage.swift | 50 +++ .../AppLifecycleEventIntegrationTests.swift | 256 +++++++++++++ .../MetaRouterTests/Helpers/TestHelpers.swift | 5 + Tests/MetaRouterTests/InitOptionsTests.swift | 32 ++ .../LifecycleEventEmitterTests.swift | 345 ++++++++++++++++++ .../LifecycleStorageTests.swift | 92 +++++ 13 files changed, 1102 insertions(+), 4 deletions(-) create mode 100644 Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift create mode 100644 Sources/MetaRouter/utils/LifecycleStorage.swift create mode 100644 Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift create mode 100644 Tests/MetaRouterTests/LifecycleEventEmitterTests.swift create mode 100644 Tests/MetaRouterTests/LifecycleStorageTests.swift diff --git a/Sources/MetaRouter/Version.swift b/Sources/MetaRouter/Version.swift index 18f16db..e6eeaeb 100644 --- a/Sources/MetaRouter/Version.swift +++ b/Sources/MetaRouter/Version.swift @@ -1,5 +1,5 @@ public enum MetaRouterSDK { // IMPORTANT: Update this value before each release. // This version is included in the `library.version` context field of all events. - public static let version = "1.4.0" + public static let version = "1.5.0" } diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index e250fca..1f8b728 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(UIKit) +import UIKit +#endif + /// Injectable dependencies for testing. All fields optional — defaults to production implementations. internal struct AnalyticsDependencies: Sendable { @@ -12,6 +16,14 @@ internal struct AnalyticsDependencies: Sendable { var dispatcher: Dispatcher? var persistentQueue: PersistentEventQueue? var networkMonitor: NetworkReachability? + var lifecycleEmitter: LifecycleEventEmitter? + var lifecycleStorage: LifecycleStorage? + var identityStorage: IdentityStorage? + var appVersionInfo: AppVersionInfo? + /// Override the initial app foreground state read at cold launch. + /// When nil, production code reads `UIApplication.shared.applicationState` + /// from the main actor; tests can pass `.active` / `.background` directly. + var initialAppState: AppForegroundState? static let production = AnalyticsDependencies() } @@ -29,6 +41,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl private var lifecycleState: LifecycleState = .idle private var disabled = false private var initTask: Task? + private let lifecycleEmitter: LifecycleEventEmitter? + private let initialAppStateOverride: AppForegroundState? private init(options: InitOptions, deps: AnalyticsDependencies = .production) { self.lifecycleState = .initializing @@ -60,6 +74,22 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl config: deps.dispatcherConfig ?? Dispatcher.Config(endpointPath: "/v1/batch", timeoutMs: 8000, autoFlushThreshold: 20, initialMaxBatchSize: 100) ) + // Build the lifecycle emitter only when the feature is enabled. Construction + // happens BEFORE identityManager.initialize() runs (in initTask), so the + // emitter's snapshot of "did identity exist before this launch?" is honest. + if options.trackLifecycleEvents { + self.lifecycleEmitter = deps.lifecycleEmitter ?? LifecycleEventEmitter( + enrichmentService: self.enrichmentService, + dispatcher: self.dispatcher, + storage: deps.lifecycleStorage ?? LifecycleStorage(), + identityStorage: deps.identityStorage ?? IdentityStorage(), + versionInfo: deps.appVersionInfo ?? .fromBundle() + ) + } else { + self.lifecycleEmitter = nil + } + self.initialAppStateOverride = deps.initialAppState + let rawMonitor = deps.networkMonitor ?? NetworkMonitor() let monitor = DebouncedNetworkMonitor(inner: rawMonitor) @@ -91,10 +121,14 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl guard let self else { return } await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) await self.dispatcher.flush() + await self.lifecycleEmitter?.emitForegroundFromBackground() } }, onBackgroundAsync: { [weak self] in guard let self else { return } + // Emit Application Backgrounded BEFORE flush/disk-flush so the event + // is captured by the same drain that ships pending events to disk. + await self.lifecycleEmitter?.emitBackgrounded() await self.dispatcher.flush() await self.dispatcher.flushToDisk() await self.dispatcher.stopFlushLoop() @@ -146,6 +180,13 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl writeKey: self.options.writeKey, host: self.options.ingestionHost.absoluteString) + // Emit cold-launch lifecycle sequence (Installed/Updated then Opened). + // Runs after .ready so events flow through the standard track path. + if let emitter = self.lifecycleEmitter { + let initialState = await self.readInitialAppState() + await emitter.emitColdLaunchSequence(initialAppState: initialState) + } + // Drain any persisted events from a previous session if monitor.currentStatus == .connected { await self.dispatcher.drainDiskStoreToNetwork() @@ -153,6 +194,25 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } + /// Reads the current process foreground state. On iOS, hops to `MainActor` + /// because `UIApplication.shared.applicationState` is main-actor isolated. + /// Tests can short-circuit by setting `AnalyticsDependencies.initialAppState`. + private func readInitialAppState() async -> AppForegroundState { + if let override = initialAppStateOverride { return override } + #if canImport(UIKit) + return await MainActor.run { + switch UIApplication.shared.applicationState { + case .active: return AppForegroundState.active + case .inactive: return .inactive + case .background: return .background + @unknown default: return .active + } + } + #else + return .active + #endif + } + internal static func initialize(options: InitOptions, deps: AnalyticsDependencies = .production) -> AnalyticsClient { AnalyticsClient(options: options, deps: deps) } @@ -477,4 +537,14 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl await self.dispatcher.setTracing(enabled) } } + + public func handleDeepLink(url: URL, sourceApplication: String?) { + guard let emitter = lifecycleEmitter else { return } + Task { + await emitter.handleDeepLink( + url: url.absoluteString, + sourceApplication: sourceApplication + ) + } + } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 164ae71..4c0c0a7 100644 --- a/Sources/MetaRouter/analytics/AnalyticsInterface.swift +++ b/Sources/MetaRouter/analytics/AnalyticsInterface.swift @@ -25,4 +25,12 @@ public protocol AnalyticsInterface: AnyObject, Sendable { func setAdvertisingId(_ advertisingId: String?) func clearAdvertisingId() func setTracing(_ enabled: Bool) + + /// Buffers a deep-link URL to be attached to the next `Application Opened` + /// event as the `url` (and optionally `referring_application`) property. + /// Call from `application(_:open:options:)` or `scene(_:openURLContexts:)`, + /// and from `application(_:didFinishLaunchingWithOptions:)` for cold-launch + /// deep-link capture using `launchOptions[.url]` / `[.sourceApplication]`. + /// Buffered values are one-shot — cleared after the next emit. + func handleDeepLink(url: URL, sourceApplication: String?) } diff --git a/Sources/MetaRouter/analytics/AnalyticsProxy.swift b/Sources/MetaRouter/analytics/AnalyticsProxy.swift index 9af2e54..fb889de 100644 --- a/Sources/MetaRouter/analytics/AnalyticsProxy.swift +++ b/Sources/MetaRouter/analytics/AnalyticsProxy.swift @@ -105,6 +105,10 @@ internal final class AnalyticsProxy: AnalyticsInterface, CustomStringConvertible public func setTracing(_ enabled: Bool) { Task { await state.enqueue(.setTracing(enabled)) } } + + public func handleDeepLink(url: URL, sourceApplication: String?) { + Task { await state.enqueue(.handleDeepLink(url, sourceApplication)) } + } } extension AnalyticsProxy { @@ -128,6 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) + case handleDeepLink(URL, String?) } private actor ProxyState { @@ -210,6 +215,7 @@ private actor ProxyState { case .setAdvertisingId(let advertisingId): r.setAdvertisingId(advertisingId) case .clearAdvertisingId: r.clearAdvertisingId() case .setTracing(let enabled): r.setTracing(enabled) + case .handleDeepLink(let url, let source): r.handleDeepLink(url: url, sourceApplication: source) } } } diff --git a/Sources/MetaRouter/analytics/InitOptions.swift b/Sources/MetaRouter/analytics/InitOptions.swift index f2a3cb4..473d8cc 100644 --- a/Sources/MetaRouter/analytics/InitOptions.swift +++ b/Sources/MetaRouter/analytics/InitOptions.swift @@ -7,6 +7,7 @@ public struct InitOptions: Sendable { public let debug: Bool public let maxQueueEvents: Int public let maxDiskEvents: Int + public let trackLifecycleEvents: Bool public init( writeKey: String, @@ -14,7 +15,8 @@ public struct InitOptions: Sendable { flushIntervalSeconds: Int = 10, debug: Bool = false, maxQueueEvents: Int = 2000, - maxDiskEvents: Int = 10000 + maxDiskEvents: Int = 10000, + trackLifecycleEvents: Bool = true ) { precondition(!writeKey.isEmpty, "writeKey must not be empty") @@ -30,6 +32,7 @@ public struct InitOptions: Sendable { self.debug = debug self.maxQueueEvents = max(1, maxQueueEvents) self.maxDiskEvents = maxDiskEvents + self.trackLifecycleEvents = trackLifecycleEvents if self.maxDiskEvents > 0 && self.maxDiskEvents < self.maxQueueEvents { Logger.warn("maxDiskEvents (\(self.maxDiskEvents)) is less than maxQueueEvents (\(self.maxQueueEvents)) — memory can hold more events than disk can preserve; events may be dropped during background flush") @@ -44,7 +47,8 @@ extension InitOptions { flushIntervalSeconds: Int = 10, debug: Bool = false, maxQueueEvents: Int = 2000, - maxDiskEvents: Int = 10000 + maxDiskEvents: Int = 10000, + trackLifecycleEvents: Bool = true ) { var host = ingestionHost.trimmingCharacters(in: .whitespacesAndNewlines) if host.hasSuffix("/") { @@ -59,7 +63,8 @@ extension InitOptions { flushIntervalSeconds: flushIntervalSeconds, debug: debug, maxQueueEvents: maxQueueEvents, - maxDiskEvents: maxDiskEvents + maxDiskEvents: maxDiskEvents, + trackLifecycleEvents: trackLifecycleEvents ) } } diff --git a/Sources/MetaRouter/identity/IdentityStorage.swift b/Sources/MetaRouter/identity/IdentityStorage.swift index 4cf33f6..499bd83 100644 --- a/Sources/MetaRouter/identity/IdentityStorage.swift +++ b/Sources/MetaRouter/identity/IdentityStorage.swift @@ -38,5 +38,16 @@ public struct IdentityStorage: @unchecked Sendable { remove(.groupId) remove(.advertisingId) } + + /// Returns `true` if any identity field is currently persisted. + /// Used by `LifecycleEventEmitter` to differentiate a true fresh install + /// (no identity, no lifecycle storage) from an existing user upgrading from + /// a pre-lifecycle SDK build (identity present, no lifecycle storage). + public func hasAnyValue() -> Bool { + return get(.anonymousId) != nil + || get(.userId) != nil + || get(.groupId) != nil + || get(.advertisingId) != nil + } } diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift new file mode 100644 index 0000000..d0cbc20 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Process-level foreground state used to gate cold-launch and resume emits. +public enum AppForegroundState: Sendable, Equatable { + case active + case inactive + case background +} + +/// Snapshot of the app's `(version, build)` taken from the bundle at SDK init. +/// `version` reads `CFBundleShortVersionString`, `build` reads `CFBundleVersion`, +/// matching `DeviceContextProvider.collectAppContext()`. +public struct AppVersionInfo: Sendable, Equatable { + public let version: String + public let build: String + + public init(version: String, build: String) { + self.version = version + self.build = build + } + + public static func fromBundle(_ bundle: Bundle = .main) -> AppVersionInfo { + let info = bundle.infoDictionary ?? [:] + let version = info["CFBundleShortVersionString"] as? String ?? "unknown" + let build = info["CFBundleVersion"] as? String ?? "unknown" + return AppVersionInfo(version: version, build: build) + } +} + +internal enum LifecycleEventNames { + static let applicationInstalled = "Application Installed" + static let applicationUpdated = "Application Updated" + static let applicationOpened = "Application Opened" + static let applicationBackgrounded = "Application Backgrounded" +} + +internal enum LifecycleEventProperties { + static let version = "version" + static let build = "build" + static let previousVersion = "previous_version" + static let previousBuild = "previous_build" + static let fromBackground = "from_background" + static let url = "url" + static let referringApplication = "referring_application" +} + +/// Owns install/update detection, cold-launch vs. resume disambiguation, and +/// the choreography for emitting the four `Application *` lifecycle events. +/// +/// The emitter is an `actor` so internal flags (`coldLaunchEmitted`, +/// `coldLaunchSuppressed`, `lastTrackedAppState`, `pendingDeepLink`) are +/// serialised against any concurrent foreground/background notification. +internal actor LifecycleEventEmitter { + private let enrichmentService: EventEnrichmentService + private let dispatcher: Dispatcher + private let storage: LifecycleStorage + private let versionInfo: AppVersionInfo + private let hadIdentityBeforeInit: Bool + + private var coldLaunchEmitted: Bool = false + private var coldLaunchSuppressed: Bool = false + private var lastTrackedAppState: AppForegroundState = .active + private var pendingDeepLink: PendingDeepLink? + + private struct PendingDeepLink { + let url: String + let source: String? + } + + init( + enrichmentService: EventEnrichmentService, + dispatcher: Dispatcher, + storage: LifecycleStorage = LifecycleStorage(), + identityStorage: IdentityStorage = IdentityStorage(), + versionInfo: AppVersionInfo = .fromBundle() + ) { + self.enrichmentService = enrichmentService + self.dispatcher = dispatcher + self.storage = storage + self.versionInfo = versionInfo + // Snapshot before IdentityManager.initialize() auto-creates an anonymousId, + // so we can tell a true fresh install (no identity) from an existing user + // upgrading to a lifecycle-aware SDK build (identity already present). + self.hadIdentityBeforeInit = identityStorage.hasAnyValue() + } + + /// Runs once after `AnalyticsClient.lifecycleState = .ready`. + /// + /// 1. Compares the persisted `(version, build)` against the current bundle values. + /// 2. Emits `Application Installed` (true fresh install) or `Application Updated` + /// (build/version drift, or first lifecycle launch with prior identity). + /// 3. Persists current `(version, build)`. + /// 4. Emits `Application Opened` with `from_background: false` IF the process is + /// in the foreground at emit time. Background-launched processes (silent push, + /// background fetch) suppress the cold-launch Opened; the next + /// `background → active` transition emits instead. + func emitColdLaunchSequence(initialAppState: AppForegroundState) async { + let prevVersion = storage.getVersion() + let prevBuild = storage.getBuild() + let curr = versionInfo + + if prevVersion == nil && prevBuild == nil { + if hadIdentityBeforeInit { + // Existing user upgrading from a pre-lifecycle SDK build — + // avoid spurious install spike for the upgraded population. + await emit( + LifecycleEventNames.applicationUpdated, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + LifecycleEventProperties.previousVersion: .string("unknown"), + LifecycleEventProperties.previousBuild: .string("unknown"), + ] + ) + } else { + await emit( + LifecycleEventNames.applicationInstalled, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + ] + ) + } + } else if prevVersion != curr.version || prevBuild != curr.build { + await emit( + LifecycleEventNames.applicationUpdated, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + LifecycleEventProperties.previousVersion: .string(prevVersion ?? "unknown"), + LifecycleEventProperties.previousBuild: .string(prevBuild ?? "unknown"), + ] + ) + } + + storage.setVersionBuild(version: curr.version, build: curr.build) + lastTrackedAppState = initialAppState + + if initialAppState == .active { + await emitOpened(fromBackground: false) + coldLaunchEmitted = true + } else { + // Process was woken in background — defer to the next true foreground entry. + coldLaunchSuppressed = true + } + } + + /// Called from `AppLifecycleObserver.onForeground` (didBecomeActive). + /// No-ops in three cases: + /// 1. `coldLaunchSuppressed` was set because cold-launch ran in non-active state — + /// emit `Application Opened {from_background: false}` here as the cold-launch bridge. + /// 2. `coldLaunchEmitted == false` — first didBecomeActive on launch; the cold-launch + /// path is the sole producer of the first Opened. Suppress. + /// 3. `lastTrackedAppState != .background` — `inactive → active` transition + /// (Control Center, FaceID prompt, system alert). Only `background → active` emits. + func emitForegroundFromBackground() async { + if coldLaunchSuppressed { + await emitOpened(fromBackground: false) + coldLaunchSuppressed = false + coldLaunchEmitted = true + lastTrackedAppState = .active + return + } + + guard coldLaunchEmitted else { + // First didBecomeActive during init — cold-launch path will (or did) emit. + lastTrackedAppState = .active + return + } + + let wasBackground = lastTrackedAppState == .background + lastTrackedAppState = .active + guard wasBackground else { return } + + await emitOpened(fromBackground: true) + } + + /// Called from `AppLifecycleObserver.onBackgroundAsync` BEFORE the dispatcher + /// flushes to disk so the event is captured by `flushToDisk()`. + func emitBackgrounded() async { + lastTrackedAppState = .background + let event = await enrichmentService.createTrackEvent( + event: LifecycleEventNames.applicationBackgrounded, + properties: nil + ) + await dispatcher.offer(event) + } + + /// Buffers a deep-link URL (and optional source application) to be attached + /// to the next `Application Opened` event. One-shot — cleared on emit. + func handleDeepLink(url: String, sourceApplication: String?) { + pendingDeepLink = PendingDeepLink(url: url, source: sourceApplication) + } + + private func emitOpened(fromBackground: Bool) async { + var properties: [String: CodableValue] = [ + LifecycleEventProperties.fromBackground: .bool(fromBackground), + LifecycleEventProperties.version: .string(versionInfo.version), + LifecycleEventProperties.build: .string(versionInfo.build), + ] + if let buf = pendingDeepLink { + properties[LifecycleEventProperties.url] = .string(buf.url) + if let source = buf.source { + properties[LifecycleEventProperties.referringApplication] = .string(source) + } + pendingDeepLink = nil + } + await emit(LifecycleEventNames.applicationOpened, properties: properties) + } + + private func emit(_ name: String, properties: [String: CodableValue]?) async { + let event = await enrichmentService.createTrackEvent( + event: name, + properties: properties + ) + await dispatcher.offer(event) + } +} diff --git a/Sources/MetaRouter/utils/LifecycleStorage.swift b/Sources/MetaRouter/utils/LifecycleStorage.swift new file mode 100644 index 0000000..91927ed --- /dev/null +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Keys for persisting the last-seen application version/build in UserDefaults. +/// These keys live in a namespace separate from `IdentityStorageKey` so they +/// are unaffected by `IdentityStorage.clear()` (and therefore by `reset()`). +public enum LifecycleStorageKey: String { + case version = "metarouter:lifecycle:version" + case build = "metarouter:lifecycle:build" +} + +/// Persists the last application version/build the SDK observed on cold launch. +/// Used by `LifecycleEventEmitter` to decide whether to emit `Application Installed` +/// or `Application Updated`. +/// +/// Storage is kept intentionally separate from `IdentityStorage` so a user's +/// `reset()` call cannot wipe install/update state. +public struct LifecycleStorage: @unchecked Sendable { + private let userDefaults: UserDefaults + + public init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + public func getVersion() -> String? { + return userDefaults.string(forKey: LifecycleStorageKey.version.rawValue) + } + + public func getBuild() -> String? { + return userDefaults.string(forKey: LifecycleStorageKey.build.rawValue) + } + + public func setVersion(_ value: String) { + userDefaults.set(value, forKey: LifecycleStorageKey.version.rawValue) + } + + public func setBuild(_ value: String) { + userDefaults.set(value, forKey: LifecycleStorageKey.build.rawValue) + } + + public func setVersionBuild(version: String, build: String) { + setVersion(version) + setBuild(build) + } + + /// Removes the persisted version and build. Intended for tests. + public func clear() { + userDefaults.removeObject(forKey: LifecycleStorageKey.version.rawValue) + userDefaults.removeObject(forKey: LifecycleStorageKey.build.rawValue) + } +} diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift new file mode 100644 index 0000000..3dcbcae --- /dev/null +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -0,0 +1,256 @@ +import XCTest +@testable import MetaRouter + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// End-to-end coverage that exercises a real `AnalyticsClient` wired with +/// dependency injection. Posts `NotificationCenter` notifications to drive the +/// lifecycle observer, and asserts events flow through the standard track path. +/// +/// Events may end up in either the dispatcher's memory queue (no flush yet) or +/// in the network stub (already flushed). Test assertions consult both. +final class AppLifecycleEventIntegrationTests: XCTestCase { + + private static var foregroundNotificationName: Notification.Name { + #if canImport(UIKit) + return UIApplication.didBecomeActiveNotification + #elseif canImport(AppKit) + return NSApplication.didBecomeActiveNotification + #else + return Notification.Name("metarouter.test.foreground") + #endif + } + + private static var backgroundNotificationName: Notification.Name { + #if canImport(UIKit) + return UIApplication.didEnterBackgroundNotification + #elseif canImport(AppKit) + return NSApplication.didResignActiveNotification + #else + return Notification.Name("metarouter.test.background") + #endif + } + + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "com.metarouter.test.lifecycleIntegration.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testColdLaunchEmitsInstalledThenOpenedThroughClient() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertGreaterThanOrEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Installed") + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0") + XCTAssertEqual(bundle.lifecycleStorage.getBuild(), "42") + } + + func testFlagDisabledEmitsNoLifecycleEvents() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: false) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertTrue(events.isEmpty, + "trackLifecycleEvents=false must produce zero lifecycle events on init") + + // Sanity: regular track() still works + bundle.client.track("user_event") + try? await Task.sleep(nanoseconds: 100_000_000) + let after = await bundle.collectEvents() + XCTAssertEqual(after.first?.event, "user_event") + } + + /// `reset()` must NOT clear lifecycle storage — install/update state survives + /// because lifecycle keys live in a separate UserDefaults namespace. + func testResetPreservesLifecycleStorage() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0") + + bundle.client.reset() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0", + "reset() must not clear lifecycle storage") + XCTAssertEqual(bundle.lifecycleStorage.getBuild(), "42", + "reset() must not clear lifecycle storage") + } + + /// Re-init with persisted same version must NOT emit Installed/Updated — + /// only Opened. + func testReinitWithSameVersionEmitsOnlyOpened() async { + // Pre-seed lifecycle storage to simulate a previous successful run + LifecycleStorage(userDefaults: defaults) + .setVersionBuild(version: "1.5.0", build: "42") + + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + func testBackgroundNotificationEnqueuesApplicationBackgrounded() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + await bundle.consumeAll() // drop cold-launch events + + await MainActor.run { + NotificationCenter.default.post( + name: Self.backgroundNotificationName, + object: nil + ) + } + try? await Task.sleep(nanoseconds: 500_000_000) + + let events = await bundle.collectEvents() + XCTAssertTrue(events.contains(where: { $0.event == "Application Backgrounded" }), + "expected Application Backgrounded after background notification, got \(events.map { $0.event ?? "?" })") + } + + func testForegroundAfterBackgroundEmitsOpenedFromBackground() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + await bundle.consumeAll() + + await MainActor.run { + NotificationCenter.default.post(name: Self.backgroundNotificationName, object: nil) + } + try? await Task.sleep(nanoseconds: 400_000_000) + + await MainActor.run { + NotificationCenter.default.post(name: Self.foregroundNotificationName, object: nil) + } + try? await Task.sleep(nanoseconds: 400_000_000) + + let events = await bundle.collectEvents() + let opened = events.first(where: { $0.event == "Application Opened" }) + XCTAssertNotNil(opened, "expected Application Opened after foreground, got \(events.map { $0.event ?? "?" })") + XCTAssertEqual(opened?.properties?["from_background"], .bool(true)) + } +} + +// MARK: - test scaffolding + +/// Bundles all dependencies needed to drive an `AnalyticsClient` with a +/// drainable queue + a recording network sink that captures flushed batches. +private final class Setup { + let client: AnalyticsClient + let queue: PersistentEventQueue + let lifecycleStorage: LifecycleStorage + let recorder: RecordingNetworking + + init(defaults: UserDefaults, trackLifecycleEvents: Bool) { + let options = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")!, + flushIntervalSeconds: 999, // effectively never auto-flush during tests + trackLifecycleEvents: trackLifecycleEvents + ) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("metarouter-integration-\(UUID().uuidString)") + let diskStore = DiskStorage(baseDirectory: tempDir) + self.queue = PersistentEventQueue(diskStore: diskStore, maxEventCount: 1000) + self.lifecycleStorage = LifecycleStorage(userDefaults: defaults) + self.recorder = RecordingNetworking() + + let identityStorage = IdentityStorage(userDefaults: defaults) + let identityManager = IdentityManager( + storage: identityStorage, + writeKey: options.writeKey, + host: options.ingestionHost.absoluteString + ) + + var deps = AnalyticsDependencies() + deps.persistentQueue = self.queue + deps.networking = self.recorder + // Connected so flush() actually POSTs (offline writes to disk instead). + // No persisted data exists on a fresh test, so drainDiskStore is a no-op. + deps.networkMonitor = StubNetworkMonitor(status: .connected) + deps.identityManager = identityManager + deps.lifecycleStorage = self.lifecycleStorage + deps.identityStorage = identityStorage + deps.appVersionInfo = AppVersionInfo(version: "1.5.0", build: "42") + // Force "active" on cold launch so we can assert the Opened event regardless of platform. + deps.initialAppState = .active + deps.dispatcherConfig = Dispatcher.Config( + endpointPath: "/v1/batch", + timeoutMs: 1000, + autoFlushThreshold: 9999, // don't auto-flush during tests + initialMaxBatchSize: 100 + ) + + self.client = AnalyticsClient.initialize(options: options, deps: deps) + } + + /// Block until init's task chain has settled (cold-launch sequence emitted). + func waitForInit() async { + _ = await client.getAnonymousId() + try? await Task.sleep(nanoseconds: 200_000_000) + } + + /// Returns the union of queue-pending events and network-flushed events + /// in roughly chronological order. Drains the queue side-effectfully. + func collectEvents() async -> [EnrichedEventPayload] { + let queued = await queue.drain(max: 100) + let flushed = recorder.recorded + return flushed + queued + } + + /// Drops every captured/queued event, used to clear cold-launch noise. + func consumeAll() async { + _ = await queue.drain(max: 100) + recorder.clear() + } +} + +private final class RecordingNetworking: Networking, @unchecked Sendable { + private let lock = NSLock() + private var _events: [EnrichedEventPayload] = [] + + var recorded: [EnrichedEventPayload] { + lock.withLock { _events } + } + + func clear() { + lock.withLock { _events.removeAll() } + } + + private struct Batch: Decodable { + let batch: [EnrichedEventPayload] + } + + func postJSON(url: URL, body: Data, timeoutMs: Int, additionalHeaders: [String: String]?) async throws -> NetworkResponse { + if let decoded = try? JSONDecoder().decode(Batch.self, from: body) { + lock.withLock { _events.append(contentsOf: decoded.batch) } + } + return NetworkResponse(statusCode: 200, headers: [:], body: nil) + } + + func parseRetryAfterMs(from headers: [String: String]) -> Int? { nil } +} diff --git a/Tests/MetaRouterTests/Helpers/TestHelpers.swift b/Tests/MetaRouterTests/Helpers/TestHelpers.swift index 88e0941..1a54d87 100644 --- a/Tests/MetaRouterTests/Helpers/TestHelpers.swift +++ b/Tests/MetaRouterTests/Helpers/TestHelpers.swift @@ -155,6 +155,10 @@ final class MockAnalyticsInterface: AnalyticsInterface, @unchecked Sendable { func setTracing(_ enabled: Bool) { recordCall(.setTracing(enabled: enabled)) } + + func handleDeepLink(url: URL, sourceApplication: String?) { + recordCall(.handleDeepLink(url: url, sourceApplication: sourceApplication)) + } } // Analytics Call Recording @@ -175,6 +179,7 @@ enum AnalyticsCall: Equatable { case setAdvertisingId(advertisingId: String?) case clearAdvertisingId case setTracing(enabled: Bool) + case handleDeepLink(url: URL, sourceApplication: String?) } // CodableValue Test Extensions diff --git a/Tests/MetaRouterTests/InitOptionsTests.swift b/Tests/MetaRouterTests/InitOptionsTests.swift index 9eec840..dda4507 100644 --- a/Tests/MetaRouterTests/InitOptionsTests.swift +++ b/Tests/MetaRouterTests/InitOptionsTests.swift @@ -80,6 +80,38 @@ final class InitOptionsTests: XCTestCase { XCTAssertFalse(output.contains("less than"), "equal values are not an inversion") } + + func testTrackLifecycleEventsDefaultsToTrue() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")! + ) + XCTAssertTrue(urlOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to true (URL initializer)") + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com" + ) + XCTAssertTrue(stringOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to true (String initializer)") + } + + func testTrackLifecycleEventsCanBeDisabled() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")!, + trackLifecycleEvents: false + ) + XCTAssertFalse(urlOptions.trackLifecycleEvents) + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com", + trackLifecycleEvents: false + ) + XCTAssertFalse(stringOptions.trackLifecycleEvents) + } } /// Captures both stdout and stderr during `block`. Used to assert on Logger.warn output. diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift new file mode 100644 index 0000000..4c8ff30 --- /dev/null +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -0,0 +1,345 @@ +import XCTest +@testable import MetaRouter + +/// Unit tests for `LifecycleEventEmitter`. Each test owns: +/// - a unique UserDefaults suite (lifecycle + identity storage isolation) +/// - a real `EventEnrichmentService` over a stub `ContextProvider` +/// - a real `Dispatcher` whose memory queue we drain directly to inspect emits +/// +/// `Dispatcher.offer` does not auto-flush below `autoFlushThreshold` (20), so +/// 1–2 emitted events stay in the queue and we drain them to assert. +final class LifecycleEventEmitterTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var queue: PersistentEventQueue! + private var dispatcher: Dispatcher! + private var enrichmentService: EventEnrichmentService! + private var identityStorage: IdentityStorage! + private var lifecycleStorage: LifecycleStorage! + private let version = AppVersionInfo(version: "1.5.0", build: "42") + + override func setUp() async throws { + try await super.setUp() + suiteName = "com.metarouter.test.lifecycleEmitter.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + identityStorage = IdentityStorage(userDefaults: defaults) + lifecycleStorage = LifecycleStorage(userDefaults: defaults) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("metarouter-emitter-\(UUID().uuidString)") + let diskStore = DiskStorage(baseDirectory: tempDir) + queue = PersistentEventQueue(diskStore: diskStore, maxEventCount: 1000) + + let options = TestDataFactory.makeInitOptions() + dispatcher = Dispatcher( + options: options, + http: SilentNetworking(), + persistentQueue: queue, + // Push thresholds high so offer() doesn't auto-flush during tests. + config: Dispatcher.Config( + endpointPath: "/v1/batch", + timeoutMs: 8000, + autoFlushThreshold: 9999, + initialMaxBatchSize: 100 + ) + ) + + let identityManager = IdentityManager( + storage: identityStorage, + writeKey: options.writeKey, + host: options.ingestionHost.absoluteString + ) + enrichmentService = EventEnrichmentService( + contextProvider: StubContextProvider(), + identityManager: identityManager, + writeKey: options.writeKey + ) + } + + override func tearDown() async throws { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + queue = nil + dispatcher = nil + enrichmentService = nil + identityStorage = nil + lifecycleStorage = nil + try await super.tearDown() + } + + // MARK: - cold launch + + func testColdLaunchFreshInstallEmitsInstalledThenOpened() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Installed") + XCTAssertEqual(events[1].event, "Application Opened") + + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + XCTAssertEqual(events[1].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[1].properties?["build"], .string("42")) + + XCTAssertEqual(lifecycleStorage.getVersion(), "1.5.0") + XCTAssertEqual(lifecycleStorage.getBuild(), "42") + } + + /// Existing user upgrading from a pre-lifecycle SDK build: + /// no lifecycle storage, but identity storage already exists. Should be + /// `Application Updated{previous_*=unknown}`, NOT `Application Installed`. + func testColdLaunchSdkUpgradeEmitsUpdatedThenOpened() async { + identityStorage.set(.anonymousId, value: "existing-anon") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("unknown")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("unknown")) + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + } + + func testColdLaunchSameVersionEmitsOnlyOpened() async { + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "42") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + func testColdLaunchVersionDifferenceEmitsUpdatedThenOpened() async { + lifecycleStorage.setVersionBuild(version: "1.4.0", build: "37") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("1.4.0")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("37")) + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].event, "Application Opened") + } + + /// Build-only changes count as Updated (parity with Android plan). + func testColdLaunchBuildOnlyDifferenceEmitsUpdated() async { + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "41") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("41")) + } + + /// Background-launched processes (silent push, background fetch) suppress + /// the cold-launch Opened. The next true foreground entry emits it. + func testColdLaunchInBackgroundSuppressesOpenedUntilForeground() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .background) + + var events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Installed") + + // First foreground entry emits Opened with from_background:false (cold-launch bridge) + await emitter.emitForegroundFromBackground() + events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + // MARK: - foreground / background + + func testBackgroundedEmitsApplicationBackgrounded() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + await emitter.emitBackgrounded() + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Backgrounded") + // Spec: empty properties payload (Codable encodes nil as omitted) + XCTAssertNil(events[0].properties) + } + + func testForegroundAfterBackgroundEmitsOpenedFromBackground() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + await emitter.emitBackgrounded() + _ = await drain() + + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(true)) + } + + /// `inactive → active` transitions (Control Center, FaceID prompt, system + /// alert) must NOT emit Application Opened. Only `background → active` does. + func testInactiveToActiveTransitionDoesNotEmit() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Simulate Control Center: didBecomeActive fires without prior didEnterBackground. + // lastTrackedAppState was .active → still .active → no emit. + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertTrue(events.isEmpty, + "inactive→active transition must not emit Application Opened") + } + + /// First `didBecomeActive` during init should NOT double-emit. Cold-launch + /// path is the sole producer of the first Opened — the observer's didBecomeActive + /// is suppressed until cold-launch flips `coldLaunchEmitted`. + func testFirstForegroundCallBeforeColdLaunchIsSuppressed() async { + let emitter = makeEmitter() + // Imagine the observer's didBecomeActive arriving before emitColdLaunchSequence. + await emitter.emitForegroundFromBackground() + let preCold = await drain() + XCTAssertTrue(preCold.isEmpty, + "Foreground call before cold launch must suppress (cold-launch path is sole producer)") + + await emitter.emitColdLaunchSequence(initialAppState: .active) + let cold = await drain() + XCTAssertEqual(cold.count, 2, "Cold launch should still emit Installed + Opened") + XCTAssertEqual(cold[0].event, "Application Installed") + XCTAssertEqual(cold[1].event, "Application Opened") + } + + /// Second background→active cycle still emits with from_background:true. + func testTwoBackgroundForegroundCyclesEmitOpenedTwice() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + var events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Backgrounded") + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(true)) + + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(true)) + } + + // MARK: - deep-link buffer + + func testHandleDeepLinkAttachesUrlAndReferringApplicationToNextOpened() async { + let emitter = makeEmitter() + await emitter.handleDeepLink( + url: "myapp://product/42", + sourceApplication: "com.example.referrer" + ) + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + let opened = events[1] + XCTAssertEqual(opened.event, "Application Opened") + XCTAssertEqual(opened.properties?["url"], .string("myapp://product/42")) + XCTAssertEqual(opened.properties?["referring_application"], .string("com.example.referrer")) + } + + func testDeepLinkBufferIsOneShot() async { + let emitter = makeEmitter() + await emitter.handleDeepLink(url: "myapp://x", sourceApplication: nil) + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Second Opened (background→active) without a new handleDeepLink should not + // carry buffered URL. + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertEqual(events.count, 2) + let opened = events[1] + XCTAssertEqual(opened.event, "Application Opened") + XCTAssertNil(opened.properties?["url"]) + XCTAssertNil(opened.properties?["referring_application"]) + } + + func testHandleDeepLinkWithoutSourceOmitsReferringApplication() async { + let emitter = makeEmitter() + await emitter.handleDeepLink(url: "myapp://x", sourceApplication: nil) + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + let opened = events[1] + XCTAssertEqual(opened.properties?["url"], .string("myapp://x")) + XCTAssertNil(opened.properties?["referring_application"], + "Optional source must be omitted, not emitted as null") + } + + // MARK: - helpers + + private func makeEmitter() -> LifecycleEventEmitter { + return LifecycleEventEmitter( + enrichmentService: enrichmentService, + dispatcher: dispatcher, + storage: lifecycleStorage, + identityStorage: identityStorage, + versionInfo: version + ) + } + + private func drain() async -> [EnrichedEventPayload] { + return await queue.drain(max: 100) + } +} + +/// Networking stub that swallows every request so flushes have no side-effects +/// in tests that nonetheless drive the dispatcher path. +private final class SilentNetworking: Networking, @unchecked Sendable { + func postJSON(url: URL, body: Data, timeoutMs: Int, additionalHeaders: [String: String]?) async throws -> NetworkResponse { + return NetworkResponse(statusCode: 200, headers: [:], body: nil) + } + func parseRetryAfterMs(from headers: [String: String]) -> Int? { nil } +} + +/// Minimal context provider for enrichment tests — returns a deterministic +/// EventContext without touching real platform APIs. +private struct StubContextProvider: ContextProvider { + func getContext() async -> EventContext { + return EventContext( + app: AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test"), + device: DeviceContext(manufacturer: "Apple", model: "test-model", type: "ios"), + library: LibraryContext(name: "metarouter-ios-sdk", version: MetaRouterSDK.version), + os: OSContext(name: "iOS", version: "18.0"), + screen: ScreenContext(density: 2.0, width: 100, height: 100), + network: nil, + locale: "en_US", + timezone: "UTC" + ) + } + + func clearCache() {} +} diff --git a/Tests/MetaRouterTests/LifecycleStorageTests.swift b/Tests/MetaRouterTests/LifecycleStorageTests.swift new file mode 100644 index 0000000..d58d8ab --- /dev/null +++ b/Tests/MetaRouterTests/LifecycleStorageTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import MetaRouter + +final class LifecycleStorageTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "com.metarouter.test.lifecycleStorage.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testRoundTripVersionAndBuild() { + let storage = LifecycleStorage(userDefaults: defaults) + + XCTAssertNil(storage.getVersion()) + XCTAssertNil(storage.getBuild()) + + storage.setVersion("1.5.0") + storage.setBuild("42") + + XCTAssertEqual(storage.getVersion(), "1.5.0") + XCTAssertEqual(storage.getBuild(), "42") + } + + func testSetVersionBuildHelperSetsBoth() { + let storage = LifecycleStorage(userDefaults: defaults) + storage.setVersionBuild(version: "2.0.0", build: "100") + + XCTAssertEqual(storage.getVersion(), "2.0.0") + XCTAssertEqual(storage.getBuild(), "100") + } + + func testClearRemovesBothKeys() { + let storage = LifecycleStorage(userDefaults: defaults) + storage.setVersionBuild(version: "1.0", build: "1") + storage.clear() + + XCTAssertNil(storage.getVersion()) + XCTAssertNil(storage.getBuild()) + } + + /// Lifecycle storage uses the `metarouter:lifecycle:*` key prefix and is + /// NOT enumerated by `IdentityStorage.clear()`. This is the structural + /// guarantee that `reset()` cannot wipe install/update state. + func testIdentityStorageClearDoesNotTouchLifecycleKeys() { + // Seed both stores on the same backing UserDefaults + let identityStorage = IdentityStorage(userDefaults: defaults) + identityStorage.set(.anonymousId, value: "abc") + identityStorage.set(.userId, value: "user-1") + + let lifecycleStorage = LifecycleStorage(userDefaults: defaults) + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "42") + + // Clearing identity must not touch lifecycle + identityStorage.clear() + + XCTAssertNil(identityStorage.get(.anonymousId), "identity cleared") + XCTAssertNil(identityStorage.get(.userId), "identity cleared") + XCTAssertEqual(lifecycleStorage.getVersion(), "1.5.0", + "lifecycle storage must survive IdentityStorage.clear()") + XCTAssertEqual(lifecycleStorage.getBuild(), "42", + "lifecycle storage must survive IdentityStorage.clear()") + } + + func testKeysUseExpectedNamespace() { + XCTAssertEqual(LifecycleStorageKey.version.rawValue, "metarouter:lifecycle:version") + XCTAssertEqual(LifecycleStorageKey.build.rawValue, "metarouter:lifecycle:build") + } + + func testIdentityStorageHasAnyValueDetectsAnyKey() { + let storage = IdentityStorage(userDefaults: defaults) + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.anonymousId, value: "abc") + XCTAssertTrue(storage.hasAnyValue()) + + storage.clear() + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.userId, value: "u") + XCTAssertTrue(storage.hasAnyValue()) + } +} From c1c2d275cdb625bb8360526d87d0ce9198896211 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 09:12:54 -0600 Subject: [PATCH 2/9] refactor: adding in LifecycleCoordinator --- Sources/MetaRouter/Version.swift | 2 +- .../analytics/AnalyticsClient.swift | 57 +++++------------ .../lifecycle/LifecycleCoordinator.swift | 61 +++++++++++++++++++ 3 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift diff --git a/Sources/MetaRouter/Version.swift b/Sources/MetaRouter/Version.swift index e6eeaeb..18f16db 100644 --- a/Sources/MetaRouter/Version.swift +++ b/Sources/MetaRouter/Version.swift @@ -1,5 +1,5 @@ public enum MetaRouterSDK { // IMPORTANT: Update this value before each release. // This version is included in the `library.version` context field of all events. - public static let version = "1.5.0" + public static let version = "1.4.0" } diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index 1f8b728..acb21ed 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -1,9 +1,5 @@ import Foundation -#if canImport(UIKit) -import UIKit -#endif - /// Injectable dependencies for testing. All fields optional — defaults to production implementations. internal struct AnalyticsDependencies: Sendable { @@ -16,13 +12,11 @@ internal struct AnalyticsDependencies: Sendable { var dispatcher: Dispatcher? var persistentQueue: PersistentEventQueue? var networkMonitor: NetworkReachability? - var lifecycleEmitter: LifecycleEventEmitter? var lifecycleStorage: LifecycleStorage? var identityStorage: IdentityStorage? var appVersionInfo: AppVersionInfo? /// Override the initial app foreground state read at cold launch. - /// When nil, production code reads `UIApplication.shared.applicationState` - /// from the main actor; tests can pass `.active` / `.background` directly. + /// Tests pass `.active` / `.background` directly to skip the UIKit probe. var initialAppState: AppForegroundState? static let production = AnalyticsDependencies() @@ -41,8 +35,7 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl private var lifecycleState: LifecycleState = .idle private var disabled = false private var initTask: Task? - private let lifecycleEmitter: LifecycleEventEmitter? - private let initialAppStateOverride: AppForegroundState? + private let lifecycleCoordinator: LifecycleCoordinator? private init(options: InitOptions, deps: AnalyticsDependencies = .production) { self.lifecycleState = .initializing @@ -74,21 +67,24 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl config: deps.dispatcherConfig ?? Dispatcher.Config(endpointPath: "/v1/batch", timeoutMs: 8000, autoFlushThreshold: 20, initialMaxBatchSize: 100) ) - // Build the lifecycle emitter only when the feature is enabled. Construction + // Build the lifecycle coordinator only when the feature is enabled. Construction // happens BEFORE identityManager.initialize() runs (in initTask), so the // emitter's snapshot of "did identity exist before this launch?" is honest. if options.trackLifecycleEvents { - self.lifecycleEmitter = deps.lifecycleEmitter ?? LifecycleEventEmitter( + let emitter = LifecycleEventEmitter( enrichmentService: self.enrichmentService, dispatcher: self.dispatcher, storage: deps.lifecycleStorage ?? LifecycleStorage(), identityStorage: deps.identityStorage ?? IdentityStorage(), versionInfo: deps.appVersionInfo ?? .fromBundle() ) + self.lifecycleCoordinator = LifecycleCoordinator( + emitter: emitter, + initialStateOverride: deps.initialAppState + ) } else { - self.lifecycleEmitter = nil + self.lifecycleCoordinator = nil } - self.initialAppStateOverride = deps.initialAppState let rawMonitor = deps.networkMonitor ?? NetworkMonitor() let monitor = DebouncedNetworkMonitor(inner: rawMonitor) @@ -121,14 +117,14 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl guard let self else { return } await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) await self.dispatcher.flush() - await self.lifecycleEmitter?.emitForegroundFromBackground() + await self.lifecycleCoordinator?.onForeground() } }, onBackgroundAsync: { [weak self] in guard let self else { return } // Emit Application Backgrounded BEFORE flush/disk-flush so the event // is captured by the same drain that ships pending events to disk. - await self.lifecycleEmitter?.emitBackgrounded() + await self.lifecycleCoordinator?.onBackground() await self.dispatcher.flush() await self.dispatcher.flushToDisk() await self.dispatcher.stopFlushLoop() @@ -182,10 +178,7 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl // Emit cold-launch lifecycle sequence (Installed/Updated then Opened). // Runs after .ready so events flow through the standard track path. - if let emitter = self.lifecycleEmitter { - let initialState = await self.readInitialAppState() - await emitter.emitColdLaunchSequence(initialAppState: initialState) - } + await self.lifecycleCoordinator?.onReady() // Drain any persisted events from a previous session if monitor.currentStatus == .connected { @@ -194,25 +187,6 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } - /// Reads the current process foreground state. On iOS, hops to `MainActor` - /// because `UIApplication.shared.applicationState` is main-actor isolated. - /// Tests can short-circuit by setting `AnalyticsDependencies.initialAppState`. - private func readInitialAppState() async -> AppForegroundState { - if let override = initialAppStateOverride { return override } - #if canImport(UIKit) - return await MainActor.run { - switch UIApplication.shared.applicationState { - case .active: return AppForegroundState.active - case .inactive: return .inactive - case .background: return .background - @unknown default: return .active - } - } - #else - return .active - #endif - } - internal static func initialize(options: InitOptions, deps: AnalyticsDependencies = .production) -> AnalyticsClient { AnalyticsClient(options: options, deps: deps) } @@ -539,12 +513,9 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } public func handleDeepLink(url: URL, sourceApplication: String?) { - guard let emitter = lifecycleEmitter else { return } + guard let coordinator = lifecycleCoordinator else { return } Task { - await emitter.handleDeepLink( - url: url.absoluteString, - sourceApplication: sourceApplication - ) + await coordinator.handleDeepLink(url: url, sourceApplication: sourceApplication) } } } diff --git a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift new file mode 100644 index 0000000..6260be2 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -0,0 +1,61 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +/// Bridges `AnalyticsClient`'s init / foreground / background / deep-link callbacks +/// to `LifecycleEventEmitter`. Owns the cold-launch app-state probe so the UIKit +/// dependency stays out of `AnalyticsClient`. Designed as an extension point for +/// richer deep-linking and lifecycle hooks. +internal final class LifecycleCoordinator: @unchecked Sendable { + private let emitter: LifecycleEventEmitter + private let initialStateOverride: AppForegroundState? + + init( + emitter: LifecycleEventEmitter, + initialStateOverride: AppForegroundState? = nil + ) { + self.emitter = emitter + self.initialStateOverride = initialStateOverride + } + + func onForeground() async { + await emitter.emitForegroundFromBackground() + } + + func onBackground() async { + await emitter.emitBackgrounded() + } + + func onReady() async { + let initialState = await readInitialAppState() + await emitter.emitColdLaunchSequence(initialAppState: initialState) + } + + func handleDeepLink(url: URL, sourceApplication: String?) async { + await emitter.handleDeepLink( + url: url.absoluteString, + sourceApplication: sourceApplication + ) + } + + /// Reads current process foreground state. Hops to `MainActor` on iOS because + /// `UIApplication.shared.applicationState` is main-actor isolated. Tests + /// short-circuit via `initialStateOverride`. + private func readInitialAppState() async -> AppForegroundState { + if let override = initialStateOverride { return override } + #if canImport(UIKit) + return await MainActor.run { + switch UIApplication.shared.applicationState { + case .active: return AppForegroundState.active + case .inactive: return .inactive + case .background: return .background + @unknown default: return .active + } + } + #else + return .active + #endif + } +} From 5b5a1d88a250ce9d705fb1fe83217dea59fdc410 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 09:30:17 -0600 Subject: [PATCH 3/9] docs: readme updates --- README.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a85704..3a9ed0f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A lightweight iOS analytics SDK that transmits events to your MetaRouter cluster - [Compatibility](#-compatibility) - [Debugging](#debugging) - [Identity Persistence](#identity-persistence) +- [Lifecycle Events](#lifecycle-events) - [Event Queue Persistence](#event-queue-persistence) - [Advertising ID (IDFA)](#advertising-id-idfa) - [Using the alias() Method](#using-the-alias-method) @@ -229,6 +230,7 @@ The analytics client provides the following methods: - `enableDebugLogging()`: Enable debug logging - `getDebugInfo() async`: Get current debug information - `setTracing(_ enabled: Bool)`: Enable or disable tracing headers on API requests. When enabled, adds a `Trace: true` header to all outgoing events for backend debugging and diagnostics +- `handleDeepLink(url: URL, sourceApplication: String?)`: Forward an inbound deep-link URL so the next `Application Opened` event carries `url` and `referring_application` properties. See [Lifecycle Events](#lifecycle-events) for wiring ### Testing APIs @@ -257,6 +259,7 @@ await MetaRouter.Analytics.resetAndWait() - 💿 **Disk-Backed Queue**: Events survive app termination and are rehydrated on next launch - 🔌 **Circuit Breaker**: Intelligent retry logic with exponential backoff - ⚡ **Batching**: Automatic event batching for network efficiency +- 📲 **Lifecycle Events**: Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with opt-in deep-link attribution ## ✅ Compatibility @@ -591,7 +594,143 @@ The SDK automatically handles app lifecycle events: - **App Termination**: Best-effort disk snapshot (not guaranteed — process may exit before completion) - **Identity Persistence**: Anonymous ID, user ID, group ID, and advertising ID are persisted across app launches -### Event Queue Persistence +## Lifecycle Events + +When `trackLifecycleEvents` is enabled (default `true`), the SDK automatically emits four canonical lifecycle events. They flow through the same enrichment + dispatch pipeline as any other event, so they pick up `anonymousId`, `userId`, `groupId`, device context, and timestamps. + +### The Four Events + +| Event | Fires when | Properties | +| ----- | ---------- | ---------- | +| `Application Installed` | First launch on a device — no prior identity, no prior `(version, build)` persisted | `version`, `build` | +| `Application Updated` | App `(version, build)` changed since last launch, **or** lifecycle tracking is being enabled for the first time on an existing user (no install spike for the upgraded population) | `version`, `build`, `previous_version`, `previous_build` | +| `Application Opened` | After cold launch (process foregrounded) and on every `background → active` resume | `from_background` (Bool), `version`, `build`, optional `url`, optional `referring_application` | +| `Application Backgrounded` | App enters background. Emitted **before** the dispatcher's flush-to-disk so the event is captured in the same drain | _(none)_ | + +### Cold Launch Sequencing + +On a cold launch with `trackLifecycleEvents: true`, the SDK emits in this order once initialization completes: + +1. `Application Installed` **or** `Application Updated` (or neither, if version/build hasn't changed) +2. `Application Opened` with `from_background: false` + +If the process was woken in the background (silent push, background fetch, location update), the cold-launch `Application Opened` is **suppressed**. The next true `background → active` transition emits `Application Opened` with `from_background: false` as the cold-launch bridge. + +### Resume vs. inactive + +Only `background → active` transitions emit `Application Opened`. Brief `inactive` states (Control Center, FaceID prompt, system alert) do **not** emit — they're not real foregrounds. + +### Disabling + +Set `trackLifecycleEvents: false` in `InitOptions` to opt out entirely. No lifecycle events will be emitted, and `handleDeepLink` becomes a no-op. + +```swift +let options = InitOptions( + writeKey: "YOUR_WRITE_KEY", + ingestionHost: "https://your-ingestion-host.com", + trackLifecycleEvents: false +) +``` + +### Deep Link Attribution + +Forward inbound deep-link URLs to the SDK so the next `Application Opened` event carries `url` and `referring_application` properties. This is a one-shot buffer — the next Opened consumes and clears it. + +**`UIScene` (iOS 13+, recommended):** + +```swift +import UIKit +import MetaRouterSwiftSDK + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // Cold-launch deep link arrives here in connectionOptions + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + + if let urlContext = connectionOptions.urlContexts.first { + MetaRouter.Analytics.shared.handleDeepLink( + url: urlContext.url, + sourceApplication: urlContext.options.sourceApplication + ) + } + } + + // Resume deep link arrives here on background → active + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + MetaRouter.Analytics.shared.handleDeepLink( + url: urlContext.url, + sourceApplication: urlContext.options.sourceApplication + ) + } +} +``` + +**`UIApplicationDelegate` (legacy, single-scene apps):** + +```swift +import UIKit +import MetaRouterSwiftSDK + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + MetaRouter.Analytics.shared.handleDeepLink( + url: url, + sourceApplication: options[.sourceApplication] as? String + ) + return true + } +} +``` + +**Universal Links** are delivered through `NSUserActivity`, not `openURL`. Pull the `webpageURL` out yourself and forward it the same way: + +```swift +func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL else { return } + MetaRouter.Analytics.shared.handleDeepLink(url: url, sourceApplication: nil) +} +``` + +#### Buffer Semantics + +`handleDeepLink` stores **one** URL until the next `Application Opened` emits. Practical implications: + +- **Calling twice before an Opened**: only the most recent URL is attached. No queue. +- **Calling without an Opened ever firing**: the URL sits in the buffer until the next Opened, whenever that is. +- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `handleDeepLink` is called again. + +This shape matches how iOS delivers URLs — at one moment, correlated with one Opened. + +#### Privacy + +Deep-link URLs frequently carry sensitive data — auth tokens, password reset codes, magic-link secrets, OTPs. **The host app is responsible for sanitizing URLs before forwarding them.** Strip query parameters that shouldn't leave the device: + +```swift +func sanitized(_ url: URL) -> URL { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = components?.queryItems?.filter { item in + !["token", "code", "otp", "secret"].contains(item.name.lowercased()) + } + return components?.url ?? url +} + +MetaRouter.Analytics.shared.handleDeepLink( + url: sanitized(incomingURL), + sourceApplication: nil +) +``` + +The SDK does not auto-instrument deep-link capture (no method swizzling, no `UIApplicationDelegate` proxy). Manual forwarding keeps integration explicit, avoids conflicts with other SDKs that swizzle (Firebase, Adjust, AppsFlyer, Branch), and gives the host control over what data is captured. + +## Event Queue Persistence Unsent events are automatically persisted to disk and recovered across app launches. This prevents event loss when the app is backgrounded, terminated, or encounters network issues. From a72533a7fde5a2277de1ece934a0bb441072c4da Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 09:48:24 -0600 Subject: [PATCH 4/9] refactor: openURL rename --- README.md | 28 +++++++++---------- .../analytics/AnalyticsClient.swift | 9 ++++-- .../analytics/AnalyticsInterface.swift | 15 +++++----- .../MetaRouter/analytics/AnalyticsProxy.swift | 8 +++--- .../lifecycle/LifecycleCoordinator.swift | 4 +-- .../lifecycle/LifecycleEventEmitter.swift | 2 +- .../MetaRouterTests/Helpers/TestHelpers.swift | 6 ++-- .../LifecycleEventEmitterTests.swift | 12 ++++---- 8 files changed, 44 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3a9ed0f..6261871 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ The analytics client provides the following methods: - `enableDebugLogging()`: Enable debug logging - `getDebugInfo() async`: Get current debug information - `setTracing(_ enabled: Bool)`: Enable or disable tracing headers on API requests. When enabled, adds a `Trace: true` header to all outgoing events for backend debugging and diagnostics -- `handleDeepLink(url: URL, sourceApplication: String?)`: Forward an inbound deep-link URL so the next `Application Opened` event carries `url` and `referring_application` properties. See [Lifecycle Events](#lifecycle-events) for wiring +- `openURL(_ url: URL, sourceApplication: String?)`: Forward an inbound deep-link URL so the next `Application Opened` event carries `url` and `referring_application` properties. Mirrors UIKit's `application(_:open:options:)` shape. See [Lifecycle Events](#lifecycle-events) for wiring ### Testing APIs @@ -622,7 +622,7 @@ Only `background → active` transitions emit `Application Opened`. Brief `inact ### Disabling -Set `trackLifecycleEvents: false` in `InitOptions` to opt out entirely. No lifecycle events will be emitted, and `handleDeepLink` becomes a no-op. +Set `trackLifecycleEvents: false` in `InitOptions` to opt out entirely. No lifecycle events will be emitted, and `openURL` becomes a no-op (logs a debug warning the first time it's called). ```swift let options = InitOptions( @@ -650,8 +650,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { if let urlContext = connectionOptions.urlContexts.first { - MetaRouter.Analytics.shared.handleDeepLink( - url: urlContext.url, + MetaRouter.Analytics.shared.openURL( + urlContext.url, sourceApplication: urlContext.options.sourceApplication ) } @@ -660,8 +660,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Resume deep link arrives here on background → active func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { guard let urlContext = URLContexts.first else { return } - MetaRouter.Analytics.shared.handleDeepLink( - url: urlContext.url, + MetaRouter.Analytics.shared.openURL( + urlContext.url, sourceApplication: urlContext.options.sourceApplication ) } @@ -680,8 +680,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - MetaRouter.Analytics.shared.handleDeepLink( - url: url, + MetaRouter.Analytics.shared.openURL( + url, sourceApplication: options[.sourceApplication] as? String ) return true @@ -689,23 +689,23 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } ``` -**Universal Links** are delivered through `NSUserActivity`, not `openURL`. Pull the `webpageURL` out yourself and forward it the same way: +**Universal Links** are delivered through `NSUserActivity`, not the `openURL` callback. Pull the `webpageURL` out yourself and forward it the same way: ```swift func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } - MetaRouter.Analytics.shared.handleDeepLink(url: url, sourceApplication: nil) + MetaRouter.Analytics.shared.openURL(url, sourceApplication: nil) } ``` #### Buffer Semantics -`handleDeepLink` stores **one** URL until the next `Application Opened` emits. Practical implications: +`openURL` stores **one** URL until the next `Application Opened` emits. Practical implications: - **Calling twice before an Opened**: only the most recent URL is attached. No queue. - **Calling without an Opened ever firing**: the URL sits in the buffer until the next Opened, whenever that is. -- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `handleDeepLink` is called again. +- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `openURL` is called again. This shape matches how iOS delivers URLs — at one moment, correlated with one Opened. @@ -722,8 +722,8 @@ func sanitized(_ url: URL) -> URL { return components?.url ?? url } -MetaRouter.Analytics.shared.handleDeepLink( - url: sanitized(incomingURL), +MetaRouter.Analytics.shared.openURL( + sanitized(incomingURL), sourceApplication: nil ) ``` diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index acb21ed..3bb9680 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -512,10 +512,13 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } - public func handleDeepLink(url: URL, sourceApplication: String?) { - guard let coordinator = lifecycleCoordinator else { return } + public func openURL(_ url: URL, sourceApplication: String?) { + guard let coordinator = lifecycleCoordinator else { + Logger.warn("openURL called but trackLifecycleEvents is disabled — ignoring") + return + } Task { - await coordinator.handleDeepLink(url: url, sourceApplication: sourceApplication) + await coordinator.openURL(url, sourceApplication: sourceApplication) } } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 4c0c0a7..7028c20 100644 --- a/Sources/MetaRouter/analytics/AnalyticsInterface.swift +++ b/Sources/MetaRouter/analytics/AnalyticsInterface.swift @@ -26,11 +26,12 @@ public protocol AnalyticsInterface: AnyObject, Sendable { func clearAdvertisingId() func setTracing(_ enabled: Bool) - /// Buffers a deep-link URL to be attached to the next `Application Opened` - /// event as the `url` (and optionally `referring_application`) property. - /// Call from `application(_:open:options:)` or `scene(_:openURLContexts:)`, - /// and from `application(_:didFinishLaunchingWithOptions:)` for cold-launch - /// deep-link capture using `launchOptions[.url]` / `[.sourceApplication]`. - /// Buffered values are one-shot — cleared after the next emit. - func handleDeepLink(url: URL, sourceApplication: String?) + /// Tells the SDK the app is opening with this URL. Buffers the URL to be + /// attached to the next `Application Opened` event as the `url` (and + /// optionally `referring_application`) property. Call from + /// `application(_:open:options:)` or `scene(_:openURLContexts:)`, and from + /// `application(_:didFinishLaunchingWithOptions:)` for cold-launch capture + /// using `launchOptions[.url]` / `[.sourceApplication]`. One-shot — cleared + /// after the next emit. + func openURL(_ url: URL, sourceApplication: String?) } diff --git a/Sources/MetaRouter/analytics/AnalyticsProxy.swift b/Sources/MetaRouter/analytics/AnalyticsProxy.swift index fb889de..e895417 100644 --- a/Sources/MetaRouter/analytics/AnalyticsProxy.swift +++ b/Sources/MetaRouter/analytics/AnalyticsProxy.swift @@ -106,8 +106,8 @@ internal final class AnalyticsProxy: AnalyticsInterface, CustomStringConvertible Task { await state.enqueue(.setTracing(enabled)) } } - public func handleDeepLink(url: URL, sourceApplication: String?) { - Task { await state.enqueue(.handleDeepLink(url, sourceApplication)) } + public func openURL(_ url: URL, sourceApplication: String?) { + Task { await state.enqueue(.openURL(url, sourceApplication)) } } } @@ -132,7 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) - case handleDeepLink(URL, String?) + case openURL(URL, String?) } private actor ProxyState { @@ -215,7 +215,7 @@ private actor ProxyState { case .setAdvertisingId(let advertisingId): r.setAdvertisingId(advertisingId) case .clearAdvertisingId: r.clearAdvertisingId() case .setTracing(let enabled): r.setTracing(enabled) - case .handleDeepLink(let url, let source): r.handleDeepLink(url: url, sourceApplication: source) + case .openURL(let url, let source): r.openURL(url, sourceApplication: source) } } } diff --git a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift index 6260be2..c9b431b 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -33,8 +33,8 @@ internal final class LifecycleCoordinator: @unchecked Sendable { await emitter.emitColdLaunchSequence(initialAppState: initialState) } - func handleDeepLink(url: URL, sourceApplication: String?) async { - await emitter.handleDeepLink( + func openURL(_ url: URL, sourceApplication: String?) async { + await emitter.openURL( url: url.absoluteString, sourceApplication: sourceApplication ) diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift index d0cbc20..ea20e4c 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -188,7 +188,7 @@ internal actor LifecycleEventEmitter { /// Buffers a deep-link URL (and optional source application) to be attached /// to the next `Application Opened` event. One-shot — cleared on emit. - func handleDeepLink(url: String, sourceApplication: String?) { + func openURL(url: String, sourceApplication: String?) { pendingDeepLink = PendingDeepLink(url: url, source: sourceApplication) } diff --git a/Tests/MetaRouterTests/Helpers/TestHelpers.swift b/Tests/MetaRouterTests/Helpers/TestHelpers.swift index 1a54d87..b7f6b2c 100644 --- a/Tests/MetaRouterTests/Helpers/TestHelpers.swift +++ b/Tests/MetaRouterTests/Helpers/TestHelpers.swift @@ -156,8 +156,8 @@ final class MockAnalyticsInterface: AnalyticsInterface, @unchecked Sendable { recordCall(.setTracing(enabled: enabled)) } - func handleDeepLink(url: URL, sourceApplication: String?) { - recordCall(.handleDeepLink(url: url, sourceApplication: sourceApplication)) + func openURL(_ url: URL, sourceApplication: String?) { + recordCall(.openURL(url: url, sourceApplication: sourceApplication)) } } @@ -179,7 +179,7 @@ enum AnalyticsCall: Equatable { case setAdvertisingId(advertisingId: String?) case clearAdvertisingId case setTracing(enabled: Bool) - case handleDeepLink(url: URL, sourceApplication: String?) + case openURL(url: URL, sourceApplication: String?) } // CodableValue Test Extensions diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index 4c8ff30..efd3211 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -253,9 +253,9 @@ final class LifecycleEventEmitterTests: XCTestCase { // MARK: - deep-link buffer - func testHandleDeepLinkAttachesUrlAndReferringApplicationToNextOpened() async { + func testOpenURLAttachesUrlAndReferringApplicationToNextOpened() async { let emitter = makeEmitter() - await emitter.handleDeepLink( + await emitter.openURL( url: "myapp://product/42", sourceApplication: "com.example.referrer" ) @@ -271,11 +271,11 @@ final class LifecycleEventEmitterTests: XCTestCase { func testDeepLinkBufferIsOneShot() async { let emitter = makeEmitter() - await emitter.handleDeepLink(url: "myapp://x", sourceApplication: nil) + await emitter.openURL(url: "myapp://x", sourceApplication: nil) await emitter.emitColdLaunchSequence(initialAppState: .active) _ = await drain() - // Second Opened (background→active) without a new handleDeepLink should not + // Second Opened (background→active) without a new openURL should not // carry buffered URL. await emitter.emitBackgrounded() await emitter.emitForegroundFromBackground() @@ -287,9 +287,9 @@ final class LifecycleEventEmitterTests: XCTestCase { XCTAssertNil(opened.properties?["referring_application"]) } - func testHandleDeepLinkWithoutSourceOmitsReferringApplication() async { + func testOpenURLWithoutSourceOmitsReferringApplication() async { let emitter = makeEmitter() - await emitter.handleDeepLink(url: "myapp://x", sourceApplication: nil) + await emitter.openURL(url: "myapp://x", sourceApplication: nil) await emitter.emitColdLaunchSequence(initialAppState: .active) let events = await drain() From d390a347e00aeb25c8904f8e1cb8a5a315b6c9fa Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 09:56:54 -0600 Subject: [PATCH 5/9] refactor: removing inline closures --- .../analytics/AnalyticsClient.swift | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index 3bb9680..f5430dc 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -111,25 +111,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } self.lifecycle = AppLifecycleObserver( - onForeground: { [weak self] in - guard let self, self.lifecycleState == .ready else { return } - Task { [weak self] in - guard let self else { return } - await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) - await self.dispatcher.flush() - await self.lifecycleCoordinator?.onForeground() - } - }, - onBackgroundAsync: { [weak self] in - guard let self else { return } - // Emit Application Backgrounded BEFORE flush/disk-flush so the event - // is captured by the same drain that ships pending events to disk. - await self.lifecycleCoordinator?.onBackground() - await self.dispatcher.flush() - await self.dispatcher.flushToDisk() - await self.dispatcher.stopFlushLoop() - await self.dispatcher.cancelScheduledRetry() - } + onForeground: { [weak self] in self?.handleForeground() }, + onBackgroundAsync: { [weak self] in await self?.handleBackground() } ) // Wire network monitor: set initial state and subscribe to changes @@ -187,6 +170,26 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } + private func handleForeground() { + guard lifecycleState == .ready else { return } + Task { [weak self] in + guard let self else { return } + await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) + await self.dispatcher.flush() + await self.lifecycleCoordinator?.onForeground() + } + } + + /// Emit `Application Backgrounded` BEFORE flush/disk-flush so the event + /// is captured by the same drain that ships pending events to disk. + private func handleBackground() async { + await lifecycleCoordinator?.onBackground() + await dispatcher.flush() + await dispatcher.flushToDisk() + await dispatcher.stopFlushLoop() + await dispatcher.cancelScheduledRetry() + } + internal static func initialize(options: InitOptions, deps: AnalyticsDependencies = .production) -> AnalyticsClient { AnalyticsClient(options: options, deps: deps) } From 2d9932f2d65116f3caf1b1a6f75b2d7721799b9a Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 10:30:49 -0600 Subject: [PATCH 6/9] refactor: improve iOS UIKit probe --- README.md | 10 ++-- .../MetaRouter/analytics/InitOptions.swift | 4 +- .../lifecycle/LifecycleCoordinator.swift | 48 +++++++++++-------- Tests/MetaRouterTests/InitOptionsTests.swift | 20 ++++---- 4 files changed, 44 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6261871..641c041 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ await MetaRouter.Analytics.resetAndWait() - 💿 **Disk-Backed Queue**: Events survive app termination and are rehydrated on next launch - 🔌 **Circuit Breaker**: Intelligent retry logic with exponential backoff - ⚡ **Batching**: Automatic event batching for network efficiency -- 📲 **Lifecycle Events**: Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with opt-in deep-link attribution +- 📲 **Lifecycle Events** (opt-in): Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with deep-link attribution support ## ✅ Compatibility @@ -596,7 +596,7 @@ The SDK automatically handles app lifecycle events: ## Lifecycle Events -When `trackLifecycleEvents` is enabled (default `true`), the SDK automatically emits four canonical lifecycle events. They flow through the same enrichment + dispatch pipeline as any other event, so they pick up `anonymousId`, `userId`, `groupId`, device context, and timestamps. +When `trackLifecycleEvents` is enabled (default `false` — opt-in), the SDK automatically emits four canonical lifecycle events. They flow through the same enrichment + dispatch pipeline as any other event, so they pick up `anonymousId`, `userId`, `groupId`, device context, and timestamps. ### The Four Events @@ -620,15 +620,15 @@ If the process was woken in the background (silent push, background fetch, locat Only `background → active` transitions emit `Application Opened`. Brief `inactive` states (Control Center, FaceID prompt, system alert) do **not** emit — they're not real foregrounds. -### Disabling +### Enabling -Set `trackLifecycleEvents: false` in `InitOptions` to opt out entirely. No lifecycle events will be emitted, and `openURL` becomes a no-op (logs a debug warning the first time it's called). +Lifecycle tracking is **opt-in** — set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted and `openURL` is a no-op (logs a debug warning the first time it's called). ```swift let options = InitOptions( writeKey: "YOUR_WRITE_KEY", ingestionHost: "https://your-ingestion-host.com", - trackLifecycleEvents: false + trackLifecycleEvents: true ) ``` diff --git a/Sources/MetaRouter/analytics/InitOptions.swift b/Sources/MetaRouter/analytics/InitOptions.swift index 473d8cc..2401328 100644 --- a/Sources/MetaRouter/analytics/InitOptions.swift +++ b/Sources/MetaRouter/analytics/InitOptions.swift @@ -16,7 +16,7 @@ public struct InitOptions: Sendable { debug: Bool = false, maxQueueEvents: Int = 2000, maxDiskEvents: Int = 10000, - trackLifecycleEvents: Bool = true + trackLifecycleEvents: Bool = false ) { precondition(!writeKey.isEmpty, "writeKey must not be empty") @@ -48,7 +48,7 @@ extension InitOptions { debug: Bool = false, maxQueueEvents: Int = 2000, maxDiskEvents: Int = 10000, - trackLifecycleEvents: Bool = true + trackLifecycleEvents: Bool = false ) { var host = ingestionHost.trimmingCharacters(in: .whitespacesAndNewlines) if host.hasSuffix("/") { diff --git a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift index c9b431b..9b56335 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -1,9 +1,5 @@ import Foundation -#if canImport(UIKit) -import UIKit -#endif - /// Bridges `AnalyticsClient`'s init / foreground / background / deep-link callbacks /// to `LifecycleEventEmitter`. Owns the cold-launch app-state probe so the UIKit /// dependency stays out of `AnalyticsClient`. Designed as an extension point for @@ -29,7 +25,12 @@ internal final class LifecycleCoordinator: @unchecked Sendable { } func onReady() async { - let initialState = await readInitialAppState() + let initialState: AppForegroundState + if let override = initialStateOverride { + initialState = override + } else { + initialState = await currentAppForegroundState() + } await emitter.emitColdLaunchSequence(initialAppState: initialState) } @@ -39,23 +40,28 @@ internal final class LifecycleCoordinator: @unchecked Sendable { sourceApplication: sourceApplication ) } +} - /// Reads current process foreground state. Hops to `MainActor` on iOS because - /// `UIApplication.shared.applicationState` is main-actor isolated. Tests - /// short-circuit via `initialStateOverride`. - private func readInitialAppState() async -> AppForegroundState { - if let override = initialStateOverride { return override } - #if canImport(UIKit) - return await MainActor.run { - switch UIApplication.shared.applicationState { - case .active: return AppForegroundState.active - case .inactive: return .inactive - case .background: return .background - @unknown default: return .active - } +#if canImport(UIKit) +import UIKit + +/// Reads `UIApplication.applicationState` on the main actor (it's main-actor +/// isolated) and maps to our platform-neutral `AppForegroundState`. +fileprivate func currentAppForegroundState() async -> AppForegroundState { + await MainActor.run { + switch UIApplication.shared.applicationState { + case .active: return .active + case .inactive: return .inactive + case .background: return .background + @unknown default: return .active } - #else - return .active - #endif } } +#else +/// UIKit unavailable (macOS native, Linux). macOS apps don't have the iOS +/// background-launch scenario (silent push, background fetch), so `.active` +/// is the correct cold-launch assumption — `Application Opened` fires normally. +fileprivate func currentAppForegroundState() async -> AppForegroundState { + .active +} +#endif diff --git a/Tests/MetaRouterTests/InitOptionsTests.swift b/Tests/MetaRouterTests/InitOptionsTests.swift index dda4507..e8040e3 100644 --- a/Tests/MetaRouterTests/InitOptionsTests.swift +++ b/Tests/MetaRouterTests/InitOptionsTests.swift @@ -81,36 +81,36 @@ final class InitOptionsTests: XCTestCase { "equal values are not an inversion") } - func testTrackLifecycleEventsDefaultsToTrue() { + func testTrackLifecycleEventsDefaultsToFalse() { let urlOptions = InitOptions( writeKey: "wk", ingestionHost: URL(string: "https://example.com")! ) - XCTAssertTrue(urlOptions.trackLifecycleEvents, - "trackLifecycleEvents should default to true (URL initializer)") + XCTAssertFalse(urlOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (URL initializer) — opt-in feature") let stringOptions = InitOptions( writeKey: "wk", ingestionHost: "https://example.com" ) - XCTAssertTrue(stringOptions.trackLifecycleEvents, - "trackLifecycleEvents should default to true (String initializer)") + XCTAssertFalse(stringOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (String initializer) — opt-in feature") } - func testTrackLifecycleEventsCanBeDisabled() { + func testTrackLifecycleEventsCanBeEnabled() { let urlOptions = InitOptions( writeKey: "wk", ingestionHost: URL(string: "https://example.com")!, - trackLifecycleEvents: false + trackLifecycleEvents: true ) - XCTAssertFalse(urlOptions.trackLifecycleEvents) + XCTAssertTrue(urlOptions.trackLifecycleEvents) let stringOptions = InitOptions( writeKey: "wk", ingestionHost: "https://example.com", - trackLifecycleEvents: false + trackLifecycleEvents: true ) - XCTAssertFalse(stringOptions.trackLifecycleEvents) + XCTAssertTrue(stringOptions.trackLifecycleEvents) } } From 1cf758df71dc64852380876750c5a2ab8845a3e1 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 10:52:30 -0600 Subject: [PATCH 7/9] feat: adding in AppMetaData --- .../analytics/AnalyticsClient.swift | 11 +++-- .../context/DeviceContextProvider.swift | 22 +++++----- .../lifecycle/LifecycleEventEmitter.swift | 42 ++++++++++++------- .../AppLifecycleEventIntegrationTests.swift | 2 +- .../LifecycleEventEmitterTests.swift | 4 +- 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index f5430dc..86edec2 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -14,7 +14,7 @@ internal struct AnalyticsDependencies: Sendable { var networkMonitor: NetworkReachability? var lifecycleStorage: LifecycleStorage? var identityStorage: IdentityStorage? - var appVersionInfo: AppVersionInfo? + var appMetadata: AppMetadata? /// Override the initial app foreground state read at cold launch. /// Tests pass `.active` / `.background` directly to skip the UIKit probe. var initialAppState: AppForegroundState? @@ -41,7 +41,12 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl self.lifecycleState = .initializing self.options = options - self.contextProvider = deps.contextProvider ?? DeviceContextProvider() + // Snapshot bundle metadata once — used by both DeviceContextProvider (per-event + // app context) and LifecycleEventEmitter (install/update detection). Bundle + // is OS-loaded at process start and immutable, so caching is safe. + let appMetadata = deps.appMetadata ?? .fromBundle() + self.contextProvider = deps.contextProvider + ?? DeviceContextProvider(appMetadata: appMetadata) self.identityManager = deps.identityManager ?? IdentityManager( writeKey: options.writeKey, host: options.ingestionHost.absoluteString @@ -76,7 +81,7 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl dispatcher: self.dispatcher, storage: deps.lifecycleStorage ?? LifecycleStorage(), identityStorage: deps.identityStorage ?? IdentityStorage(), - versionInfo: deps.appVersionInfo ?? .fromBundle() + metadata: appMetadata ) self.lifecycleCoordinator = LifecycleCoordinator( emitter: emitter, diff --git a/Sources/MetaRouter/context/DeviceContextProvider.swift b/Sources/MetaRouter/context/DeviceContextProvider.swift index 546d271..0ad3a8e 100644 --- a/Sources/MetaRouter/context/DeviceContextProvider.swift +++ b/Sources/MetaRouter/context/DeviceContextProvider.swift @@ -29,13 +29,16 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { private let contextActor = ContextActor() private let library: LibraryContext + private let appMetadata: AppMetadata private let advertisingIdActor = AdvertisingIdActor() public init( libraryName: String = "metarouter-ios-sdk", - libraryVersion: String = MetaRouterSDK.version + libraryVersion: String = MetaRouterSDK.version, + appMetadata: AppMetadata = .fromBundle() ) { self.library = LibraryContext(name: libraryName, version: libraryVersion) + self.appMetadata = appMetadata } public func getContext() async -> EventContext { @@ -76,17 +79,12 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { } private func collectAppContext() async -> AppContext { - let bundle = Bundle.main - let info = bundle.infoDictionary ?? [:] - - let name = (info["CFBundleDisplayName"] as? String) - ?? (info["CFBundleName"] as? String) - ?? "Unknown" - let version = info["CFBundleShortVersionString"] as? String ?? "unknown" - let build = info["CFBundleVersion"] as? String ?? "unknown" - let namespace = bundle.bundleIdentifier ?? "unknown" - - return AppContext(name: name, version: version, build: build, namespace: namespace) + AppContext( + name: appMetadata.name, + version: appMetadata.version, + build: appMetadata.build, + namespace: appMetadata.namespace + ) } private func collectDeviceContext() async -> DeviceContext { diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift index ea20e4c..df5e7e4 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -7,23 +7,35 @@ public enum AppForegroundState: Sendable, Equatable { case background } -/// Snapshot of the app's `(version, build)` taken from the bundle at SDK init. -/// `version` reads `CFBundleShortVersionString`, `build` reads `CFBundleVersion`, -/// matching `DeviceContextProvider.collectAppContext()`. -public struct AppVersionInfo: Sendable, Equatable { +/// Snapshot of the app's bundle metadata taken once at SDK init. Single source +/// of truth for `EventEnrichmentService` (per-event `app` context block) and +/// `LifecycleEventEmitter` (install/update detection). `Bundle.main.infoDictionary` +/// is OS-loaded once at process start and immutable for the process lifetime, so +/// caching is safe. +public struct AppMetadata: Sendable, Equatable { + public let name: String public let version: String public let build: String + public let namespace: String - public init(version: String, build: String) { + public init(name: String, version: String, build: String, namespace: String) { + self.name = name self.version = version self.build = build + self.namespace = namespace } - public static func fromBundle(_ bundle: Bundle = .main) -> AppVersionInfo { + public static func fromBundle(_ bundle: Bundle = .main) -> AppMetadata { let info = bundle.infoDictionary ?? [:] - let version = info["CFBundleShortVersionString"] as? String ?? "unknown" - let build = info["CFBundleVersion"] as? String ?? "unknown" - return AppVersionInfo(version: version, build: build) + let name = (info["CFBundleDisplayName"] as? String) + ?? (info["CFBundleName"] as? String) + ?? "Unknown" + return AppMetadata( + name: name, + version: info["CFBundleShortVersionString"] as? String ?? "unknown", + build: info["CFBundleVersion"] as? String ?? "unknown", + namespace: bundle.bundleIdentifier ?? "unknown" + ) } } @@ -54,7 +66,7 @@ internal actor LifecycleEventEmitter { private let enrichmentService: EventEnrichmentService private let dispatcher: Dispatcher private let storage: LifecycleStorage - private let versionInfo: AppVersionInfo + private let metadata: AppMetadata private let hadIdentityBeforeInit: Bool private var coldLaunchEmitted: Bool = false @@ -72,12 +84,12 @@ internal actor LifecycleEventEmitter { dispatcher: Dispatcher, storage: LifecycleStorage = LifecycleStorage(), identityStorage: IdentityStorage = IdentityStorage(), - versionInfo: AppVersionInfo = .fromBundle() + metadata: AppMetadata = .fromBundle() ) { self.enrichmentService = enrichmentService self.dispatcher = dispatcher self.storage = storage - self.versionInfo = versionInfo + self.metadata = metadata // Snapshot before IdentityManager.initialize() auto-creates an anonymousId, // so we can tell a true fresh install (no identity) from an existing user // upgrading to a lifecycle-aware SDK build (identity already present). @@ -97,7 +109,7 @@ internal actor LifecycleEventEmitter { func emitColdLaunchSequence(initialAppState: AppForegroundState) async { let prevVersion = storage.getVersion() let prevBuild = storage.getBuild() - let curr = versionInfo + let curr = metadata if prevVersion == nil && prevBuild == nil { if hadIdentityBeforeInit { @@ -195,8 +207,8 @@ internal actor LifecycleEventEmitter { private func emitOpened(fromBackground: Bool) async { var properties: [String: CodableValue] = [ LifecycleEventProperties.fromBackground: .bool(fromBackground), - LifecycleEventProperties.version: .string(versionInfo.version), - LifecycleEventProperties.build: .string(versionInfo.build), + LifecycleEventProperties.version: .string(metadata.version), + LifecycleEventProperties.build: .string(metadata.build), ] if let buf = pendingDeepLink { properties[LifecycleEventProperties.url] = .string(buf.url) diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift index 3dcbcae..5f9c4ec 100644 --- a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -195,7 +195,7 @@ private final class Setup { deps.identityManager = identityManager deps.lifecycleStorage = self.lifecycleStorage deps.identityStorage = identityStorage - deps.appVersionInfo = AppVersionInfo(version: "1.5.0", build: "42") + deps.appMetadata = AppMetadata(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") // Force "active" on cold launch so we can assert the Opened event regardless of platform. deps.initialAppState = .active deps.dispatcherConfig = Dispatcher.Config( diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index efd3211..886bcce 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -16,7 +16,7 @@ final class LifecycleEventEmitterTests: XCTestCase { private var enrichmentService: EventEnrichmentService! private var identityStorage: IdentityStorage! private var lifecycleStorage: LifecycleStorage! - private let version = AppVersionInfo(version: "1.5.0", build: "42") + private let metadata = AppMetadata(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") override func setUp() async throws { try await super.setUp() @@ -307,7 +307,7 @@ final class LifecycleEventEmitterTests: XCTestCase { dispatcher: dispatcher, storage: lifecycleStorage, identityStorage: identityStorage, - versionInfo: version + metadata: metadata ) } From 6b555461b748929d68fb6a9279374f0a7247b3ce Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 11:10:50 -0600 Subject: [PATCH 8/9] fix: unifying AppMetadata --- .../analytics/AnalyticsClient.swift | 8 ++-- .../context/DeviceContextProvider.swift | 13 ++---- .../lifecycle/LifecycleEventEmitter.swift | 44 +++---------------- Sources/MetaRouter/types/EventContext.swift | 19 +++++++- .../AppLifecycleEventIntegrationTests.swift | 2 +- .../LifecycleEventEmitterTests.swift | 4 +- 6 files changed, 34 insertions(+), 56 deletions(-) diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index 86edec2..ef25514 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -14,7 +14,7 @@ internal struct AnalyticsDependencies: Sendable { var networkMonitor: NetworkReachability? var lifecycleStorage: LifecycleStorage? var identityStorage: IdentityStorage? - var appMetadata: AppMetadata? + var appContext: AppContext? /// Override the initial app foreground state read at cold launch. /// Tests pass `.active` / `.background` directly to skip the UIKit probe. var initialAppState: AppForegroundState? @@ -44,9 +44,9 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl // Snapshot bundle metadata once — used by both DeviceContextProvider (per-event // app context) and LifecycleEventEmitter (install/update detection). Bundle // is OS-loaded at process start and immutable, so caching is safe. - let appMetadata = deps.appMetadata ?? .fromBundle() + let appContext = deps.appContext ?? .fromBundle() self.contextProvider = deps.contextProvider - ?? DeviceContextProvider(appMetadata: appMetadata) + ?? DeviceContextProvider(appContext: appContext) self.identityManager = deps.identityManager ?? IdentityManager( writeKey: options.writeKey, host: options.ingestionHost.absoluteString @@ -81,7 +81,7 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl dispatcher: self.dispatcher, storage: deps.lifecycleStorage ?? LifecycleStorage(), identityStorage: deps.identityStorage ?? IdentityStorage(), - metadata: appMetadata + appContext: appContext ) self.lifecycleCoordinator = LifecycleCoordinator( emitter: emitter, diff --git a/Sources/MetaRouter/context/DeviceContextProvider.swift b/Sources/MetaRouter/context/DeviceContextProvider.swift index 0ad3a8e..22f7867 100644 --- a/Sources/MetaRouter/context/DeviceContextProvider.swift +++ b/Sources/MetaRouter/context/DeviceContextProvider.swift @@ -29,16 +29,16 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { private let contextActor = ContextActor() private let library: LibraryContext - private let appMetadata: AppMetadata + private let appContext: AppContext private let advertisingIdActor = AdvertisingIdActor() public init( libraryName: String = "metarouter-ios-sdk", libraryVersion: String = MetaRouterSDK.version, - appMetadata: AppMetadata = .fromBundle() + appContext: AppContext = .fromBundle() ) { self.library = LibraryContext(name: libraryName, version: libraryVersion) - self.appMetadata = appMetadata + self.appContext = appContext } public func getContext() async -> EventContext { @@ -79,12 +79,7 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { } private func collectAppContext() async -> AppContext { - AppContext( - name: appMetadata.name, - version: appMetadata.version, - build: appMetadata.build, - namespace: appMetadata.namespace - ) + appContext } private func collectDeviceContext() async -> DeviceContext { diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift index df5e7e4..6b284a0 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -7,38 +7,6 @@ public enum AppForegroundState: Sendable, Equatable { case background } -/// Snapshot of the app's bundle metadata taken once at SDK init. Single source -/// of truth for `EventEnrichmentService` (per-event `app` context block) and -/// `LifecycleEventEmitter` (install/update detection). `Bundle.main.infoDictionary` -/// is OS-loaded once at process start and immutable for the process lifetime, so -/// caching is safe. -public struct AppMetadata: Sendable, Equatable { - public let name: String - public let version: String - public let build: String - public let namespace: String - - public init(name: String, version: String, build: String, namespace: String) { - self.name = name - self.version = version - self.build = build - self.namespace = namespace - } - - public static func fromBundle(_ bundle: Bundle = .main) -> AppMetadata { - let info = bundle.infoDictionary ?? [:] - let name = (info["CFBundleDisplayName"] as? String) - ?? (info["CFBundleName"] as? String) - ?? "Unknown" - return AppMetadata( - name: name, - version: info["CFBundleShortVersionString"] as? String ?? "unknown", - build: info["CFBundleVersion"] as? String ?? "unknown", - namespace: bundle.bundleIdentifier ?? "unknown" - ) - } -} - internal enum LifecycleEventNames { static let applicationInstalled = "Application Installed" static let applicationUpdated = "Application Updated" @@ -66,7 +34,7 @@ internal actor LifecycleEventEmitter { private let enrichmentService: EventEnrichmentService private let dispatcher: Dispatcher private let storage: LifecycleStorage - private let metadata: AppMetadata + private let appContext: AppContext private let hadIdentityBeforeInit: Bool private var coldLaunchEmitted: Bool = false @@ -84,12 +52,12 @@ internal actor LifecycleEventEmitter { dispatcher: Dispatcher, storage: LifecycleStorage = LifecycleStorage(), identityStorage: IdentityStorage = IdentityStorage(), - metadata: AppMetadata = .fromBundle() + appContext: AppContext ) { self.enrichmentService = enrichmentService self.dispatcher = dispatcher self.storage = storage - self.metadata = metadata + self.appContext = appContext // Snapshot before IdentityManager.initialize() auto-creates an anonymousId, // so we can tell a true fresh install (no identity) from an existing user // upgrading to a lifecycle-aware SDK build (identity already present). @@ -109,7 +77,7 @@ internal actor LifecycleEventEmitter { func emitColdLaunchSequence(initialAppState: AppForegroundState) async { let prevVersion = storage.getVersion() let prevBuild = storage.getBuild() - let curr = metadata + let curr = appContext if prevVersion == nil && prevBuild == nil { if hadIdentityBeforeInit { @@ -207,8 +175,8 @@ internal actor LifecycleEventEmitter { private func emitOpened(fromBackground: Bool) async { var properties: [String: CodableValue] = [ LifecycleEventProperties.fromBackground: .bool(fromBackground), - LifecycleEventProperties.version: .string(metadata.version), - LifecycleEventProperties.build: .string(metadata.build), + LifecycleEventProperties.version: .string(appContext.version), + LifecycleEventProperties.build: .string(appContext.build), ] if let buf = pendingDeepLink { properties[LifecycleEventProperties.url] = .string(buf.url) diff --git a/Sources/MetaRouter/types/EventContext.swift b/Sources/MetaRouter/types/EventContext.swift index dd58f21..250efa7 100644 --- a/Sources/MetaRouter/types/EventContext.swift +++ b/Sources/MetaRouter/types/EventContext.swift @@ -1,7 +1,9 @@ import Foundation -/// App-specific context information -public struct AppContext: Codable, Sendable { +/// App-specific context information. Cached once at SDK init from the bundle +/// (see `fromBundle`) — `Bundle.main.infoDictionary` is OS-loaded at process +/// start and immutable, so the cached value is stable for the SDK lifetime. +public struct AppContext: Codable, Sendable, Equatable { public let name: String public let version: String public let build: String @@ -13,6 +15,19 @@ public struct AppContext: Codable, Sendable { self.build = build self.namespace = namespace } + + public static func fromBundle(_ bundle: Bundle = .main) -> AppContext { + let info = bundle.infoDictionary ?? [:] + let name = (info["CFBundleDisplayName"] as? String) + ?? (info["CFBundleName"] as? String) + ?? "Unknown" + return AppContext( + name: name, + version: info["CFBundleShortVersionString"] as? String ?? "unknown", + build: info["CFBundleVersion"] as? String ?? "unknown", + namespace: bundle.bundleIdentifier ?? "unknown" + ) + } } /// Device-specific context information diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift index 5f9c4ec..dc6c033 100644 --- a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -195,7 +195,7 @@ private final class Setup { deps.identityManager = identityManager deps.lifecycleStorage = self.lifecycleStorage deps.identityStorage = identityStorage - deps.appMetadata = AppMetadata(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") + deps.appContext = AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") // Force "active" on cold launch so we can assert the Opened event regardless of platform. deps.initialAppState = .active deps.dispatcherConfig = Dispatcher.Config( diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index 886bcce..704f020 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -16,7 +16,7 @@ final class LifecycleEventEmitterTests: XCTestCase { private var enrichmentService: EventEnrichmentService! private var identityStorage: IdentityStorage! private var lifecycleStorage: LifecycleStorage! - private let metadata = AppMetadata(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") + private let appContext = AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") override func setUp() async throws { try await super.setUp() @@ -307,7 +307,7 @@ final class LifecycleEventEmitterTests: XCTestCase { dispatcher: dispatcher, storage: lifecycleStorage, identityStorage: identityStorage, - metadata: metadata + appContext: appContext ) } From 05e5e98899da3784f01c4ca5f2f72bf1944dca66 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 11:19:39 -0600 Subject: [PATCH 9/9] fix: testing comments --- .../AppLifecycleEventIntegrationTests.swift | 2 -- Tests/MetaRouterTests/LifecycleEventEmitterTests.swift | 8 ++++---- Tests/MetaRouterTests/PersistentEventQueueTests.swift | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift index dc6c033..76ec596 100644 --- a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -154,8 +154,6 @@ final class AppLifecycleEventIntegrationTests: XCTestCase { } } -// MARK: - test scaffolding - /// Bundles all dependencies needed to drive an `AnalyticsClient` with a /// drainable queue + a recording network sink that captures flushed batches. private final class Setup { diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index 704f020..bb6c16d 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -68,7 +68,7 @@ final class LifecycleEventEmitterTests: XCTestCase { try await super.tearDown() } - // MARK: - cold launch + // cold launch func testColdLaunchFreshInstallEmitsInstalledThenOpened() async { let emitter = makeEmitter() @@ -168,7 +168,7 @@ final class LifecycleEventEmitterTests: XCTestCase { XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) } - // MARK: - foreground / background + // foreground / background func testBackgroundedEmitsApplicationBackgrounded() async { let emitter = makeEmitter() @@ -251,7 +251,7 @@ final class LifecycleEventEmitterTests: XCTestCase { XCTAssertEqual(events[1].properties?["from_background"], .bool(true)) } - // MARK: - deep-link buffer + // deep-link buffer func testOpenURLAttachesUrlAndReferringApplicationToNextOpened() async { let emitter = makeEmitter() @@ -299,7 +299,7 @@ final class LifecycleEventEmitterTests: XCTestCase { "Optional source must be omitted, not emitted as null") } - // MARK: - helpers + // helpers private func makeEmitter() -> LifecycleEventEmitter { return LifecycleEventEmitter( diff --git a/Tests/MetaRouterTests/PersistentEventQueueTests.swift b/Tests/MetaRouterTests/PersistentEventQueueTests.swift index ad741ad..43df9e2 100644 --- a/Tests/MetaRouterTests/PersistentEventQueueTests.swift +++ b/Tests/MetaRouterTests/PersistentEventQueueTests.swift @@ -410,7 +410,7 @@ final class PersistentEventQueueTests: XCTestCase { "Events older than 7 days should be filtered out on drain") } - // MARK: - Pending overflow on disk write failure + // Pending overflow on disk write failure /// Poison the tempDir path so DiskStorage.ensureDirectory fails. /// Creates a regular file where the queue expects a directory. @@ -461,7 +461,7 @@ final class PersistentEventQueueTests: XCTestCase { XCTAssertEqual(snapshot?.events.count, 6) } - // MARK: - Byte cap enforcement on enqueue + // Byte cap enforcement on enqueue func testEnqueueAtByteCapFlushesMemoryToDisk() async throws { // maxSizeBytes small enough that a few test events exceed it.