From 31063e3a02420e1760c769666c22d0b0de48391d Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:05:23 -0600 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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 35d0583b75813784d6b238015ec512e0220e1f1c Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:11:05 -0600 Subject: [PATCH 5/9] feat(lifecycle): wire emitter into AnalyticsClient + openURL public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of 4 of the iOS lifecycle events feature (parent: sc-36764). Connects the emitter (slice 2) to AnalyticsClient via a new LifecycleCoordinator and ships the public openURL deep-link API. After this PR, the feature works end-to-end behind trackLifecycleEvents=true. Default stays false so no existing customer is impacted on upgrade. LifecycleCoordinator: - Wraps the emitter, owns the cold-launch UIKit probe (single #if canImport(UIKit) block), exposes onForeground / onBackground / onReady / openURL. - Single seam between the platform-notification observer and the emitter; UIApplication access stays out of AnalyticsClient. AnalyticsClient integration: - Snapshots AppContext.fromBundle() once at init; injects into both DeviceContextProvider (replacing per-event Bundle reads) and LifecycleEventEmitter. - Conditionally constructs LifecycleCoordinator when trackLifecycleEvents == true. - Named handleForeground() / handleBackground() async methods replace inline closures so the load-bearing emit-before-flush ordering rule lives next to the code it describes. - coordinator.onReady() fires inside initTask after .ready, before drainDiskStoreToNetwork, so cold-launch events ship in the same drain. Public API: - openURL(_ url: URL, sourceApplication: String?) on AnalyticsInterface, AnalyticsClient, and AnalyticsProxy. First param positional — mirrors UIApplication.application(_:open:) and Segment's API. - Logs Logger.warn when called while feature disabled (silent no-op was bad DX). - AnalyticsProxy buffers openURL identical to other proxied methods. Opt-in default: - InitOptions.trackLifecycleEvents: Bool = false. - InitOptionsTests cover default + explicit override. Test seams: - AnalyticsDependencies gains appContext, lifecycleStorage, identityStorage, initialAppState for integration test injection. - AppLifecycleEventIntegrationTests drives a real AnalyticsClient via DI, posts UIApplication/NSApplication notifications through NotificationCenter, asserts events flow through queue + network. Covers: cold launch end-to-end, flag-disabled path emits zero events, reset() preserves lifecycle storage, re-init same version emits only Opened, background notification triggers Application Backgrounded, foreground after background emits Opened with from_background:true. Ticket: sc-38230 Parent: sc-36764 Stack: sc-38228 -> sc-38229 -> this PR -> sc-38231 --- .../analytics/AnalyticsClient.swift | 84 ++++-- .../analytics/AnalyticsInterface.swift | 9 + .../MetaRouter/analytics/AnalyticsProxy.swift | 6 + .../MetaRouter/analytics/InitOptions.swift | 11 +- .../context/DeviceContextProvider.swift | 17 +- .../lifecycle/LifecycleCoordinator.swift | 67 +++++ .../AppLifecycleEventIntegrationTests.swift | 254 ++++++++++++++++++ .../MetaRouterTests/Helpers/TestHelpers.swift | 5 + Tests/MetaRouterTests/InitOptionsTests.swift | 32 +++ 9 files changed, 454 insertions(+), 31 deletions(-) create mode 100644 Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift create mode 100644 Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index e250fca..ef25514 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -12,6 +12,12 @@ internal struct AnalyticsDependencies: Sendable { var dispatcher: Dispatcher? var persistentQueue: PersistentEventQueue? var networkMonitor: NetworkReachability? + var lifecycleStorage: LifecycleStorage? + var identityStorage: IdentityStorage? + 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? static let production = AnalyticsDependencies() } @@ -29,12 +35,18 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl private var lifecycleState: LifecycleState = .idle private var disabled = false private var initTask: Task? + private let lifecycleCoordinator: LifecycleCoordinator? private init(options: InitOptions, deps: AnalyticsDependencies = .production) { 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 appContext = deps.appContext ?? .fromBundle() + self.contextProvider = deps.contextProvider + ?? DeviceContextProvider(appContext: appContext) self.identityManager = deps.identityManager ?? IdentityManager( writeKey: options.writeKey, host: options.ingestionHost.absoluteString @@ -60,6 +72,25 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl config: deps.dispatcherConfig ?? Dispatcher.Config(endpointPath: "/v1/batch", timeoutMs: 8000, autoFlushThreshold: 20, initialMaxBatchSize: 100) ) + // 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 { + let emitter = LifecycleEventEmitter( + enrichmentService: self.enrichmentService, + dispatcher: self.dispatcher, + storage: deps.lifecycleStorage ?? LifecycleStorage(), + identityStorage: deps.identityStorage ?? IdentityStorage(), + appContext: appContext + ) + self.lifecycleCoordinator = LifecycleCoordinator( + emitter: emitter, + initialStateOverride: deps.initialAppState + ) + } else { + self.lifecycleCoordinator = nil + } + let rawMonitor = deps.networkMonitor ?? NetworkMonitor() let monitor = DebouncedNetworkMonitor(inner: rawMonitor) @@ -85,21 +116,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() - } - }, - onBackgroundAsync: { [weak self] in - guard let self else { return } - 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 @@ -146,6 +164,10 @@ 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. + await self.lifecycleCoordinator?.onReady() + // Drain any persisted events from a previous session if monitor.currentStatus == .connected { await self.dispatcher.drainDiskStoreToNetwork() @@ -153,6 +175,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) } @@ -477,4 +519,14 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl await self.dispatcher.setTracing(enabled) } } + + 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.openURL(url, sourceApplication: sourceApplication) + } + } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 164ae71..7028c20 100644 --- a/Sources/MetaRouter/analytics/AnalyticsInterface.swift +++ b/Sources/MetaRouter/analytics/AnalyticsInterface.swift @@ -25,4 +25,13 @@ public protocol AnalyticsInterface: AnyObject, Sendable { func setAdvertisingId(_ advertisingId: String?) func clearAdvertisingId() func setTracing(_ enabled: Bool) + + /// 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 9af2e54..e895417 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 openURL(_ url: URL, sourceApplication: String?) { + Task { await state.enqueue(.openURL(url, sourceApplication)) } + } } extension AnalyticsProxy { @@ -128,6 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) + case openURL(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 .openURL(let url, let source): r.openURL(url, sourceApplication: source) } } } diff --git a/Sources/MetaRouter/analytics/InitOptions.swift b/Sources/MetaRouter/analytics/InitOptions.swift index f2a3cb4..2401328 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 = false ) { 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 = false ) { 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/context/DeviceContextProvider.swift b/Sources/MetaRouter/context/DeviceContextProvider.swift index 546d271..22f7867 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 appContext: AppContext private let advertisingIdActor = AdvertisingIdActor() public init( libraryName: String = "metarouter-ios-sdk", - libraryVersion: String = MetaRouterSDK.version + libraryVersion: String = MetaRouterSDK.version, + appContext: AppContext = .fromBundle() ) { self.library = LibraryContext(name: libraryName, version: libraryVersion) + self.appContext = appContext } public func getContext() async -> EventContext { @@ -76,17 +79,7 @@ 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 } private func collectDeviceContext() async -> DeviceContext { diff --git a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift new file mode 100644 index 0000000..9b56335 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -0,0 +1,67 @@ +import Foundation + +/// 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: AppForegroundState + if let override = initialStateOverride { + initialState = override + } else { + initialState = await currentAppForegroundState() + } + await emitter.emitColdLaunchSequence(initialAppState: initialState) + } + + func openURL(_ url: URL, sourceApplication: String?) async { + await emitter.openURL( + url: url.absoluteString, + sourceApplication: sourceApplication + ) + } +} + +#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 +/// 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/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift new file mode 100644 index 0000000..76ec596 --- /dev/null +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -0,0 +1,254 @@ +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)) + } +} + +/// 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.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( + 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..b7f6b2c 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 openURL(_ url: URL, sourceApplication: String?) { + recordCall(.openURL(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 openURL(url: URL, sourceApplication: String?) } // CodableValue Test Extensions diff --git a/Tests/MetaRouterTests/InitOptionsTests.swift b/Tests/MetaRouterTests/InitOptionsTests.swift index 9eec840..e8040e3 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 testTrackLifecycleEventsDefaultsToFalse() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")! + ) + XCTAssertFalse(urlOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (URL initializer) — opt-in feature") + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com" + ) + XCTAssertFalse(stringOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (String initializer) — opt-in feature") + } + + func testTrackLifecycleEventsCanBeEnabled() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")!, + trackLifecycleEvents: true + ) + XCTAssertTrue(urlOptions.trackLifecycleEvents) + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com", + trackLifecycleEvents: true + ) + XCTAssertTrue(stringOptions.trackLifecycleEvents) + } } /// Captures both stdout and stderr during `block`. Used to assert on Logger.warn output. From eac8bb2ede9f5ada4bfc70fcfeb18f1129cfedb0 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:03:48 -0600 Subject: [PATCH 6/9] test(lifecycle): proxy openURL coverage + warn-capture + tmpDir cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-ups (sc-38230). - Add openURL round-trip test through AnalyticsProxy: forward-when-bound and queued-before-bind-replayed-in-order paths. Closes the test gap the reviewer flagged where the new openURL Call enum case had no dedicated coverage even though the dispatch is mechanical. - Add testOpenURLWithFeatureDisabledLogsWarning in AppLifecycleEventIntegrationTests. Verifies the Logger.warn line fires when openURL is called while trackLifecycleEvents=false, so a silent-no-op misconfiguration is diagnosable from logs. - Hoist captureStderrAndStdout from InitOptionsTests (private) to TestHelpers (file-level). Add an async variant with a `settle` parameter for tests where the block under test fires fire-and-forget Tasks (the openURL flow does this — Task { ... } around the actor call). - Track tempDir on Setup and remove it in deinit so per-test integration tmp dirs don't accumulate in /var/folders/.../T. Sleep migration in integration tests deferred — would require adding a non-draining count API to PersistentEventQueue, which is a production surface change that the reviewer flagged as low priority. Tests pass reliably as-is. Test count goes 446 -> 454 (+5 emitter tests from slice 2's amend, +2 proxy tests, +1 warn-capture test). --- .../MetaRouterTests/AnalyticsProxyTests.swift | 39 ++++++++++++-- .../AppLifecycleEventIntegrationTests.swift | 32 ++++++++++- .../MetaRouterTests/Helpers/TestHelpers.swift | 54 +++++++++++++++++++ Tests/MetaRouterTests/InitOptionsTests.swift | 26 --------- 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/Tests/MetaRouterTests/AnalyticsProxyTests.swift b/Tests/MetaRouterTests/AnalyticsProxyTests.swift index 0dba591..67d6fb2 100644 --- a/Tests/MetaRouterTests/AnalyticsProxyTests.swift +++ b/Tests/MetaRouterTests/AnalyticsProxyTests.swift @@ -151,17 +151,48 @@ final class AnalyticsProxyTests: XCTestCase { func testResetForwardedWhenBound() async { proxy.bind(mockClient) proxy.reset() - + let forwarded = await TestUtilities.waitFor { [weak self] in self?.mockClient.callCount == 1 } - + XCTAssertTrue(forwarded) XCTAssertEqual(mockClient.calls.first, .reset) } - + + func testOpenURLForwardedWhenBound() async { + proxy.bind(mockClient) + let url = URL(string: "myapp://promo/42")! + proxy.openURL(url, sourceApplication: "com.example.referrer") + + let forwarded = await TestUtilities.waitFor { [weak self] in + self?.mockClient.callCount == 1 + } + + XCTAssertTrue(forwarded) + XCTAssertEqual(mockClient.calls.first, .openURL(url: url, sourceApplication: "com.example.referrer")) + } + + func testOpenURLQueuedBeforeBindAndReplayedInOrder() async { + let url1 = URL(string: "myapp://a")! + let url2 = URL(string: "myapp://b")! + proxy.openURL(url1, sourceApplication: nil) + proxy.track("between_urls") + proxy.openURL(url2, sourceApplication: "com.example") + + proxy.bind(mockClient) + let allForwarded = await TestUtilities.waitFor { [weak self] in + self?.mockClient.callCount == 3 + } + + XCTAssertTrue(allForwarded) + XCTAssertEqual(mockClient.calls[0], .openURL(url: url1, sourceApplication: nil)) + XCTAssertEqual(mockClient.calls[1], .track(event: "between_urls", properties: nil)) + XCTAssertEqual(mockClient.calls[2], .openURL(url: url2, sourceApplication: "com.example")) + } + // Call Queuing Tests - + func testCallsQueuedWhenNotBound() async { // Make calls before binding // Add delays to ensure sequential processing due to Task{} concurrency diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift index 76ec596..47cda81 100644 --- a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -82,6 +82,31 @@ final class AppLifecycleEventIntegrationTests: XCTestCase { XCTAssertEqual(after.first?.event, "user_event") } + /// Calling `openURL` while `trackLifecycleEvents == false` is a silent no-op + /// for event emission, but logs a debug warning so misconfiguration ("I'm + /// calling openURL but no events fire!") is diagnosable from logs. + func testOpenURLWithFeatureDisabledLogsWarning() async { + Logger.setDebugLogging(true) + defer { Logger.setDebugLogging(false) } + + let bundle = Setup(defaults: defaults, trackLifecycleEvents: false) + await bundle.waitForInit() + await bundle.consumeAll() + + let output = await captureStderrAndStdout(settle: 0.1) { + bundle.client.openURL(URL(string: "myapp://x")!, sourceApplication: nil) + } + + XCTAssertTrue(output.contains("openURL called but trackLifecycleEvents is disabled"), + "Expected disabled-flag warning, got: \(output)") + + // Sanity: still no event emitted. + try? await Task.sleep(nanoseconds: 100_000_000) + let events = await bundle.collectEvents() + XCTAssertTrue(events.isEmpty, + "openURL with feature disabled must not emit any event") + } + /// `reset()` must NOT clear lifecycle storage — install/update state survives /// because lifecycle keys live in a separate UserDefaults namespace. func testResetPreservesLifecycleStorage() async { @@ -161,6 +186,11 @@ private final class Setup { let queue: PersistentEventQueue let lifecycleStorage: LifecycleStorage let recorder: RecordingNetworking + let tempDir: URL + + deinit { + try? FileManager.default.removeItem(at: tempDir) + } init(defaults: UserDefaults, trackLifecycleEvents: Bool) { let options = InitOptions( @@ -170,7 +200,7 @@ private final class Setup { trackLifecycleEvents: trackLifecycleEvents ) - let tempDir = FileManager.default.temporaryDirectory + self.tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("metarouter-integration-\(UUID().uuidString)") let diskStore = DiskStorage(baseDirectory: tempDir) self.queue = PersistentEventQueue(diskStore: diskStore, maxEventCount: 1000) diff --git a/Tests/MetaRouterTests/Helpers/TestHelpers.swift b/Tests/MetaRouterTests/Helpers/TestHelpers.swift index b7f6b2c..cdd1fee 100644 --- a/Tests/MetaRouterTests/Helpers/TestHelpers.swift +++ b/Tests/MetaRouterTests/Helpers/TestHelpers.swift @@ -216,3 +216,57 @@ enum TestUtilities { return condition() } } + +// stdout/stderr capture for asserting on Logger output + +/// Captures both stdout and stderr during `block`. Used to assert on Logger.warn output. +/// Order matters: restore the original fds before reading so the pipe writer reaches EOF. +func captureStderrAndStdout(_ block: () -> Void) -> String { + let pipe = Pipe() + let origOut = dup(fileno(stdout)) + let origErr = dup(fileno(stderr)) + setvbuf(stdout, nil, _IONBF, 0) + setvbuf(stderr, nil, _IONBF, 0) + dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stdout)) + dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stderr)) + + block() + + // Restore stdout/stderr FIRST so no more writers reference the pipe + dup2(origOut, fileno(stdout)) + dup2(origErr, fileno(stderr)) + close(origOut) + close(origErr) + // Now safe to close the writer and read until EOF + pipe.fileHandleForWriting.closeFile() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" +} + +/// Async variant — useful when the block under test fires fire-and-forget +/// Tasks (e.g. `AnalyticsClient.openURL`) and the log line is emitted +/// asynchronously. Sleeps `settle` before restoring fds so background +/// Tasks have time to write. +func captureStderrAndStdout( + settle: TimeInterval = 0.2, + _ block: () async -> Void +) async -> String { + let pipe = Pipe() + let origOut = dup(fileno(stdout)) + let origErr = dup(fileno(stderr)) + setvbuf(stdout, nil, _IONBF, 0) + setvbuf(stderr, nil, _IONBF, 0) + dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stdout)) + dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stderr)) + + await block() + try? await Task.sleep(nanoseconds: UInt64(settle * 1_000_000_000)) + + dup2(origOut, fileno(stdout)) + dup2(origErr, fileno(stderr)) + close(origOut) + close(origErr) + pipe.fileHandleForWriting.closeFile() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" +} diff --git a/Tests/MetaRouterTests/InitOptionsTests.swift b/Tests/MetaRouterTests/InitOptionsTests.swift index e8040e3..29e496e 100644 --- a/Tests/MetaRouterTests/InitOptionsTests.swift +++ b/Tests/MetaRouterTests/InitOptionsTests.swift @@ -113,29 +113,3 @@ final class InitOptionsTests: XCTestCase { XCTAssertTrue(stringOptions.trackLifecycleEvents) } } - -/// Captures both stdout and stderr during `block`. Used to assert on Logger.warn output. -/// Order matters: restore the original fds before reading so the pipe writer reaches EOF. -private func captureStderrAndStdout(_ block: () -> Void) -> String { - let pipe = Pipe() - let origOut = dup(fileno(stdout)) - let origErr = dup(fileno(stderr)) - setvbuf(stdout, nil, _IONBF, 0) - setvbuf(stderr, nil, _IONBF, 0) - dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stdout)) - dup2(pipe.fileHandleForWriting.fileDescriptor, fileno(stderr)) - - block() - - // Restore stdout/stderr FIRST so no more writers reference the pipe - dup2(origOut, fileno(stdout)) - dup2(origErr, fileno(stderr)) - close(origOut) - close(origErr) - // Now safe to close the writer and read until EOF - pipe.fileHandleForWriting.closeFile() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" -} - - From 8c755e8792aedc6b862d1cf974868d9954551e7f Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:28:15 -0600 Subject: [PATCH 7/9] docs(lifecycle): add Lifecycle Events section to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of 4 of the iOS lifecycle events feature (parent: sc-36764). No behavior change. Adds a top-level "Lifecycle Events" section covering: - Table of the four events + their properties - Cold-launch sequencing rules (Installed/Updated then Opened) - Resume vs inactive semantics — only background→active emits Opened; Control Center / FaceID prompt / system alerts do not - Opt-in framing (trackLifecycleEvents: false default) - Deep-link wiring snippets: - UIScene cold launch (willConnectTo) + resume (openURLContexts) - UIApplicationDelegate.application(_:open:options:) for legacy single-scene apps - Universal Links via NSUserActivity.webpageURL - Buffer semantics: one-shot, last-write-wins, cleared on emit - Privacy / URL-sanitization guidance with code sample - Rationale for not auto-instrumenting (swizzling conflicts, UIScene migration, privacy footgun, host control) Also fixes a pre-existing structural issue: ## Event Queue Persistence was at h3 by accident (leftover from when it was nested under Identity Persistence) — promoted to h2 to match its TOC entry. Adds the new section to TOC + a feature bullet (📲 Lifecycle Events (opt-in)). Ticket: sc-38231 Parent: sc-36764 Stack: sc-38228 -> sc-38229 -> sc-38230 -> this PR --- README.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a85704..641c041 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 +- `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 @@ -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** (opt-in): Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with deep-link attribution support ## ✅ 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 `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 + +| 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. + +### Enabling + +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: true +) +``` + +### 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.openURL( + 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.openURL( + 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.openURL( + url, + sourceApplication: options[.sourceApplication] as? String + ) + return true + } +} +``` + +**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.openURL(url, sourceApplication: nil) +} +``` + +#### Buffer Semantics + +`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 `openURL` 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.openURL( + 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 564f576365e4c54aaa3df2ea683254da82bb1e19 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:06:09 -0600 Subject: [PATCH 8/9] docs(lifecycle): fix README snippets to use real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-ups (sc-38231). Critical: - import MetaRouterSwiftSDK -> import MetaRouter (the actual module name per Package.swift). The wrong import broke every deep-link snippet for copy-paste integrators. - MetaRouter.Analytics.shared.openURL(...) -> MetaRouter.Analytics.client().openURL(...) — there is no `shared` static accessor; `client()` is the canonical entry point and is already used elsewhere in the README. Important: - Add cold-launch deep-link snippet for legacy UIApplicationDelegate via launchOptions[.url] / [.sourceApplication]. The protocol's own doc comment promises this works; without the snippet, legacy single-scene apps launched on a deep link silently lose attribution. - Document that previous_version / previous_build can literally be the string "unknown" for the upgrade-from-pre-lifecycle population. Integrators building version-drift funnels need to know. - Drop "the first time it's called" — Logger.warn fires on every openURL call when the feature is disabled, not just the first. Replaced with clearer wording about the diagnostic intent. Minor: - Privacy snippet: switched to `guard var components else { return url }` for clearer nil handling, expanded the deny-list with realistic auth-related keys, called out that the list is illustrative. - Universal Links: noted that they carry no source-application identifier (they come from Safari/system) so always pass nil. --- README.md | 46 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 641c041..6bea8ba 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,7 @@ When `trackLifecycleEvents` is enabled (default `false` — opt-in), the SDK aut | 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 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`. **Note:** for the upgrade-from-pre-lifecycle case, `previous_version` and `previous_build` are emitted as the literal string `"unknown"` since the SDK had no prior persisted values. | | `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)_ | @@ -622,7 +622,7 @@ Only `background → active` transitions emit `Application Opened`. Brief `inact ### Enabling -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). +Lifecycle tracking is **opt-in** — set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted; calls to `openURL` are silent no-ops for event emission but log a debug warning so misconfiguration ("I'm calling `openURL` but no events fire!") is diagnosable from logs. ```swift let options = InitOptions( @@ -640,7 +640,7 @@ Forward inbound deep-link URLs to the SDK so the next `Application Opened` event ```swift import UIKit -import MetaRouterSwiftSDK +import MetaRouter final class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -650,7 +650,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { if let urlContext = connectionOptions.urlContexts.first { - MetaRouter.Analytics.shared.openURL( + MetaRouter.Analytics.client().openURL( urlContext.url, sourceApplication: urlContext.options.sourceApplication ) @@ -660,7 +660,7 @@ 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.openURL( + MetaRouter.Analytics.client().openURL( urlContext.url, sourceApplication: urlContext.options.sourceApplication ) @@ -670,17 +670,32 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { **`UIApplicationDelegate` (legacy, single-scene apps):** +For apps that launch on a deep link in the legacy single-scene model, the URL is delivered through `launchOptions` in `application(_:didFinishLaunchingWithOptions:)`, **not** through `application(_:open:options:)`. Forward both: + ```swift import UIKit -import MetaRouterSwiftSDK +import MetaRouter @main final class AppDelegate: UIResponder, UIApplicationDelegate { + // Cold-launch deep link arrives here via launchOptions + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + if let url = launchOptions?[.url] as? URL { + MetaRouter.Analytics.client().openURL( + url, + sourceApplication: launchOptions?[.sourceApplication] as? String + ) + } + return true + } + + // Resume deep link arrives here on background → active func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - MetaRouter.Analytics.shared.openURL( + MetaRouter.Analytics.client().openURL( url, sourceApplication: options[.sourceApplication] as? String ) @@ -689,13 +704,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } ``` -**Universal Links** are delivered through `NSUserActivity`, not the `openURL` callback. 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. Universal Links carry no source-application identifier (they originate from Safari or another system handler), so always pass `sourceApplication: nil`. ```swift func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } - MetaRouter.Analytics.shared.openURL(url, sourceApplication: nil) + MetaRouter.Analytics.client().openURL(url, sourceApplication: nil) } ``` @@ -715,14 +730,17 @@ Deep-link URLs frequently carry sensitive data — auth tokens, password reset c ```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()) + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url } - return components?.url ?? url + // Replace with your project's actual deny-list. This is illustrative. + let denied: Set = ["token", "code", "otp", "secret", + "access_token", "id_token", "auth", "key", "password"] + components.queryItems = components.queryItems?.filter { !denied.contains($0.name.lowercased()) } + return components.url ?? url } -MetaRouter.Analytics.shared.openURL( +MetaRouter.Analytics.client().openURL( sanitized(incomingURL), sourceApplication: nil ) From 36159e0578397b1a97a9a810c0257c3d074b044e Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 11 May 2026 09:01:00 -0600 Subject: [PATCH 9/9] fix: updating public API --- README.md | 20 +++++++++---------- .../analytics/AnalyticsClient.swift | 6 +++--- .../analytics/AnalyticsInterface.swift | 2 +- .../MetaRouter/analytics/AnalyticsProxy.swift | 8 ++++---- .../lifecycle/LifecycleCoordinator.swift | 4 ++-- .../MetaRouterTests/AnalyticsProxyTests.swift | 16 +++++++-------- .../AppLifecycleEventIntegrationTests.swift | 12 +++++------ .../MetaRouterTests/Helpers/TestHelpers.swift | 8 ++++---- 8 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 81370a9..1eb7032 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,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 -- `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 +- `recordOpenedURL(_ 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 @@ -639,7 +639,7 @@ Only `background → active` transitions emit `Application Opened`. Brief `inact ### Enabling -Lifecycle tracking is **opt-in** — set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted; calls to `openURL` are silent no-ops for event emission but log a debug warning so misconfiguration ("I'm calling `openURL` but no events fire!") is diagnosable from logs. +Lifecycle tracking is **opt-in** — set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted; calls to `recordOpenedURL` are silent no-ops for event emission but log a debug warning so misconfiguration ("I'm calling `recordOpenedURL` but no events fire!") is diagnosable from logs. ```swift let options = InitOptions( @@ -667,7 +667,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { if let urlContext = connectionOptions.urlContexts.first { - MetaRouter.Analytics.client().openURL( + MetaRouter.Analytics.shared.recordOpenedURL( urlContext.url, sourceApplication: urlContext.options.sourceApplication ) @@ -677,7 +677,7 @@ 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.client().openURL( + MetaRouter.Analytics.shared.recordOpenedURL( urlContext.url, sourceApplication: urlContext.options.sourceApplication ) @@ -700,7 +700,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if let url = launchOptions?[.url] as? URL { - MetaRouter.Analytics.client().openURL( + MetaRouter.Analytics.shared.recordOpenedURL( url, sourceApplication: launchOptions?[.sourceApplication] as? String ) @@ -712,7 +712,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - MetaRouter.Analytics.client().openURL( + MetaRouter.Analytics.shared.recordOpenedURL( url, sourceApplication: options[.sourceApplication] as? String ) @@ -727,17 +727,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL else { return } - MetaRouter.Analytics.client().openURL(url, sourceApplication: nil) + MetaRouter.Analytics.shared.recordOpenedURL(url, sourceApplication: nil) } ``` #### Buffer Semantics -`openURL` stores **one** URL until the next `Application Opened` emits. Practical implications: +`recordOpenedURL` 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 `openURL` is called again. +- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `recordOpenedURL` is called again. This shape matches how iOS delivers URLs — at one moment, correlated with one Opened. @@ -757,7 +757,7 @@ func sanitized(_ url: URL) -> URL { return components.url ?? url } -MetaRouter.Analytics.client().openURL( +MetaRouter.Analytics.shared.recordOpenedURL( sanitized(incomingURL), sourceApplication: nil ) diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index ef25514..6094148 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -520,13 +520,13 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } - public func openURL(_ url: URL, sourceApplication: String?) { + public func recordOpenedURL(_ url: URL, sourceApplication: String?) { guard let coordinator = lifecycleCoordinator else { - Logger.warn("openURL called but trackLifecycleEvents is disabled — ignoring") + Logger.warn("recordOpenedURL called but trackLifecycleEvents is disabled — ignoring") return } Task { - await coordinator.openURL(url, sourceApplication: sourceApplication) + await coordinator.recordOpenedURL(url, sourceApplication: sourceApplication) } } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 7028c20..50e0524 100644 --- a/Sources/MetaRouter/analytics/AnalyticsInterface.swift +++ b/Sources/MetaRouter/analytics/AnalyticsInterface.swift @@ -33,5 +33,5 @@ public protocol AnalyticsInterface: AnyObject, Sendable { /// `application(_:didFinishLaunchingWithOptions:)` for cold-launch capture /// using `launchOptions[.url]` / `[.sourceApplication]`. One-shot — cleared /// after the next emit. - func openURL(_ url: URL, sourceApplication: String?) + func recordOpenedURL(_ url: URL, sourceApplication: String?) } diff --git a/Sources/MetaRouter/analytics/AnalyticsProxy.swift b/Sources/MetaRouter/analytics/AnalyticsProxy.swift index e895417..7616238 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 openURL(_ url: URL, sourceApplication: String?) { - Task { await state.enqueue(.openURL(url, sourceApplication)) } + public func recordOpenedURL(_ url: URL, sourceApplication: String?) { + Task { await state.enqueue(.recordOpenedURL(url, sourceApplication)) } } } @@ -132,7 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) - case openURL(URL, String?) + case recordOpenedURL(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 .openURL(let url, let source): r.openURL(url, sourceApplication: source) + case .recordOpenedURL(let url, let source): r.recordOpenedURL(url, sourceApplication: source) } } } diff --git a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift index 9b56335..37eacab 100644 --- a/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -34,8 +34,8 @@ internal final class LifecycleCoordinator: @unchecked Sendable { await emitter.emitColdLaunchSequence(initialAppState: initialState) } - func openURL(_ url: URL, sourceApplication: String?) async { - await emitter.openURL( + func recordOpenedURL(_ url: URL, sourceApplication: String?) async { + await emitter.recordOpenedURL( url: url.absoluteString, sourceApplication: sourceApplication ) diff --git a/Tests/MetaRouterTests/AnalyticsProxyTests.swift b/Tests/MetaRouterTests/AnalyticsProxyTests.swift index 67d6fb2..753a4a3 100644 --- a/Tests/MetaRouterTests/AnalyticsProxyTests.swift +++ b/Tests/MetaRouterTests/AnalyticsProxyTests.swift @@ -160,25 +160,25 @@ final class AnalyticsProxyTests: XCTestCase { XCTAssertEqual(mockClient.calls.first, .reset) } - func testOpenURLForwardedWhenBound() async { + func testRecordOpenedURLForwardedWhenBound() async { proxy.bind(mockClient) let url = URL(string: "myapp://promo/42")! - proxy.openURL(url, sourceApplication: "com.example.referrer") + proxy.recordOpenedURL(url, sourceApplication: "com.example.referrer") let forwarded = await TestUtilities.waitFor { [weak self] in self?.mockClient.callCount == 1 } XCTAssertTrue(forwarded) - XCTAssertEqual(mockClient.calls.first, .openURL(url: url, sourceApplication: "com.example.referrer")) + XCTAssertEqual(mockClient.calls.first, .recordOpenedURL(url: url, sourceApplication: "com.example.referrer")) } - func testOpenURLQueuedBeforeBindAndReplayedInOrder() async { + func testRecordOpenedURLQueuedBeforeBindAndReplayedInOrder() async { let url1 = URL(string: "myapp://a")! let url2 = URL(string: "myapp://b")! - proxy.openURL(url1, sourceApplication: nil) + proxy.recordOpenedURL(url1, sourceApplication: nil) proxy.track("between_urls") - proxy.openURL(url2, sourceApplication: "com.example") + proxy.recordOpenedURL(url2, sourceApplication: "com.example") proxy.bind(mockClient) let allForwarded = await TestUtilities.waitFor { [weak self] in @@ -186,9 +186,9 @@ final class AnalyticsProxyTests: XCTestCase { } XCTAssertTrue(allForwarded) - XCTAssertEqual(mockClient.calls[0], .openURL(url: url1, sourceApplication: nil)) + XCTAssertEqual(mockClient.calls[0], .recordOpenedURL(url: url1, sourceApplication: nil)) XCTAssertEqual(mockClient.calls[1], .track(event: "between_urls", properties: nil)) - XCTAssertEqual(mockClient.calls[2], .openURL(url: url2, sourceApplication: "com.example")) + XCTAssertEqual(mockClient.calls[2], .recordOpenedURL(url: url2, sourceApplication: "com.example")) } // Call Queuing Tests diff --git a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift index 47cda81..d3b5ada 100644 --- a/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -82,10 +82,10 @@ final class AppLifecycleEventIntegrationTests: XCTestCase { XCTAssertEqual(after.first?.event, "user_event") } - /// Calling `openURL` while `trackLifecycleEvents == false` is a silent no-op + /// Calling `recordOpenedURL` while `trackLifecycleEvents == false` is a silent no-op /// for event emission, but logs a debug warning so misconfiguration ("I'm - /// calling openURL but no events fire!") is diagnosable from logs. - func testOpenURLWithFeatureDisabledLogsWarning() async { + /// calling recordOpenedURL but no events fire!") is diagnosable from logs. + func testRecordOpenedURLWithFeatureDisabledLogsWarning() async { Logger.setDebugLogging(true) defer { Logger.setDebugLogging(false) } @@ -94,17 +94,17 @@ final class AppLifecycleEventIntegrationTests: XCTestCase { await bundle.consumeAll() let output = await captureStderrAndStdout(settle: 0.1) { - bundle.client.openURL(URL(string: "myapp://x")!, sourceApplication: nil) + bundle.client.recordOpenedURL(URL(string: "myapp://x")!, sourceApplication: nil) } - XCTAssertTrue(output.contains("openURL called but trackLifecycleEvents is disabled"), + XCTAssertTrue(output.contains("recordOpenedURL called but trackLifecycleEvents is disabled"), "Expected disabled-flag warning, got: \(output)") // Sanity: still no event emitted. try? await Task.sleep(nanoseconds: 100_000_000) let events = await bundle.collectEvents() XCTAssertTrue(events.isEmpty, - "openURL with feature disabled must not emit any event") + "recordOpenedURL with feature disabled must not emit any event") } /// `reset()` must NOT clear lifecycle storage — install/update state survives diff --git a/Tests/MetaRouterTests/Helpers/TestHelpers.swift b/Tests/MetaRouterTests/Helpers/TestHelpers.swift index cdd1fee..bdb6935 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 openURL(_ url: URL, sourceApplication: String?) { - recordCall(.openURL(url: url, sourceApplication: sourceApplication)) + func recordOpenedURL(_ url: URL, sourceApplication: String?) { + recordCall(.recordOpenedURL(url: url, sourceApplication: sourceApplication)) } } @@ -179,7 +179,7 @@ enum AnalyticsCall: Equatable { case setAdvertisingId(advertisingId: String?) case clearAdvertisingId case setTracing(enabled: Bool) - case openURL(url: URL, sourceApplication: String?) + case recordOpenedURL(url: URL, sourceApplication: String?) } // CodableValue Test Extensions @@ -244,7 +244,7 @@ func captureStderrAndStdout(_ block: () -> Void) -> String { } /// Async variant — useful when the block under test fires fire-and-forget -/// Tasks (e.g. `AnalyticsClient.openURL`) and the log line is emitted +/// Tasks (e.g. `AnalyticsClient.recordOpenedURL`) and the log line is emitted /// asynchronously. Sleeps `settle` before restoring fds so background /// Tasks have time to write. func captureStderrAndStdout(