feat: app-lifecycle [ios]#41
Conversation
Adds the four standard lifecycle events (Application Installed/Updated/Opened/
Backgrounded), gated behind InitOptions.trackLifecycleEvents (default true).
Events flow through the standard track() path so they pick up enrichment,
identity, and dispatcher batching like any other event.
Cold launch compares persisted (version, build) — stored under
metarouter:lifecycle:* in UserDefaults, separate from identity keys so reset()
cannot wipe install/update state — to the current bundle to decide whether to
emit Installed, Updated, or neither, then emits Opened with from_background:false
when the process is in foreground at emit time.
Resume: only background → active emits Application Opened{from_background:true};
inactive → active transitions (Control Center, FaceID prompt, system alert) are
suppressed.
Background: Application Backgrounded is enqueued at the front of
onBackgroundAsync so it lands in the same flushToDisk() drain.
Adds AnalyticsInterface.handleDeepLink(url:sourceApplication:) — host calls it
from didFinishLaunchingWithOptions / SceneDelegate; buffered values attach to
the next Application Opened (one-shot).
Bumps SDK version to 1.5.0.
| var lifecycleStorage: LifecycleStorage? | ||
| var identityStorage: IdentityStorage? | ||
| var appVersionInfo: AppVersionInfo? | ||
| /// Override the initial app foreground state read at cold launch. |
There was a problem hiding this comment.
only for testing purposes
| @@ -85,21 +111,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl | |||
| } | |||
|
|
|||
| self.lifecycle = AppLifecycleObserver( | |||
There was a problem hiding this comment.
getting verbose and split these closures out into named methods, small refactor
|
|
||
| public func openURL(_ url: URL, sourceApplication: String?) { | ||
| guard let coordinator = lifecycleCoordinator else { | ||
| Logger.warn("openURL called but trackLifecycleEvents is disabled — ignoring") |
There was a problem hiding this comment.
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 .
| 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) |
There was a problem hiding this comment.
straight pass through once client is bound
|
|
||
| /// Reads `UIApplication.applicationState` on the main actor (it's main-actor | ||
| /// isolated) and maps to our platform-neutral `AppForegroundState`. | ||
| fileprivate func currentAppForegroundState() async -> AppForegroundState { |
There was a problem hiding this comment.
need to run this on MainActor as UIApplication.shared is @mainactor isolated.
| import Foundation | ||
|
|
||
| /// Process-level foreground state used to gate cold-launch and resume emits. | ||
| public enum AppForegroundState: Sendable, Equatable { |
There was a problem hiding this comment.
same value as UIApplication.state but not dependenant on UIKit (so this would work on macOS)
| // 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 appMetadata = deps.appMetadata ?? .fromBundle() |
There was a problem hiding this comment.
since we are reading this same appMetadata twice (one in per event context and the other for comparing version + build number for LifecycleEvents) I went consolidated the read from info bundle to one time in init and then utilize in both places.
|
Superseded by a stack of four smaller, reviewable PRs (~250–600 LOC each):
Same ticket (sc-36764); each slice has its own sub-story (sc-38228 → sc-38231) with scoped acceptance criteria. Stack each merges sequentially; feature stays gated by Closing this PR; the original branch will be deleted once all four sub-PRs merge. |
Ticket
sc-36764 — Application lifecycle events for iOS.
Summary
Adds the four standard lifecycle events to the iOS SDK, gated behind
InitOptions.trackLifecycleEvents(defaulttrue):Application Installedversion,buildApplication Updatedversion,build,previous_version,previous_buildApplication Openedfrom_background,version,build, optionalreferring_application, optionalurlApplication BackgroundedAll events flow through the standard
track()→ enrichment → dispatcher path.Cold launch
(version, build)to the current bundle to decide betweenInstalled,Updated, or no-op.(version, build)undermetarouter:lifecycle:*keys inUserDefaults.standard— namespace is independent of identity keys, soreset()cannot wipe install/update state.Application Updated{previous_*=unknown}to avoid a spurious install spike.Application Openedwithfrom_background: falseonly emits when the process is in foreground at emit time. Background-launched processes (silent push, background fetch) suppress; the next true foreground entry emits instead.Resume / background
background → activetransitions emitApplication Opened {from_background: true}.inactive → active(Control Center, FaceID prompt, system alert) is suppressed.Application Backgroundedis enqueued at the front ofonBackgroundAsyncso it lands in the sameflushToDisk()drain.didBecomeActiveafter launch is suppressed viacoldLaunchEmitted— the cold-launch path is the sole producer of the firstApplication Opened.Public API
InitOptions.trackLifecycleEvents: Bool = trueAnalyticsInterface.handleDeepLink(url: URL, sourceApplication: String?)— host calls fromapplication(_:didFinishLaunchingWithOptions:)/application(_:open:options:)/SceneDelegate. Buffered values attach to the nextApplication Opened(one-shot).Implementation notes
LifecycleEventEmitteractor owns install/update detection, the cold-launch flag, the deep-link buffer, and the three emit entrypoints. Concurrency is serialised through actor isolation.LifecycleStoragewraps the twoUserDefaultskeys.IdentityStorage.hasAnyValue()helper lets the emitter snapshot identity presence at construction time, beforeIdentityManager.initialize()auto-creates ananonymousId.Test plan
swift test— full suite passes (446 tests, 0 failures).swift test --filter Lifecycle— 28 lifecycle-specific tests pass acrossLifecycleStorageTests,LifecycleEventEmitterTests,AppLifecycleEventIntegrationTests.InitOptionsTestscovers the new flag default + explicit override.LifecycleStoragekeys surviveIdentityStorage.clear().inactive → activesuppression, double-emit suppression, deep-link buffer (one-shot), andtrackLifecycleEvents=false.AnalyticsClientvia dependency injection, postNSApplication.*Notifications throughNotificationCenter, and assert events flow through the queue/network path. Includesreset()not wiping lifecycle storage and re-init on same version emitting onlyOpened.Notes for reviewers
Application Updatedfor parity with the Android plan.UIApplication.*Notifications are observed; per-sceneUIScene.*Notificationtransitions are not tracked (documented limitation; revisit if a customer needs scene-level granularity).getDebugInfo()exposure of stored lifecycle version/build was deliberately not added — not in the acceptance criteria; can follow up if reviewers want it.