diff --git a/README.md b/README.md index 7e4aff7..1eb7032 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) @@ -246,6 +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 +- `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 @@ -274,6 +276,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 @@ -608,7 +611,161 @@ 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`. **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)_ | + +### 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; 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( + 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 MetaRouter + +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.recordOpenedURL( + 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.recordOpenedURL( + urlContext.url, + sourceApplication: urlContext.options.sourceApplication + ) + } +} +``` + +**`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 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.shared.recordOpenedURL( + 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.recordOpenedURL( + 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. 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.recordOpenedURL(url, sourceApplication: nil) +} +``` + +#### Buffer Semantics + +`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 `recordOpenedURL` 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 { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return 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.recordOpenedURL( + 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. diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index e250fca..6094148 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 recordOpenedURL(_ url: URL, sourceApplication: String?) { + guard let coordinator = lifecycleCoordinator else { + Logger.warn("recordOpenedURL called but trackLifecycleEvents is disabled — ignoring") + return + } + Task { + await coordinator.recordOpenedURL(url, sourceApplication: sourceApplication) + } + } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 164ae71..50e0524 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 recordOpenedURL(_ url: URL, sourceApplication: String?) } diff --git a/Sources/MetaRouter/analytics/AnalyticsProxy.swift b/Sources/MetaRouter/analytics/AnalyticsProxy.swift index 9af2e54..7616238 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 recordOpenedURL(_ url: URL, sourceApplication: String?) { + Task { await state.enqueue(.recordOpenedURL(url, sourceApplication)) } + } } extension AnalyticsProxy { @@ -128,6 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) + case recordOpenedURL(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 .recordOpenedURL(let url, let source): r.recordOpenedURL(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..37eacab --- /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 recordOpenedURL(_ url: URL, sourceApplication: String?) async { + await emitter.recordOpenedURL( + 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/AnalyticsProxyTests.swift b/Tests/MetaRouterTests/AnalyticsProxyTests.swift index 0dba591..753a4a3 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 testRecordOpenedURLForwardedWhenBound() async { + proxy.bind(mockClient) + let url = URL(string: "myapp://promo/42")! + 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, .recordOpenedURL(url: url, sourceApplication: "com.example.referrer")) + } + + func testRecordOpenedURLQueuedBeforeBindAndReplayedInOrder() async { + let url1 = URL(string: "myapp://a")! + let url2 = URL(string: "myapp://b")! + proxy.recordOpenedURL(url1, sourceApplication: nil) + proxy.track("between_urls") + proxy.recordOpenedURL(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], .recordOpenedURL(url: url1, sourceApplication: nil)) + XCTAssertEqual(mockClient.calls[1], .track(event: "between_urls", properties: nil)) + XCTAssertEqual(mockClient.calls[2], .recordOpenedURL(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 new file mode 100644 index 0000000..d3b5ada --- /dev/null +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -0,0 +1,284 @@ +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") + } + + /// Calling `recordOpenedURL` while `trackLifecycleEvents == false` is a silent no-op + /// for event emission, but logs a debug warning so misconfiguration ("I'm + /// calling recordOpenedURL but no events fire!") is diagnosable from logs. + func testRecordOpenedURLWithFeatureDisabledLogsWarning() 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.recordOpenedURL(URL(string: "myapp://x")!, sourceApplication: nil) + } + + 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, + "recordOpenedURL 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 { + 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 + let tempDir: URL + + deinit { + try? FileManager.default.removeItem(at: tempDir) + } + + 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 + ) + + self.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..bdb6935 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 recordOpenedURL(_ url: URL, sourceApplication: String?) { + recordCall(.recordOpenedURL(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 recordOpenedURL(url: URL, sourceApplication: String?) } // CodableValue Test Extensions @@ -211,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.recordOpenedURL`) 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 9eec840..29e496e 100644 --- a/Tests/MetaRouterTests/InitOptionsTests.swift +++ b/Tests/MetaRouterTests/InitOptionsTests.swift @@ -80,30 +80,36 @@ final class InitOptionsTests: XCTestCase { XCTAssertFalse(output.contains("less than"), "equal values are not an inversion") } -} - -/// 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() + 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") - // 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) ?? "" -} + 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) + } +}