Skip to content
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,23 @@ Calls to `track`, `identify`, etc. are **buffered in-memory** by the proxy and r
- On fatal config errors (`401/403/404`), the client enters **disabled** state and drops subsequent calls.
- `sentAt` is stamped when the batch is prepared for transmission (just before network send). If you need the original occurrence time, pass your own `timestamp` on each event.

### MetaRouter.Analytics.shared

Property-style accessor for the live proxy, matching Apple SDK convention (`URLSession.shared`, `UserDefaults.standard`, etc.). Returns the same proxy that `initialize(with:)` returns — call it from anywhere in your app once the SDK has been initialized:

```swift
// Initialize once at app launch
MetaRouter.Analytics.initialize(with: options)

// Use anywhere — no need to thread the proxy through your code
MetaRouter.Analytics.shared.track("Button Tapped")
MetaRouter.Analytics.shared.identify("user123")
```

`.shared` is safe to call before `initialize(with:)` — the proxy buffers calls until binding completes (same FIFO + replay-on-ready semantics described above). Use the proxy returned from `initialize(with:)` if you prefer dependency-injection style; both refer to the same underlying instance.

> **Note:** `MetaRouter.Analytics.client()` is deprecated as of this release; use `.shared` instead. Existing call sites will continue to work (with a yellow deprecation warning) until the next major version.

### Analytics Interface

The analytics client provides the following methods:
Expand Down
8 changes: 8 additions & 0 deletions Sources/MetaRouter/MetaRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ public enum MetaRouter {
}


/// Idiomatic singleton-style accessor matching Apple SDK conventions
/// (`URLSession.shared`, `UserDefaults.standard`, `FileManager.default`).
/// Returns the same buffered proxy `initialize(with:)` returns — calls
/// made before `initialize` are queued and replayed on bind.
public static var shared: AnalyticsInterface { proxy }

@available(*, deprecated, renamed: "shared",
message: "Use MetaRouter.Analytics.shared. client() will be removed in v2.0.")
public static func client() -> AnalyticsInterface { proxy }

public static func reset() {
Expand Down
88 changes: 72 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.
/// 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(
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() },
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.

just cleaning up these closures as they were getting quite long

onBackgroundAsync: { [weak self] in await self?.handleBackground() }
)

// Wire network monitor: set initial state and subscribe to changes
Expand Down Expand Up @@ -146,13 +164,41 @@ 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()
}
}
}

/// Drain any residue from the previous background period BEFORE emitting
/// `Application Opened` so the foreground session starts with a clean queue.
/// `onForeground()` only enqueues the event — the just-started flush loop
/// ships it on the next tick, so backlog size doesn't delay emission.
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()
Comment on lines +185 to +188
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Feel free to disregard, just looking at from a consistency POV. Any value to to having self.lifecycleCoordinator?.onForeground() before dispatcher flushes? If not, maybe have a comment similar to what you have for handleBackground to give context on flush then onForeground .

(EX: If there are a lot of events pilled up, wasn't sure if delaying onForeground would cause any issues)

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.

Yes I added a comment here clarifying that we are draining before Application Opened so that we start with a clean state / queue.

}
}

/// 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 +523,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.

for the time being this method is a noop if there is no lifecycle coordinator and trackLifecycleEvents is false.

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.

passing through proxy

}
}
}
47 changes: 34 additions & 13 deletions Sources/MetaRouter/analytics/CodableValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,36 +184,57 @@ extension CodableValue {
/// Convert any supported value to CodableValue
/// - Parameter value: The value to convert
/// - Returns: A CodableValue if the conversion was successful, nil otherwise
///
/// Container handling is tolerant: a single unsupported value inside a dict
/// or array drops only that key/element (with a warning logged) rather than
/// invalidating the entire container.
public static func from(_ value: Any) -> CodableValue? {
// Handle Optional<Any> first
if let unwrapped = Mirror(reflecting: value).unwrapOptional() {
return from(unwrapped)
// NSNull (common from JSONSerialization / Obj-C bridging) → null
if value is NSNull {
return .null
}

// Handle already converted values

// Optional<T>.none → null; Optional<T>.some(x) → recurse with x
if Mirror(reflecting: value).displayStyle == .optional {
if let unwrapped = Mirror(reflecting: value).unwrapOptional() {
return from(unwrapped)
}
return .null
}

// Already a CodableValue
if let codableValue = value as? CodableValue {
return codableValue
}

// Handle primitive types

switch value {
case let string as String: return .string(string)
case let int as Int: return .int(int)
case let double as Double: return .double(double)
case let float as Float: return .double(Double(float))
case let bool as Bool: return .bool(bool)
case let array as [Any]:
let converted = array.compactMap(from)
return converted.count == array.count ? .array(converted) : nil
var converted: [CodableValue] = []
converted.reserveCapacity(array.count)
for element in array {
if let cv = from(element) {
converted.append(cv)
} else {
Logger.warn("CodableValue: dropping unsupported array element of type \(type(of: element))")
}
}
return .array(converted)
case let dict as [String: Any]:
var converted: [String: CodableValue] = [:]
converted.reserveCapacity(dict.count) // Performance optimization
converted.reserveCapacity(dict.count)
for (key, val) in dict {
guard let codableVal = from(val) else { return nil }
converted[key] = codableVal
if let cv = from(val) {
converted[key] = cv
} else {
Logger.warn("CodableValue: dropping unsupported value for key '\(key)' of type \(type(of: val))")
}
}
return .object(converted)
case Optional<Any>.none: return .null
default: return nil
}
}
Expand Down
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
)
}
}
17 changes: 5 additions & 12 deletions Sources/MetaRouter/context/DeviceContextProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable {

private let contextActor = ContextActor()
private let library: LibraryContext
private let appContext: AppContext
private let advertisingIdActor = AdvertisingIdActor()

public init(
libraryName: String = "metarouter-ios-sdk",
libraryVersion: String = MetaRouterSDK.version
libraryVersion: String = MetaRouterSDK.version,
appContext: AppContext = .fromBundle()
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.

since. we are using the AppContext in event context and in the LifeCycleCoordinator wanted to expose here and read from both places.

) {
self.library = LibraryContext(name: libraryName, version: libraryVersion)
self.appContext = appContext
}

public func getContext() async -> EventContext {
Expand Down Expand Up @@ -76,17 +79,7 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable {
}

private func collectAppContext() async -> AppContext {
let bundle = Bundle.main
let info = bundle.infoDictionary ?? [:]

let name = (info["CFBundleDisplayName"] as? String)
?? (info["CFBundleName"] as? String)
?? "Unknown"
let version = info["CFBundleShortVersionString"] as? String ?? "unknown"
let build = info["CFBundleVersion"] as? String ?? "unknown"
let namespace = bundle.bundleIdentifier ?? "unknown"

return AppContext(name: name, version: version, build: build, namespace: namespace)
appContext
}

private func collectDeviceContext() async -> DeviceContext {
Expand Down
Loading