Skip to content

docs(lifecycle): add Lifecycle Events section to README [4/4]#45

Merged
choudlet merged 10 commits into
mainfrom
chrish/sc-38231/lifecycle-docs
May 11, 2026
Merged

docs(lifecycle): add Lifecycle Events section to README [4/4]#45
choudlet merged 10 commits into
mainfrom
chrish/sc-38231/lifecycle-docs

Conversation

@choudlet
Copy link
Copy Markdown
Collaborator

@choudlet choudlet commented Apr 27, 2026

Ticket

sc-38231 — iOS Lifecycle PR 4: documentation + structural cleanups
Parent: sc-36764
Base: #44 (slice 3) — review/merge that first

Summary

Slice 4 of 4 — README documentation. No behavior change. Adds a comprehensive "Lifecycle Events" section to the README covering the events feature, deep-link wiring, privacy guidance, and rationale for design decisions. Also fixes a pre-existing structural issue.

Depends on

  • #42, #43, #44 all merged so the README accurately describes shipped behavior.

What's in

New ## Lifecycle Events section

  • Table of the four events + their properties.
  • Cold-launch sequencing rules (Installed/Updated then Opened).
  • Resume vs. inactive semantics — only background → active emits Opened; Control Center / FaceID prompt / system alerts do not.
  • Opt-in framing with concrete trackLifecycleEvents: true example.
  • Deep-link wiring with full code snippets for:
    • UIScene cold launch (willConnectTo) + resume (openURLContexts)
    • UIApplicationDelegate.application(_:open:options:) for legacy single-scene apps
    • Universal Links via NSUserActivity.webpageURL
  • Buffer semantics — one-shot, last-write-wins, cleared on emit.
  • Privacy / URL-sanitization guidance with code sample (filter token / code / otp / secret query params before forwarding).
  • Rationale for not auto-instrumenting (swizzling conflicts with Firebase / Adjust / AppsFlyer / Branch, UIScene migration broke single-point swizzling, privacy footgun for auth-token URLs, host control matters for what to track).

Other README updates

  • TOC entry for the new section.
  • Feature bullet: 📲 Lifecycle Events (opt-in).
  • openURL(_:sourceApplication:) listed in Analytics Interface bullets.

Structural fix (pre-existing bug)

  • ### Event Queue Persistence was at h3 by accident (leftover from when it was nested under Identity Persistence). Promoted to ## Event Queue Persistence to match its TOC entry. Heading hierarchy now correct:
    • ## Identity Persistence
      • ### App Lifecycle Handling (about flush behavior, distinct from events)
    • ## Lifecycle Events (new)
    • ## Event Queue Persistence (was h3, now h2)

What's not in

  • Any new behavior or API.
  • Any code changes beyond the README.

Test plan

  • swift build clean (no source files touched).
  • swift test — all tests still pass (no test files touched).
  • README renders correctly on GitHub.

Stack

  1. #42 — storage + types foundation
  2. #43LifecycleEventEmitter actor + unit tests
  3. #44AnalyticsClient wiring + openURL public API + integration tests
  4. This PR — README documentation + h3→h2 fix

This stack supersedes #41, which will be closed after merge of all four.

@choudlet choudlet mentioned this pull request Apr 27, 2026
7 tasks
Slice 1 of 4 of the iOS lifecycle events feature (parent: sc-36764).

Adds the storage layer and shared types that the upcoming emitter / wiring
slices depend on. Pure additions — no behavior change to AnalyticsClient,
no events emitted yet.

- LifecycleStorage: UserDefaults wrapper for (version, build) under the
  metarouter:lifecycle:* namespace, separate from identity keys so reset()
  cannot wipe install/update state.
- IdentityStorage.hasAnyValue(): helper for the emitter (slice 2) to
  snapshot identity presence at construction time, before
  IdentityManager.initialize() auto-creates an anonymousId.
- AppContext: gains Equatable + fromBundle(_:) — single source of truth
  for app metadata, replacing the per-event Bundle.main.infoDictionary
  reads that DeviceContextProvider does today (consumed in slice 3).
- AppForegroundState enum: platform-neutral active/inactive/background
  trichotomy used by the emitter and coordinator.

Ticket: sc-38228
Parent: sc-36764
Stack: this PR -> sc-38229 -> sc-38230 -> sc-38231
@choudlet choudlet force-pushed the chrish/sc-38230/lifecycle-wiring-and-openurl branch from f7d52fe to eca2233 Compare April 27, 2026 18:35
@choudlet choudlet force-pushed the chrish/sc-38231/lifecycle-docs branch from f4c0a02 to f36c110 Compare April 27, 2026 18:36
Code review follow-up (sc-38228 M1).

The `clear()` method is a test-only seam, not part of the SDK's public
contract. Marking it `public` would expose a "wipe install/update state"
affordance to consumers that contradicts the entire rationale for the
`metarouter:lifecycle:*` namespace separation: nothing — not even
`reset()` — should be able to wipe this state.

