-
Notifications
You must be signed in to change notification settings - Fork 0
feat: app-lifecycle [ios] #41
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
bbc1bb2
c1c2d27
5b5a1d8
a72533a
d390a34
2d9932f
1cf758d
6b55546
05e5e98
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( | ||
|
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. getting verbose and split these closures out into named methods, small refactor |
||
| 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,37 @@ 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() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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") | ||
|
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. currently we are only using this openURL to enrich Application Opened events when trackLifecycleEvents is true. In the future we will add additional methods to more robustly handle deep links. Most notable here would be setting campaignvalues and enriching all events with that ad or campaign level information . |
||
| 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. straight pass through once client is bound |
||
| } | ||
| } | ||
| } | ||
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.
only for testing purposes