Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A lightweight iOS analytics SDK that transmits events to your MetaRouter cluster
- [Compatibility](#-compatibility)
- [Debugging](#debugging)
- [Identity Persistence](#identity-persistence)
- [Lifecycle Events](#lifecycle-events)
- [Event Queue Persistence](#event-queue-persistence)
- [Advertising ID (IDFA)](#advertising-id-idfa)
- [Using the alias() Method](#using-the-alias-method)
Expand Down Expand Up @@ -229,6 +230,7 @@ The analytics client provides the following methods:
- `enableDebugLogging()`: Enable debug logging
- `getDebugInfo() async`: Get current debug information
- `setTracing(_ enabled: Bool)`: Enable or disable tracing headers on API requests. When enabled, adds a `Trace: true` header to all outgoing events for backend debugging and diagnostics
- `openURL(_ url: URL, sourceApplication: String?)`: Forward an inbound deep-link URL so the next `Application Opened` event carries `url` and `referring_application` properties. Mirrors UIKit's `application(_:open:options:)` shape. See [Lifecycle Events](#lifecycle-events) for wiring

### Testing APIs

Expand Down Expand Up @@ -257,6 +259,7 @@ await MetaRouter.Analytics.resetAndWait()
- 💿 **Disk-Backed Queue**: Events survive app termination and are rehydrated on next launch
- 🔌 **Circuit Breaker**: Intelligent retry logic with exponential backoff
- ⚡ **Batching**: Automatic event batching for network efficiency
- 📲 **Lifecycle Events** (opt-in): Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with deep-link attribution support

## ✅ Compatibility

Expand Down Expand Up @@ -591,7 +594,143 @@ The SDK automatically handles app lifecycle events:
- **App Termination**: Best-effort disk snapshot (not guaranteed — process may exit before completion)
- **Identity Persistence**: Anonymous ID, user ID, group ID, and advertising ID are persisted across app launches

### Event Queue Persistence
## Lifecycle Events

When `trackLifecycleEvents` is enabled (default `false` — opt-in), the SDK automatically emits four canonical lifecycle events. They flow through the same enrichment + dispatch pipeline as any other event, so they pick up `anonymousId`, `userId`, `groupId`, device context, and timestamps.

### The Four Events

| Event | Fires when | Properties |
| ----- | ---------- | ---------- |
| `Application Installed` | First launch on a device — no prior identity, no prior `(version, build)` persisted | `version`, `build` |
| `Application Updated` | App `(version, build)` changed since last launch, **or** lifecycle tracking is being enabled for the first time on an existing user (no install spike for the upgraded population) | `version`, `build`, `previous_version`, `previous_build` |
| `Application Opened` | After cold launch (process foregrounded) and on every `background → active` resume | `from_background` (Bool), `version`, `build`, optional `url`, optional `referring_application` |
| `Application Backgrounded` | App enters background. Emitted **before** the dispatcher's flush-to-disk so the event is captured in the same drain | _(none)_ |

### Cold Launch Sequencing

On a cold launch with `trackLifecycleEvents: true`, the SDK emits in this order once initialization completes:

1. `Application Installed` **or** `Application Updated` (or neither, if version/build hasn't changed)
2. `Application Opened` with `from_background: false`

If the process was woken in the background (silent push, background fetch, location update), the cold-launch `Application Opened` is **suppressed**. The next true `background → active` transition emits `Application Opened` with `from_background: false` as the cold-launch bridge.

### Resume vs. inactive

Only `background → active` transitions emit `Application Opened`. Brief `inactive` states (Control Center, FaceID prompt, system alert) do **not** emit — they're not real foregrounds.

### Enabling

Lifecycle tracking is **opt-in** — set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted and `openURL` is a no-op (logs a debug warning the first time it's called).

```swift
let options = InitOptions(
writeKey: "YOUR_WRITE_KEY",
ingestionHost: "https://your-ingestion-host.com",
trackLifecycleEvents: true
)
```

### Deep Link Attribution

Forward inbound deep-link URLs to the SDK so the next `Application Opened` event carries `url` and `referring_application` properties. This is a one-shot buffer — the next Opened consumes and clears it.

**`UIScene` (iOS 13+, recommended):**

```swift
import UIKit
import MetaRouterSwiftSDK

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {

// Cold-launch deep link arrives here in connectionOptions
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {

if let urlContext = connectionOptions.urlContexts.first {
MetaRouter.Analytics.shared.openURL(
urlContext.url,
sourceApplication: urlContext.options.sourceApplication
)
}
}

// Resume deep link arrives here on background → active
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let urlContext = URLContexts.first else { return }
MetaRouter.Analytics.shared.openURL(
urlContext.url,
sourceApplication: urlContext.options.sourceApplication
)
}
}
```

**`UIApplicationDelegate` (legacy, single-scene apps):**

```swift
import UIKit
import MetaRouterSwiftSDK

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
MetaRouter.Analytics.shared.openURL(
url,
sourceApplication: options[.sourceApplication] as? String
)
return true
}
}
```

**Universal Links** are delivered through `NSUserActivity`, not the `openURL` callback. Pull the `webpageURL` out yourself and forward it the same way:

```swift
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
MetaRouter.Analytics.shared.openURL(url, sourceApplication: nil)
}
```

#### Buffer Semantics

`openURL` stores **one** URL until the next `Application Opened` emits. Practical implications:

- **Calling twice before an Opened**: only the most recent URL is attached. No queue.
- **Calling without an Opened ever firing**: the URL sits in the buffer until the next Opened, whenever that is.
- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `openURL` is called again.

This shape matches how iOS delivers URLs — at one moment, correlated with one Opened.

#### Privacy

Deep-link URLs frequently carry sensitive data — auth tokens, password reset codes, magic-link secrets, OTPs. **The host app is responsible for sanitizing URLs before forwarding them.** Strip query parameters that shouldn't leave the device:

```swift
func sanitized(_ url: URL) -> URL {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = components?.queryItems?.filter { item in
!["token", "code", "otp", "secret"].contains(item.name.lowercased())
}
return components?.url ?? url
}

MetaRouter.Analytics.shared.openURL(
sanitized(incomingURL),
sourceApplication: nil
)
```

The SDK does not auto-instrument deep-link capture (no method swizzling, no `UIApplicationDelegate` proxy). Manual forwarding keeps integration explicit, avoids conflicts with other SDKs that swizzle (Firebase, Adjust, AppsFlyer, Branch), and gives the host control over what data is captured.

## Event Queue Persistence

Unsent events are automatically persisted to disk and recovered across app launches. This prevents event loss when the app is backgrounded, terminated, or encounters network issues.

Expand Down
84 changes: 68 additions & 16 deletions Sources/MetaRouter/analytics/AnalyticsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

only for testing purposes

/// Tests pass `.active` / `.background` directly to skip the UIKit probe.
var initialAppState: AppForegroundState?

static let production = AnalyticsDependencies()
}
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -85,21 +116,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl
}

self.lifecycle = AppLifecycleObserver(
Copy link
Copy Markdown
Collaborator Author

@choudlet choudlet Apr 27, 2026

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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)
}
}
}
9 changes: 9 additions & 0 deletions Sources/MetaRouter/analytics/AnalyticsInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,13 @@ public protocol AnalyticsInterface: AnyObject, Sendable {
func setAdvertisingId(_ advertisingId: String?)
func clearAdvertisingId()
func setTracing(_ enabled: Bool)

/// Tells the SDK the app is opening with this URL. Buffers the URL to be
/// attached to the next `Application Opened` event as the `url` (and
/// optionally `referring_application`) property. Call from
/// `application(_:open:options:)` or `scene(_:openURLContexts:)`, and from
/// `application(_:didFinishLaunchingWithOptions:)` for cold-launch capture
/// using `launchOptions[.url]` / `[.sourceApplication]`. One-shot — cleared
/// after the next emit.
func openURL(_ url: URL, sourceApplication: String?)
}
6 changes: 6 additions & 0 deletions Sources/MetaRouter/analytics/AnalyticsProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -128,6 +132,7 @@ private enum Call {
case setAdvertisingId(String?)
case clearAdvertisingId
case setTracing(Bool)
case openURL(URL, String?)
}

private actor ProxyState {
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

straight pass through once client is bound

}
}
}
11 changes: 8 additions & 3 deletions Sources/MetaRouter/analytics/InitOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ public struct InitOptions: Sendable {
public let debug: Bool
public let maxQueueEvents: Int
public let maxDiskEvents: Int
public let trackLifecycleEvents: Bool

public init(
writeKey: String,
ingestionHost: URL,
flushIntervalSeconds: Int = 10,
debug: Bool = false,
maxQueueEvents: Int = 2000,
maxDiskEvents: Int = 10000
maxDiskEvents: Int = 10000,
trackLifecycleEvents: Bool = false
) {
precondition(!writeKey.isEmpty, "writeKey must not be empty")

Expand All @@ -30,6 +32,7 @@ public struct InitOptions: Sendable {
self.debug = debug
self.maxQueueEvents = max(1, maxQueueEvents)
self.maxDiskEvents = maxDiskEvents
self.trackLifecycleEvents = trackLifecycleEvents

if self.maxDiskEvents > 0 && self.maxDiskEvents < self.maxQueueEvents {
Logger.warn("maxDiskEvents (\(self.maxDiskEvents)) is less than maxQueueEvents (\(self.maxQueueEvents)) — memory can hold more events than disk can preserve; events may be dropped during background flush")
Expand All @@ -44,7 +47,8 @@ extension InitOptions {
flushIntervalSeconds: Int = 10,
debug: Bool = false,
maxQueueEvents: Int = 2000,
maxDiskEvents: Int = 10000
maxDiskEvents: Int = 10000,
trackLifecycleEvents: Bool = false
) {
var host = ingestionHost.trimmingCharacters(in: .whitespacesAndNewlines)
if host.hasSuffix("/") {
Expand All @@ -59,7 +63,8 @@ extension InitOptions {
flushIntervalSeconds: flushIntervalSeconds,
debug: debug,
maxQueueEvents: maxQueueEvents,
maxDiskEvents: maxDiskEvents
maxDiskEvents: maxDiskEvents,
trackLifecycleEvents: trackLifecycleEvents
)
}
}
Loading
Loading