`@testable import MetaRouter` already gives test code access to
`internal` symbols, so this is purely a tightening of the public surface
with no functional change.
Slice 2 of 4 of the iOS lifecycle events feature (parent: sc-36764).

Adds the lifecycle event emitter — the actor that owns install/update
detection, the cold-launch state machine, foreground/background
transitions, and the one-shot deep-link buffer. Standalone in this
slice; not yet wired into AnalyticsClient.

The emitter exposes four entrypoints:

- emitColdLaunchSequence(initialAppState:): decides Installed vs Updated
  vs no-op based on persisted (version, build) and identity-existed-
  before-init snapshot, then conditionally emits Application Opened
  with from_background:false (suppressed for background-launched
  processes; the next true foreground entry emits the deferred Opened
  via the cold-launch bridge).
- emitForegroundFromBackground(): handles bridge case, suppresses the
  first didBecomeActive during init, filters inactive→active, and emits
  Application Opened with from_background:true on real
  background→active transitions.
- emitBackgrounded(): updates lastTrackedAppState and emits Application
  Backgrounded.
- openURL(url:sourceApplication:): one-shot buffer (last-write-wins),
  consumed by the next emitOpened.

State machine flags (coldLaunchEmitted, coldLaunchSuppressed,
lastTrackedAppState, pendingDeepLink) are serialised through actor
isolation.

22 unit tests cover the install/update decision tree, cold-launch
sequencing, resume scenarios, deep-link buffer semantics, and double-
emit suppression.

Ticket: sc-38229
Parent: sc-36764
Stack: sc-38228 -> this PR -> sc-38230 -> sc-38231
Code review follow-ups (sc-38229).

- Guard emitBackgrounded against firing before the cold-launch sequence
  runs. Race scenario: process woken in .background, observer already
  registered, didEnterBackground arrives before the async
  initTask → onReady chain. Without the guard, we'd emit Backgrounded
  with no preceding Opened — a spec violation.
- Add log line on the cold-launch bridge emit (suppressed cold launch
  → first true foreground entry). This path is rare and notoriously
  hard to diagnose in field reports; one log line earns its keep.
- Add log line when openURL overwrites an existing pending deep link
  (last-write-wins), making field debugging of "why did Opened carry
  a different URL than I expected" tractable.
- Drop the unnecessary lastTrackedAppState = .active write on the
  pre-cold-launch suppressed branch. The cold-launch path is the
  canonical source of truth for this flag (set in
  emitColdLaunchSequence); writing it during the suppression window
  is a no-op at best and hides intent at worst.
- Add 5 missing test branches:
  - Cold launch with initialAppState == .inactive (distinct from
    .background) suppresses + bridges
  - Deep-link buffered before a suppressed cold launch survives the
    suppression and attaches to the bridge Opened
  - emitBackgrounded does NOT consume the deep-link buffer (one-shot
    per Application Opened, not per any-emit)
  - Same-bundle no-op cold launch still persists (version, build) to
    storage (regression guard against accidental skip)
  - Backgrounded before cold launch is suppressed (the new guard)

Test count goes from 438 -> 443. All passing.
Slice 3 of 4 of the iOS lifecycle events feature (parent: sc-36764).

Connects the emitter (slice 2) to AnalyticsClient via a new
LifecycleCoordinator and ships the public openURL deep-link API. After
this PR, the feature works end-to-end behind trackLifecycleEvents=true.
Default stays false so no existing customer is impacted on upgrade.

