-
Notifications
You must be signed in to change notification settings - Fork 0
feat(lifecycle): wire emitter into AnalyticsClient + openURL API [3/4] #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
35d0583
eac8bb2
9615ddd
86d2c8d
70622bb
a1d6674
b7f1ece
df24f36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Void, Never>? | ||
| 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,13 +164,41 @@ 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() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Drain any residue from the previous background period BEFORE emitting | ||
| /// `Application Opened` so the foreground session starts with a clean queue. | ||
| /// `onForeground()` only enqueues the event — the just-started flush loop | ||
| /// ships it on the next tick, so backlog size doesn't delay emission. | ||
| 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() | ||
|
Comment on lines
+185
to
+188
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feel free to disregard, just looking at from a consistency POV. Any value to to having (EX: If there are a lot of events pilled up, wasn't sure if delaying
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I added a comment here clarifying that we are draining before Application Opened so that we start with a clean state / queue. |
||
| } | ||
| } | ||
|
|
||
| /// 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 +523,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") | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for the time being this method is a noop if there is no lifecycle coordinator and trackLifecycleEvents is false. |
||
| return | ||
| } | ||
| Task { | ||
| await coordinator.openURL(url, sourceApplication: sourceApplication) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. passing through proxy |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since. we are using the AppContext in event context and in the LifeCycleCoordinator wanted to expose here and read from both places. |
||
| ) { | ||
| 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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just cleaning up these closures as they were getting quite long