-
Notifications
You must be signed in to change notification settings - Fork 0
feat(lifecycle): LifecycleEventEmitter actor + unit tests [2/4] #43
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
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
31063e3
feat(lifecycle): storage + bundle metadata foundation
choudlet d8bb114
refactor(lifecycle): mark LifecycleStorage.clear() as internal
choudlet 62dd91a
feat(lifecycle): LifecycleEventEmitter actor + unit tests
choudlet 7052d4a
fix(lifecycle): emitter race guard + buffer log lines + missing tests
choudlet 94e79a3
fix: merge main
choudlet 7dd1e68
fix: review comments rename recordUrl
choudlet File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
208 changes: 208 additions & 0 deletions
208
Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| 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 { | ||
| Logger.log("Cold-launch Application Opened bridged from background") | ||
| 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. | ||
| // Don't mutate `lastTrackedAppState` here: the cold-launch path is the | ||
| // canonical source of truth for it (set in `emitColdLaunchSequence`). | ||
| 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()`. | ||
| /// | ||
| /// 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, | ||
| properties: nil | ||
| ) | ||
|
Comment on lines
+168
to
+171
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. Should this also use |
||
| 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. | ||
| /// Last-write-wins if called multiple times before the next Opened. | ||
| func recordOpenedURL(url: String, sourceApplication: String?) { | ||
| if pendingDeepLink != nil { | ||
| Logger.log("Pending deep link overwritten by newer URL") | ||
| } | ||
| 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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Good catch with this this case of no prev version / build + has anon id. 💪