LifecycleCoordinator:
- Wraps the emitter, owns the cold-launch UIKit probe (single
  #if canImport(UIKit) block), exposes onForeground / onBackground /
  onReady / openURL.
- Single seam between the platform-notification observer and the
  emitter; UIApplication access stays out of AnalyticsClient.

AnalyticsClient integration:
- Snapshots AppContext.fromBundle() once at init; injects into both
  DeviceContextProvider (replacing per-event Bundle reads) and
  LifecycleEventEmitter.
- Conditionally constructs LifecycleCoordinator when
  trackLifecycleEvents == true.
- Named handleForeground() / handleBackground() async methods replace
  inline closures so the load-bearing emit-before-flush ordering rule
  lives next to the code it describes.
- coordinator.onReady() fires inside initTask after .ready, before
  drainDiskStoreToNetwork, so cold-launch events ship in the same drain.

Public API:
- openURL(_ url: URL, sourceApplication: String?) on AnalyticsInterface,
  AnalyticsClient, and AnalyticsProxy. First param positional —
  mirrors UIApplication.application(_:open:) and Segment's API.
- Logs Logger.warn when called while feature disabled (silent no-op was
  bad DX).
- AnalyticsProxy buffers openURL identical to other proxied methods.

Opt-in default:
- InitOptions.trackLifecycleEvents: Bool = false.
- InitOptionsTests cover default + explicit override.

Test seams:
- AnalyticsDependencies gains appContext, lifecycleStorage,
  identityStorage, initialAppState for integration test injection.
- AppLifecycleEventIntegrationTests drives a real AnalyticsClient via
  DI, posts UIApplication/NSApplication notifications through
  NotificationCenter, asserts events flow through queue + network.
  Covers: cold launch end-to-end, flag-disabled path emits zero events,
  reset() preserves lifecycle storage, re-init same version emits only
  Opened, background notification triggers Application Backgrounded,
  foreground after background emits Opened with from_background:true.

Ticket: sc-38230
Parent: sc-36764
Stack: sc-38228 -> sc-38229 -> this PR -> sc-38231
Code review follow-ups (sc-38230).

- Add openURL round-trip test through AnalyticsProxy: forward-when-bound
  and queued-before-bind-replayed-in-order paths. Closes the test gap
  the reviewer flagged where the new openURL Call enum case had no
  dedicated coverage even though the dispatch is mechanical.
- Add testOpenURLWithFeatureDisabledLogsWarning in
  AppLifecycleEventIntegrationTests. Verifies the Logger.warn line
  fires when openURL is called while trackLifecycleEvents=false, so a
  silent-no-op misconfiguration is diagnosable from logs.
- Hoist captureStderrAndStdout from InitOptionsTests (private) to
  TestHelpers (file-level). Add an async variant with a `settle`
  parameter for tests where the block under test fires fire-and-forget
  Tasks (the openURL flow does this — Task { ... } around the actor
  call).
- Track tempDir on Setup and remove it in deinit so per-test integration
  tmp dirs don't accumulate in /var/folders/.../T.

Sleep migration in integration tests deferred — would require adding
a non-draining count API to PersistentEventQueue, which is a production
surface change that the reviewer flagged as low priority. Tests pass
reliably as-is.

Test count goes 446 -> 454 (+5 emitter tests from slice 2's amend,
+2 proxy tests, +1 warn-capture test).
@choudlet choudlet force-pushed the chrish/sc-38230/lifecycle-wiring-and-openurl branch from eca2233 to eac8bb2 Compare April 27, 2026 22:03
Slice 4 of 4 of the iOS lifecycle events feature (parent: sc-36764).
No behavior change.

Adds a top-level "Lifecycle Events" section covering:

- Table of the four events + their properties
- Cold-launch sequencing rules (Installed/Updated then Opened)
- Resume vs inactive semantics — only background→active emits Opened;
  Control Center / FaceID prompt / system alerts do not
- Opt-in framing (trackLifecycleEvents: false default)
- Deep-link wiring snippets:
  - UIScene cold launch (willConnectTo) + resume (openURLContexts)
  - UIApplicationDelegate.application(_:open:options:) for legacy
    single-scene apps
  - Universal Links via NSUserActivity.webpageURL
- Buffer semantics: one-shot, last-write-wins, cleared on emit
- Privacy / URL-sanitization guidance with code sample
- Rationale for not auto-instrumenting (swizzling conflicts, UIScene
  migration, privacy footgun, host control)

Also fixes a pre-existing structural issue: ## Event Queue Persistence
was at h3 by accident (leftover from when it was nested under Identity
Persistence) — promoted to h2 to match its TOC entry. Adds the new
section to TOC + a feature bullet (📲 Lifecycle Events (opt-in)).

Ticket: sc-38231
Parent: sc-36764
Stack: sc-38228 -> sc-38229 -> sc-38230 -> this PR
Code review follow-ups (sc-38231).

Critical:
- import MetaRouterSwiftSDK -> import MetaRouter (the actual module
  name per Package.swift). The wrong import broke every deep-link
  snippet for copy-paste integrators.
- MetaRouter.Analytics.shared.openURL(...) ->
  MetaRouter.Analytics.client().openURL(...) — there is no `shared`
  static accessor; `client()` is the canonical entry point and is
  already used elsewhere in the README.

Important:
- Add cold-launch deep-link snippet for legacy UIApplicationDelegate
  via launchOptions[.url] / [.sourceApplication]. The protocol's own
  doc comment promises this works; without the snippet, legacy
  single-scene apps launched on a deep link silently lose attribution.
- Document that previous_version / previous_build can literally be
  the string "unknown" for the upgrade-from-pre-lifecycle population.
  Integrators building version-drift funnels need to know.
- Drop "the first time it's called" — Logger.warn fires on every
  openURL call when the feature is disabled, not just the first.
  Replaced with clearer wording about the diagnostic intent.

Minor:
- Privacy snippet: switched to `guard var components else { return url }`
  for clearer nil handling, expanded the deny-list with realistic
  auth-related keys, called out that the list is illustrative.
- Universal Links: noted that they carry no source-application
  identifier (they come from Safari/system) so always pass nil.
Copy link
Copy Markdown
Collaborator

@brandon-metarouter brandon-metarouter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only thing here is if you make any updates from MR1-3, re-audit the readme to push any updates here too.

@choudlet choudlet changed the base branch from chrish/sc-38230/lifecycle-wiring-and-openurl to main May 11, 2026 14:48
@choudlet choudlet merged commit 4ff5a96 into main May 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants