From 31063e3a02420e1760c769666c22d0b0de48391d Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:05:23 -0600 Subject: [PATCH 1/5] feat(lifecycle): storage + bundle metadata foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of 4 of the iOS lifecycle events feature (parent: sc-36764). Adds the storage layer and shared types that the upcoming emitter / wiring slices depend on. Pure additions — no behavior change to AnalyticsClient, no events emitted yet. - LifecycleStorage: UserDefaults wrapper for (version, build) under the metarouter:lifecycle:* namespace, separate from identity keys so reset() cannot wipe install/update state. - IdentityStorage.hasAnyValue(): helper for the emitter (slice 2) to snapshot identity presence at construction time, before IdentityManager.initialize() auto-creates an anonymousId. - AppContext: gains Equatable + fromBundle(_:) — single source of truth for app metadata, replacing the per-event Bundle.main.infoDictionary reads that DeviceContextProvider does today (consumed in slice 3). - AppForegroundState enum: platform-neutral active/inactive/background trichotomy used by the emitter and coordinator. Ticket: sc-38228 Parent: sc-36764 Stack: this PR -> sc-38229 -> sc-38230 -> sc-38231 --- .../MetaRouter/identity/IdentityStorage.swift | 11 +++ .../lifecycle/AppForegroundState.swift | 11 +++ Sources/MetaRouter/types/EventContext.swift | 19 +++- .../MetaRouter/utils/LifecycleStorage.swift | 50 ++++++++++ .../LifecycleStorageTests.swift | 92 +++++++++++++++++++ 5 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 Sources/MetaRouter/lifecycle/AppForegroundState.swift create mode 100644 Sources/MetaRouter/utils/LifecycleStorage.swift create mode 100644 Tests/MetaRouterTests/LifecycleStorageTests.swift 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/AppForegroundState.swift b/Sources/MetaRouter/lifecycle/AppForegroundState.swift new file mode 100644 index 0000000..0d4efa0 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/AppForegroundState.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Process-level foreground state used to gate cold-launch and resume emits +/// in the lifecycle subsystem. Mirrors `UIApplication.State` (and the AppKit +/// equivalent) but is platform-neutral so non-UI code can pass it across +/// actor / Task boundaries. +public enum AppForegroundState: Sendable, Equatable { + case active + case inactive + case background +} 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/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/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 d8bb1145b01e1c5f362388bda664175bb58b13cc Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:50:33 -0600 Subject: [PATCH 2/5] refactor(lifecycle): mark LifecycleStorage.clear() as internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-up (sc-38228 M1). The `clear()` method is a test-only seam, not part of the SDK's public contract. Marking it `public` would expose a "wipe install/update state" affordance to consumers that contradicts the entire rationale for the `metarouter:lifecycle:*` namespace separation: nothing — not even `reset()` — should be able to wipe this state. `@testable import MetaRouter` already gives test code access to `internal` symbols, so this is purely a tightening of the public surface with no functional change. --- Sources/MetaRouter/utils/LifecycleStorage.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/MetaRouter/utils/LifecycleStorage.swift b/Sources/MetaRouter/utils/LifecycleStorage.swift index 91927ed..e542625 100644 --- a/Sources/MetaRouter/utils/LifecycleStorage.swift +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -42,8 +42,11 @@ public struct LifecycleStorage: @unchecked Sendable { setBuild(build) } - /// Removes the persisted version and build. Intended for tests. - public func clear() { + /// Removes the persisted version and build. Test-only seam — production code + /// must never call this. The whole point of the `metarouter:lifecycle:*` + /// namespace separation is that nothing — not even `reset()` — can wipe + /// install/update state. + internal func clear() { userDefaults.removeObject(forKey: LifecycleStorageKey.version.rawValue) userDefaults.removeObject(forKey: LifecycleStorageKey.build.rawValue) } From 62dd91ae0711b879eaae6915688387ada2ec2947 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:09:22 -0600 Subject: [PATCH 3/5] feat(lifecycle): LifecycleEventEmitter actor + unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of 4 of the iOS lifecycle events feature (parent: sc-36764). Adds the lifecycle event emitter — the actor that owns install/update detection, the cold-launch state machine, foreground/background transitions, and the one-shot deep-link buffer. Standalone in this slice; not yet wired into AnalyticsClient. The emitter exposes four entrypoints: - emitColdLaunchSequence(initialAppState:): decides Installed vs Updated vs no-op based on persisted (version, build) and identity-existed- before-init snapshot, then conditionally emits Application Opened with from_background:false (suppressed for background-launched processes; the next true foreground entry emits the deferred Opened via the cold-launch bridge). - emitForegroundFromBackground(): handles bridge case, suppresses the first didBecomeActive during init, filters inactive→active, and emits Application Opened with from_background:true on real background→active transitions. - emitBackgrounded(): updates lastTrackedAppState and emits Application Backgrounded. - openURL(url:sourceApplication:): one-shot buffer (last-write-wins), consumed by the next emitOpened. State machine flags (coldLaunchEmitted, coldLaunchSuppressed, lastTrackedAppState, pendingDeepLink) are serialised through actor isolation. 22 unit tests cover the install/update decision tree, cold-launch sequencing, resume scenarios, deep-link buffer semantics, and double- emit suppression. Ticket: sc-38229 Parent: sc-36764 Stack: sc-38228 -> this PR -> sc-38230 -> sc-38231 --- .../lifecycle/LifecycleEventEmitter.swift | 191 ++++++++++ .../LifecycleEventEmitterTests.swift | 345 ++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift create mode 100644 Tests/MetaRouterTests/LifecycleEventEmitterTests.swift diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift new file mode 100644 index 0000000..6329d99 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -0,0 +1,191 @@ +import Foundation + +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 appContext: AppContext + 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(), + appContext: AppContext + ) { + self.enrichmentService = enrichmentService + self.dispatcher = dispatcher + self.storage = storage + 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). + 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 = appContext + + 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 openURL(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(appContext.version), + LifecycleEventProperties.build: .string(appContext.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/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift new file mode 100644 index 0000000..bb6c16d --- /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 appContext = AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") + + 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() + } + + // 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)) + } + + // 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)) + } + + // deep-link buffer + + func testOpenURLAttachesUrlAndReferringApplicationToNextOpened() async { + let emitter = makeEmitter() + await emitter.openURL( + 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.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Second Opened (background→active) without a new openURL 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 testOpenURLWithoutSourceOmitsReferringApplication() async { + let emitter = makeEmitter() + await emitter.openURL(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") + } + + // helpers + + private func makeEmitter() -> LifecycleEventEmitter { + return LifecycleEventEmitter( + enrichmentService: enrichmentService, + dispatcher: dispatcher, + storage: lifecycleStorage, + identityStorage: identityStorage, + appContext: appContext + ) + } + + 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() {} +} From 7052d4a660a01fc163f0be75be22613f56eaa294 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:53:21 -0600 Subject: [PATCH 4/5] fix(lifecycle): emitter race guard + buffer log lines + missing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-ups (sc-38229). - Guard emitBackgrounded against firing before the cold-launch sequence runs. Race scenario: process woken in .background, observer already registered, didEnterBackground arrives before the async initTask → onReady chain. Without the guard, we'd emit Backgrounded with no preceding Opened — a spec violation. - Add log line on the cold-launch bridge emit (suppressed cold launch → first true foreground entry). This path is rare and notoriously hard to diagnose in field reports; one log line earns its keep. - Add log line when openURL overwrites an existing pending deep link (last-write-wins), making field debugging of "why did Opened carry a different URL than I expected" tractable. - Drop the unnecessary lastTrackedAppState = .active write on the pre-cold-launch suppressed branch. The cold-launch path is the canonical source of truth for this flag (set in emitColdLaunchSequence); writing it during the suppression window is a no-op at best and hides intent at worst. - Add 5 missing test branches: - Cold launch with initialAppState == .inactive (distinct from .background) suppresses + bridges - Deep-link buffered before a suppressed cold launch survives the suppression and attaches to the bridge Opened - emitBackgrounded does NOT consume the deep-link buffer (one-shot per Application Opened, not per any-emit) - Same-bundle no-op cold launch still persists (version, build) to storage (regression guard against accidental skip) - Backgrounded before cold launch is suppressed (the new guard) Test count goes from 438 -> 443. All passing. --- .../lifecycle/LifecycleEventEmitter.swift | 19 +++- .../LifecycleEventEmitterTests.swift | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift index 6329d99..e4875a7 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -128,6 +128,7 @@ internal actor LifecycleEventEmitter { /// (Control Center, FaceID prompt, system alert). Only `background → active` emits. func emitForegroundFromBackground() async { if coldLaunchSuppressed { + Logger.log("Cold-launch Application Opened bridged from background") await emitOpened(fromBackground: false) coldLaunchSuppressed = false coldLaunchEmitted = true @@ -137,7 +138,8 @@ internal actor LifecycleEventEmitter { guard coldLaunchEmitted else { // First didBecomeActive during init — cold-launch path will (or did) emit. - lastTrackedAppState = .active + // Don't mutate `lastTrackedAppState` here: the cold-launch path is the + // canonical source of truth for it (set in `emitColdLaunchSequence`). return } @@ -150,7 +152,18 @@ internal actor LifecycleEventEmitter { /// Called from `AppLifecycleObserver.onBackgroundAsync` BEFORE the dispatcher /// flushes to disk so the event is captured by `flushToDisk()`. + /// + /// Guarded against firing before the cold-launch sequence runs: if neither + /// `coldLaunchEmitted` nor `coldLaunchSuppressed` is set, no `emitColdLaunchSequence` + /// has happened yet, so emitting `Application Backgrounded` would produce + /// a spec-violating event sequence (Backgrounded with no preceding Opened). + /// Race scenario: process woken in `.background`, observer registered, + /// `didEnterBackground` fires before the async `initTask → onReady` chain. func emitBackgrounded() async { + guard coldLaunchEmitted || coldLaunchSuppressed else { + Logger.log("Backgrounded fired before cold-launch sequence — suppressing to preserve event ordering") + return + } lastTrackedAppState = .background let event = await enrichmentService.createTrackEvent( event: LifecycleEventNames.applicationBackgrounded, @@ -161,7 +174,11 @@ 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. + /// Last-write-wins if called multiple times before the next Opened. func openURL(url: String, sourceApplication: String?) { + if pendingDeepLink != nil { + Logger.log("Pending deep link overwritten by newer URL") + } pendingDeepLink = PendingDeepLink(url: url, source: sourceApplication) } diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index bb6c16d..ce42031 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -299,6 +299,94 @@ final class LifecycleEventEmitterTests: XCTestCase { "Optional source must be omitted, not emitted as null") } + /// Cold launch in `.inactive` state (rare but possible — pulled-down + /// notification banner mid-launch, etc.) should suppress just like + /// `.background` does. The bridge fires on the next true foreground entry. + func testColdLaunchInInactiveSuppressesOpenedUntilForeground() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .inactive) + + var events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Installed") + + await emitter.emitForegroundFromBackground() + events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + /// Deep-link buffered before a cold launch that was suppressed (background- + /// launched process woken by a deep link) survives the suppression and + /// attaches to the bridge `Application Opened` when the user actually + /// foregrounds the app. + func testDeepLinkSurvivesSuppressedColdLaunch() async { + let emitter = makeEmitter() + await emitter.openURL(url: "myapp://promo/42", sourceApplication: "com.example.referrer") + await emitter.emitColdLaunchSequence(initialAppState: .background) + _ = await drain() + + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertEqual(events.count, 1) + let opened = events[0] + XCTAssertEqual(opened.event, "Application Opened") + XCTAssertEqual(opened.properties?["url"], .string("myapp://promo/42")) + XCTAssertEqual(opened.properties?["referring_application"], .string("com.example.referrer")) + } + + /// `emitBackgrounded` does NOT consume the deep-link buffer — buffer is + /// one-shot per `Application Opened`, not per any-emit. + func testBackgroundedDoesNotConsumeDeepLinkBuffer() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.emitBackgrounded() + let bgEvents = await drain() + XCTAssertEqual(bgEvents.count, 1) + XCTAssertEqual(bgEvents[0].event, "Application Backgrounded") + XCTAssertNil(bgEvents[0].properties?["url"], + "Backgrounded must never carry deep-link properties") + + // Buffer should still be intact and attach to the next Opened. + await emitter.emitForegroundFromBackground() + let resumeEvents = await drain() + XCTAssertEqual(resumeEvents.count, 1) + XCTAssertEqual(resumeEvents[0].event, "Application Opened") + XCTAssertEqual(resumeEvents[0].properties?["url"], .string("myapp://x")) + } + + /// Same-bundle no-op cold launch (no install/update event) must still + /// persist `(version, build)` so subsequent launches see them as "seen". + func testSameVersionColdLaunchStillPersistsVersionBuild() async { + // Pre-seed with current bundle's (version, build) — same as `metadata`. + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "42") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Storage values should still match — and crucially, this would also + // catch a regression where the no-op path skipped the persist call. + XCTAssertEqual(lifecycleStorage.getVersion(), "1.5.0") + XCTAssertEqual(lifecycleStorage.getBuild(), "42") + } + + /// Defensive: `emitBackgrounded` called before any cold-launch sequence + /// is suppressed. Race scenario — process woken in `.background`, observer + /// already registered, `didEnterBackground` arriving before async + /// `initTask → onReady` fires. Without the guard, we'd emit Backgrounded + /// with no preceding Opened, which is a spec violation. + func testBackgroundedBeforeColdLaunchIsSuppressed() async { + let emitter = makeEmitter() + await emitter.emitBackgrounded() + let events = await drain() + XCTAssertTrue(events.isEmpty, + "Backgrounded before cold launch must be suppressed to preserve event ordering") + } + // helpers private func makeEmitter() -> LifecycleEventEmitter { From 7dd1e684329b2182225d869b98d00c058d0882ef Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 11 May 2026 08:29:11 -0600 Subject: [PATCH 5/5] fix: review comments rename recordUrl --- .../lifecycle/LifecycleEventEmitter.swift | 2 +- .../LifecycleEventEmitterTests.swift | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift index e4875a7..543b2ec 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -175,7 +175,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. /// Last-write-wins if called multiple times before the next Opened. - func openURL(url: String, sourceApplication: String?) { + func recordOpenedURL(url: String, sourceApplication: String?) { if pendingDeepLink != nil { Logger.log("Pending deep link overwritten by newer URL") } diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift index ce42031..ffbf0c4 100644 --- a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -253,9 +253,9 @@ final class LifecycleEventEmitterTests: XCTestCase { // deep-link buffer - func testOpenURLAttachesUrlAndReferringApplicationToNextOpened() async { + func testRecordOpenedURLAttachesUrlAndReferringApplicationToNextOpened() async { let emitter = makeEmitter() - await emitter.openURL( + await emitter.recordOpenedURL( url: "myapp://product/42", sourceApplication: "com.example.referrer" ) @@ -271,11 +271,11 @@ final class LifecycleEventEmitterTests: XCTestCase { func testDeepLinkBufferIsOneShot() async { let emitter = makeEmitter() - await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.recordOpenedURL(url: "myapp://x", sourceApplication: nil) await emitter.emitColdLaunchSequence(initialAppState: .active) _ = await drain() - // Second Opened (background→active) without a new openURL should not + // Second Opened (background→active) without a new recordOpenedURL 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 testOpenURLWithoutSourceOmitsReferringApplication() async { + func testRecordOpenedURLWithoutSourceOmitsReferringApplication() async { let emitter = makeEmitter() - await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.recordOpenedURL(url: "myapp://x", sourceApplication: nil) await emitter.emitColdLaunchSequence(initialAppState: .active) let events = await drain() @@ -323,7 +323,7 @@ final class LifecycleEventEmitterTests: XCTestCase { /// foregrounds the app. func testDeepLinkSurvivesSuppressedColdLaunch() async { let emitter = makeEmitter() - await emitter.openURL(url: "myapp://promo/42", sourceApplication: "com.example.referrer") + await emitter.recordOpenedURL(url: "myapp://promo/42", sourceApplication: "com.example.referrer") await emitter.emitColdLaunchSequence(initialAppState: .background) _ = await drain() @@ -343,7 +343,7 @@ final class LifecycleEventEmitterTests: XCTestCase { await emitter.emitColdLaunchSequence(initialAppState: .active) _ = await drain() - await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.recordOpenedURL(url: "myapp://x", sourceApplication: nil) await emitter.emitBackgrounded() let bgEvents = await drain() XCTAssertEqual(bgEvents.count, 1)