A lightweight iOS analytics SDK that transmits events to your MetaRouter cluster.
- Installation
- Usage
- API Reference
- Features
- Compatibility
- Debugging
- Identity Persistence
- Lifecycle Events
- Event Queue Persistence
- Advertising ID (IDFA)
- Using the alias() Method
- License
Add the following dependency to your Package.swift:
.package(url: "https://github.com/metarouterio/ios-sdk.git", from: "1.5.0")Or add it via Xcode: File → Add Package Dependencies → Enter repository URL
import MetaRouter
// Initialize the analytics client
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com",
debug: true, // Optional: enable debug mode
flushIntervalSeconds: 30, // Optional: flush events every 30 seconds
maxQueueEvents: 2000 // Optional: max events in memory queue
)
let analytics = MetaRouter.Analytics.initialize(with: options)import SwiftUI
import MetaRouter
@main
struct MyApp: App {
@StateObject private var analyticsManager = AnalyticsManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(analyticsManager)
.onAppear {
analyticsManager.initialize()
}
}
}
}
class AnalyticsManager: ObservableObject {
private var analytics: AnalyticsInterface?
func initialize() {
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com"
)
analytics = MetaRouter.Analytics.initialize(with: options)
}
func track(_ event: String, properties: [String: Any]? = nil) {
analytics?.track(event, properties: properties)
}
func identify(_ userId: String, traits: [String: Any]? = nil) {
analytics?.identify(userId, traits: traits)
}
func screen(_ name: String, properties: [String: Any]? = nil) {
analytics?.screen(name, properties: properties)
}
}
// Use analytics in any view
struct ContentView: View {
@EnvironmentObject var analyticsManager: AnalyticsManager
var body: some View {
Button("Submit") {
analyticsManager.track("Button Pressed", properties: [
"buttonName": "submit",
"timestamp": Date().timeIntervalSince1970
])
}
}
}import UIKit
import MetaRouter
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var analytics: AnalyticsInterface!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initialize the analytics client
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com",
debug: true
)
analytics = MetaRouter.Analytics.initialize(with: options)
return true
}
}import MetaRouter
// Initialize the client (optionally await it), but you can use it at any time
// with events transmitted when the client is ready.
let analytics = MetaRouter.Analytics.initialize(with: options)
// Track events
analytics.track("User Action", properties: [
"action": "button_click",
"screen": "home"
])
// Identify users
analytics.identify("user123", traits: [
"name": "John Doe",
"email": "john@example.com"
])
// Track screen views
analytics.screen("Home Screen", properties: [
"category": "navigation"
])
// Track page views
analytics.page("Home Page", properties: [
"url": "/home",
"referrer": "/landing"
])
// Group users
analytics.group("company123", traits: [
"name": "Acme Corp",
"industry": "technology"
])
// Flush events immediately
analytics.flush()
// Reset analytics (useful for testing or logout)
analytics.reset()Initializes the analytics client and returns a live proxy to the client instance.
initialize() returns immediately, but you do not need to wait before using analytics methods.
Calls to track, identify, etc. are buffered in-memory by the proxy and replayed in order once the client is fully initialized.
Options:
writeKey(String, required): Your write keyingestionHost(String or URL, required): Your MetaRouter ingestor hostdebug(Bool, optional): Enable debug modeflushIntervalSeconds(Int, optional): Interval in seconds to flush events (default10)maxQueueEvents(Int, optional): Maximum events buffered in the in-memory queue (default2000, must be > 0)maxDiskEvents(Int, optional): Maximum events retained on disk across crash-safety snapshots and extended-offline overflow (default10000). Set to0to disable disk persistence entirely — the SDK then runs as a purely in-memory pipeline (oldest events dropped when memory is full; nothing recovered across app launches).
Proxy behavior (quick notes):
- The proxy buffer is in-memory only (not persisted). Calls made before the client is ready are lost if the process exits. Once the client is initialized, events in the main queue are disk-backed (see Event Queue Persistence).
- Ordering is preserved relative to other buffered calls; normal FIFO + batching applies after ready.
- On fatal config errors (
401/403/404), the client enters disabled state and drops subsequent calls. sentAtis stamped when the batch is prepared for transmission (just before network send). If you need the original occurrence time, pass your owntimestampon each event.
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:
// 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.sharedinstead. Existing call sites will continue to work (with a yellow deprecation warning) until the next major version.
The analytics client provides the following methods:
track(_ event: String, properties: [String: Any]?): Track custom eventsidentify(_ userId: String, traits: [String: Any]?): Identify usersgroup(_ groupId: String, traits: [String: Any]?): Group usersscreen(_ name: String, properties: [String: Any]?): Track screen viewspage(_ name: String, properties: [String: Any]?): Track page viewsalias(_ newUserId: String): Connect anonymous users to known user IDs. See Using the alias() Method for detailssetAdvertisingId(_ advertisingId: String?): Set the advertising identifier (IDFA) for ad tracking. See Advertising ID section for usage and compliance requirementsclearAdvertisingId(): Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad trackinggetAnonymousId() async -> String: Retrieve the device's anonymous ID. Awaits initialization internally so the returned value is always valid — never nil or emptyflush(): Flush events immediatelyreset(): Reset analytics state and clear all stored data (includes clearing advertising ID)enableDebugLogging(): Enable debug logginggetDebugInfo() async: Get current debug informationsetTracing(_ enabled: Bool): Enable or disable tracing headers on API requests. When enabled, adds aTrace: trueheader to all outgoing events for backend debugging and diagnosticsrecordOpenedURL(_ url: URL, sourceApplication: String?): Forward an inbound deep-link URL so the nextApplication Openedevent carriesurlandreferring_applicationproperties. Mirrors UIKit'sapplication(_:open:options:)shape. See Lifecycle Events for wiring
For tests that require synchronous initialization:
// Wait for initialization to complete
let analytics = await MetaRouter.Analytics.initializeAndWait(with: options)
// Wait for reset to complete
await MetaRouter.Analytics.resetAndWait()- 🎯 Custom Endpoints: Send events to your own ingestion endpoints
- 📱 iOS & macOS: Native Swift SDK for Apple platforms
- 🔧 Type-Safe: Full Swift type safety with automatic
Anyconversion - 🚀 Lightweight: Minimal overhead and zero external dependencies
- 🧵 Thread-Safe: Built on Swift actors and concurrency
- 🔄 Reset Capability: Easily reset analytics state for testing or logout scenarios
- 🐛 Debug Support: Built-in debugging tools for troubleshooting
- 💾 Persistent Identity: Anonymous ID and user identity stored in UserDefaults
- 💿 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/Backgroundedtracking with deep-link attribution support
| Component | Supported Versions |
|---|---|
| iOS | >= 15.0 |
| macOS | >= 12.0 |
| Swift | >= 5.5 |
| Xcode | >= 13.0 |
If you're not seeing API calls being made, here are some steps to troubleshoot:
// Initialize with debug enabled
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com",
debug: true // This enables detailed logging
)
let analytics = MetaRouter.Analytics.initialize(with: options)
// Or enable debug logging after initialization
analytics.enableDebugLogging()// Get current state information
let debugInfo = await analytics.getDebugInfo()
print("Analytics debug info:", debugInfo)
// Debug info includes:
// - lifecycle: Current SDK state (idle/initializing/ready/resetting/disabled)
// - queueLength: Number of events waiting to be sent
// - writeKey: Masked write key (last 4 chars)
// - ingestionHost: Your ingestion endpoint
// - flushIntervalSeconds: Flush interval configuration
// - maxQueueEvents: Queue capacity
// - circuitState: Circuit breaker state (closed/halfOpen/open)
// - circuitRemainingMs: Cooldown time remaining
// - flushInFlight: Whether a flush is currently in progress
// - anonymousId: Device anonymous ID (if available)
// - userId: Current user ID (if identified)
// - groupId: Current group ID (if grouped)
// - advertisingId: Current advertising ID (if set)When working with MetaRouter support or debugging event delivery issues, you can enable tracing to add a Trace: true header to all API requests:
// Enable tracing for detailed backend diagnostics
analytics.setTracing(true)
// Send events - they'll now include the Trace header
analytics.track("Debug Event", properties: ["test": true])
// Disable tracing when done
analytics.setTracing(false)This helps backend engineers trace your events through the ingestion pipeline and identify delivery issues.
// Manually flush events to see if they're being sent
analytics.flush()- Network Permissions: Ensure your app has network permissions in Info.plist
- UserDefaults: The SDK uses UserDefaults for identity persistence (anonymousId, userId, groupId, advertisingId)
- Endpoint URL: Verify your ingestion endpoint is correct and accessible
- Write Key: Ensure your write key is valid
Queue capacity: The SDK keeps up to 2,000 events in memory. When the cap is reached, the oldest events are dropped first (drop-oldest). You can change this via maxQueueEvents in InitOptions.
This SDK uses a circuit breaker around network I/O. It keeps ordering stable, avoids tight retry loops, and backs off cleanly when your cluster is unhealthy or throttling.
Queueing during backoff: While the breaker is OPEN, new events are accepted and appended to the in-memory queue; nothing is sent until the cooldown elapses.
Ordering (FIFO): If a batch fails with a retryable error, that batch is requeued at the front (original order preserved). New events go to the tail. After cooldown, we try again; on success we continue draining in order.
Half-open probe: After cooldown, one probe is allowed. Success → breaker CLOSED (keep flushing). Failure → breaker OPEN again with longer cooldown.
sentAt semantics: sentAt is stamped when the batch is prepared for transmission. If the client is backing off, the actual transmit may be later; sentAt reflects when the batch was assembled for sending.
| Status / Failure | Action | Breaker | Queue effect |
|---|---|---|---|
2xx |
Success | close | Batch removed |
5xx |
Retry: requeue front, schedule after cooldown | open↑ | Requeued (front) |
408 (timeout) |
Retry: requeue front, schedule after cooldown | open↑ | Requeued (front) |
429 (throttle) |
Retry: requeue front, wait = max(Retry-After, breaker, 1000ms) |
open↑ | Requeued (front) |
413 (payload too large) |
Halve maxBatchSize; requeue and retry; if already 1, drop. On subsequent 2xx, batch size recovers (maxBatchSize * 2 up to initialMaxBatchSize). |
close | Requeued or dropped (size=1) |
400, 422, other non-fatal 4xx |
Drop bad batch, continue | close | Dropped |
401, 403, 404 |
Disable client (stop timers), clear queue | close | Cleared |
| Network error / Timeout | Retry: requeue front, schedule after cooldown | open↑ | Requeued (front) |
| Reset during flush | Do not requeue in-flight chunk; drop it | — | Dropped |
Defaults: failureThreshold=3, cooldownMs=10s, maxCooldownMs=120s, jitter=±20%, halfOpenMaxConcurrent=1.
Identifiers:
anonymousIdis a stable, persisted UUID for the device/user before identify; it does not include timestamps.messageIdis generated as<epochMillis>-<uuid>(e.g.,1734691572843-6f0c7e85-...) to aid debugging.
The MetaRouter iOS SDK automatically manages and persists user identifiers across app sessions using UserDefaults. This ensures consistent user tracking even after app restarts.
The userId is set when you identify a user and represents their unique identifier in your system (e.g., database ID, email, employee ID).
How to set:
analytics.identify("user123", traits: [
"name": "John Doe",
"email": "john@example.com",
"role": "Sales Associate"
])Behavior:
- Persisted to UserDefaults (key:
metarouter:user_id) - Automatically loaded on app restart
- Automatically included in all subsequent events (
track,page,screen,group) - Remains set until
reset()is called or app is uninstalled
Example flow:
// Day 1: User logs in
analytics.identify("employeeID", traits: ["name": "Jane"])
analytics.track("Product Viewed", properties: ["sku": "ABC123"])
// Event includes: userId: "employeeID"
// App restarts...
// Day 2: User opens app
analytics.track("App Opened")
// Event STILL includes: userId: "employeeID" (auto-loaded from storage)The anonymousId is a unique identifier automatically generated for each device/installation before a user is identified.
How it's set:
- Automatically generated as a UUID on first SDK initialization
- No manual action required
Behavior:
- Persisted to UserDefaults (key:
metarouter:anonymous_id) - Automatically loaded on app restart
- Automatically included in all events
- Remains stable across app sessions until
reset()is called - Cleared on
reset()and a new UUID is generated on next initialization
Use case:
Track user behavior before they log in or create an account, then connect pre-login and post-login activity using the alias() method.
The groupId associates a user with an organization, team, account, or other group entity.
How to set:
analytics.group("company123", traits: [
"name": "Acme Corp",
"plan": "Enterprise",
"industry": "Technology"
])Behavior:
- Persisted to UserDefaults (key:
metarouter:group_id) - Automatically loaded on app restart
- Automatically included in all subsequent events after being set
- Remains set until
reset()is called
Example use case:
// User logs into their company account
analytics.identify("user123", traits: ["name": "Jane"])
analytics.group("acme-corp", traits: ["name": "Acme Corp"])
// All future events include both userId and groupId
analytics.track("Report Generated")
// Event includes: userId: "user123", groupId: "acme-corp"The advertisingId is used for ad tracking and attribution (IDFA on iOS). See the Advertising ID section below for detailed usage and compliance requirements.
| Field | Set By | Storage Key | Auto-Attached | Cleared By |
|---|---|---|---|---|
| userId | identify(userId) |
metarouter:user_id |
All events | reset() |
| anonymousId | Auto-generated (UUID) | metarouter:anonymous_id |
All events | reset() (new ID generated on init) |
| groupId | group(groupId) |
metarouter:group_id |
All events after set | reset() |
| advertisingId | setAdvertisingId(id) |
metarouter:advertising_id |
Event context | clearAdvertisingId(), reset() |
Every event you send (track, page, screen, group) is automatically enriched with persisted identity information:
// You call:
analytics.track("Button Clicked", properties: ["buttonName": "Submit"])
// SDK automatically adds:
{
"type": "track",
"event": "Button Clicked",
"properties": { "buttonName": "Submit" },
"userId": "employeeID", // ← Auto-added from storage
"anonymousId": "a1b2c3d4-...", // ← Auto-added from storage
"groupId": "company123", // ← Auto-added from storage (if set)
"timestamp": "2025-10-23T...",
"context": {
"device": {
"advertisingId": "..." // ← Auto-added from storage (if set)
}
}
}Call reset() to clear all identity data, typically when a user logs out:
analytics.reset()What reset() does:
- Clears
userId,anonymousId,groupId, andadvertisingIdfrom memory - Removes all identity fields from UserDefaults
- Stops background flush loops
- Clears event queue
- Next initialization will generate a new
anonymousId
Common logout flow:
// User logs out
analytics.reset()
// User is now tracked with a new anonymousId (auto-generated on next event)
// No userId or groupId until they log in again- On Login: Call
identify()immediately after successful authentication - On Logout: Call
reset()to clear user identity - Cross-Session Tracking: The SDK handles this automatically - no action needed
- Group Associations: Set
groupIdafter determining the user's organization/team - Pre-Login Tracking: Events are tracked with
anonymousIdbefore login - Connecting Sessions: Use
alias()to connect pre-login and post-login activity
// App starts - SDK initializes
let analytics = MetaRouter.Analytics.initialize(with: options)
// anonymousId: "abc-123" (auto-generated and persisted)
// User browses before login
analytics.track("Product Viewed", properties: ["sku": "XYZ"])
// Includes: anonymousId: "abc-123"
// User logs in
analytics.identify("user456", traits: ["name": "John", "email": "john@example.com"])
// userId: "user456" is now persisted
// User performs actions
analytics.track("Added to Cart", properties: ["sku": "XYZ"])
// Includes: userId: "user456", anonymousId: "abc-123"
// App closes and reopens...
// SDK auto-loads userId from storage
analytics.track("App Reopened")
// STILL includes: userId: "user456", anonymousId: "abc-123"
// User logs out
analytics.reset()
// All IDs cleared, new anonymousId will be generated on next initAll identity data is stored in UserDefaults, which provides:
- Persistent storage across app sessions
- Automatic data encryption on iOS (Keychain-backed when using appropriate data protection classes)
- Secure local storage
- Cleared only on app uninstall or explicit
reset()call
The SDK automatically handles app lifecycle events:
- App Foreground: Starts periodic flush loop and immediately flushes any queued events
- App Background: Attempts a network flush first, then snapshots any remaining events to disk, stops flush loop, and cancels any scheduled retries
- 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
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.
| 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. Note: for the upgrade-from-pre-lifecycle case, previous_version and previous_build are emitted as the literal string "unknown" since the SDK had no prior persisted values. |
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) |
On a cold launch with trackLifecycleEvents: true, the SDK emits in this order once initialization completes:
Application InstalledorApplication Updated(or neither, if version/build hasn't changed)Application Openedwithfrom_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.
Only background → active transitions emit Application Opened. Brief inactive states (Control Center, FaceID prompt, system alert) do not emit — they're not real foregrounds.
Lifecycle tracking is opt-in — set trackLifecycleEvents: true in InitOptions to turn it on. When disabled (the default), no lifecycle events are emitted; calls to recordOpenedURL are silent no-ops for event emission but log a debug warning so misconfiguration ("I'm calling recordOpenedURL but no events fire!") is diagnosable from logs.
let options = InitOptions(
writeKey: "YOUR_WRITE_KEY",
ingestionHost: "https://your-ingestion-host.com",
trackLifecycleEvents: true
)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):
import UIKit
import MetaRouter
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.recordOpenedURL(
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.recordOpenedURL(
urlContext.url,
sourceApplication: urlContext.options.sourceApplication
)
}
}UIApplicationDelegate (legacy, single-scene apps):
For apps that launch on a deep link in the legacy single-scene model, the URL is delivered through launchOptions in application(_:didFinishLaunchingWithOptions:), not through application(_:open:options:). Forward both:
import UIKit
import MetaRouter
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
// Cold-launch deep link arrives here via launchOptions
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if let url = launchOptions?[.url] as? URL {
MetaRouter.Analytics.shared.recordOpenedURL(
url,
sourceApplication: launchOptions?[.sourceApplication] as? String
)
}
return true
}
// Resume deep link arrives here on background → active
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
MetaRouter.Analytics.shared.recordOpenedURL(
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. Universal Links carry no source-application identifier (they originate from Safari or another system handler), so always pass sourceApplication: nil.
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
MetaRouter.Analytics.shared.recordOpenedURL(url, sourceApplication: nil)
}recordOpenedURL 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
recordOpenedURLis called again.
This shape matches how iOS delivers URLs — at one moment, correlated with one Opened.
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:
func sanitized(_ url: URL) -> URL {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
// Replace with your project's actual deny-list. This is illustrative.
let denied: Set<String> = ["token", "code", "otp", "secret",
"access_token", "id_token", "auth", "key", "password"]
components.queryItems = components.queryItems?.filter { !denied.contains($0.name.lowercased()) }
return components.url ?? url
}
MetaRouter.Analytics.shared.recordOpenedURL(
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.
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.
How it works:
- Events are queued in memory during normal operation (no disk I/O on the hot path)
- The in-memory queue is snapshotted to disk when:
- The app enters background (after attempting a network flush)
- The app is about to terminate (best-effort)
- The in-memory queue crosses a configurable flush threshold
- On next launch, events are rehydrated from disk back into memory and sent normally
- Events older than 7 days are dropped during rehydration to prevent stale data from being sent after extended offline periods
- The disk snapshot file is deleted after a successful rehydration to prevent stale reads
Storage details:
- Location:
~/Library/Application Support/metarouter/disk-queue/queue.v1.json - Excluded from iCloud backup
- Atomic writes (no partial corruption)
- Resilient decoding: individual corrupt events are skipped rather than losing the entire snapshot
Disk cap (maxDiskEvents):
- Default
10000. When the cap is exceeded, the oldest events on disk are dropped first (FIFO). - Negative values are rejected.
- Set
maxDiskEvents: 0to disable disk persistence entirely. The SDK then runs as a purely in-memory pipeline:- No background flush to disk (events in memory at app-background or app-kill are lost)
- No overflow writes while offline (memory cap is the only buffer)
- No recovery across app launches
maxQueueEventsstill applies — when the in-memory queue is full, the oldest event is dropped to make room (ring buffer)- On retry-after-failure, requeued events are inserted at the front; if that overflows the cap, the newest entries are dropped from the back so the retry events survive
- Disabling persistence is appropriate for apps that never want events to survive a process restart (e.g. strict privacy requirements or short-lived sessions). In all other cases, the default is recommended.
sentAt semantics: sentAt is stamped when a batch is prepared for transmission (just before network send), not when the event was originally created. Events rehydrated from disk receive a fresh sentAt on their next send attempt. If you need the original occurrence time, rely on the timestamp field set at event creation.
The alias() method connects an anonymous user (tracked by anonymousId) to a known user ID. It's used to link pre-login activity to post-login identity.
Use alias() when a user signs up or logs in for the first time, and you want to connect their pre-login browsing activity to their new account.
Primary use case: Connecting anonymous browsing sessions to newly created user accounts.
analytics.alias(newUserId)This does two things:
- Sets the new
userId(same asidentify()) - Sends an
aliasevent to your analytics backend, telling it: "This anonymousId and this userId are the same person"
// App starts - user is anonymous
let analytics = MetaRouter.Analytics.initialize(with: options)
// anonymousId: "abc-123" (auto-generated)
// User browses anonymously
analytics.track("Product Viewed", properties: ["productId": "XYZ"])
analytics.track("Add to Cart", properties: ["productId": "XYZ"])
// Both events tracked with anonymousId: "abc-123"
// User creates an account / signs up
analytics.alias("user-456")
// Sends alias event connecting: anonymousId "abc-123" → userId "user-456"
// Optionally add user traits
analytics.identify("user-456", traits: [
"name": "John Doe",
"email": "john@example.com"
])
// Future events now tracked as authenticated user
analytics.track("Purchase Complete", properties: ["orderId": "789"])
// Event includes: userId: "user-456", anonymousId: "abc-123"| Method | When to Use | What It Does |
|---|---|---|
alias() |
First-time sign-up/login when connecting anonymous activity | Sets userId + sends alias event to link anonymousId → userId |
identify() |
Subsequent logins or updating user traits | Sets userId + sends identify event with user traits |
- First-time sign-up: Call
alias()to connect anonymous activity to the new account - Subsequent logins: Use
identify()- no need to alias again - Backend support: Ensure your analytics backend supports alias events for merging user profiles
- One-time operation: You typically only need
alias()once per user - when they first create an account
// Day 1: Anonymous browsing
analytics.track("App Opened")
analytics.track("Product Viewed", properties: ["sku": "SHOE-123"])
analytics.track("Product Viewed", properties: ["sku": "SHIRT-456"])
// All tracked with anonymousId: "anon-xyz"
// User signs up
analytics.alias("user-789")
analytics.identify("user-789", traits: [
"name": "Jane Doe",
"email": "jane@example.com"
])
// User continues shopping (now authenticated)
analytics.track("Added to Cart", properties: ["sku": "SHIRT-456"])
analytics.track("Purchase", properties: ["total": 49.99])
// Your analytics platform can now show the complete customer journey:
// - Pre-signup activity (anonymous product views)
// - Post-signup activity (cart additions, purchase)
// - Full conversion funnel from anonymous → identified → convertedThe SDK supports including advertising identifiers (IDFA - Identifier for Advertisers) in event context for ad tracking and attribution purposes.
The MetaRouter SDK supports including the IDFA in your analytics events for ad tracking and attribution purposes. This is useful for marketing analytics, ad campaign measurement, and user acquisition tracking.
- iOS 14.5+: App Tracking Transparency (ATT) is required
- Info.plist: Add
NSUserTrackingUsageDescriptionto explain why you need tracking permission - Frameworks: Import
AppTrackingTransparencyandAdSupport
Add the tracking usage description to your Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>We use your advertising identifier to measure ad campaign effectiveness and provide personalized experiences.</string>Request permission before accessing the IDFA:
Note: The setAdvertisingId() method can be called at any time, even immediately after initialization. If called during initialization, the SDK will queue the operation and apply it once ready. The advertising ID is persisted to UserDefaults and will be automatically restored on subsequent app launches.
import AppTrackingTransparency
import AdSupport
import MetaRouter
// Request tracking authorization (typically in AppDelegate or SceneDelegate)
func requestTrackingPermission() {
// Initialize MetaRouter first
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com"
)
let analytics = MetaRouter.Analytics.initialize(with: options)
// Only request on iOS 14.5+
if #available(iOS 14.5, *) {
ATTrackingManager.requestTrackingAuthorization { status in
switch status {
case .authorized:
// Permission granted - get IDFA and set it
let advertisingId = ASIdentifierManager.shared().advertisingIdentifier.uuidString
analytics.setAdvertisingId(advertisingId)
case .denied, .restricted, .notDetermined:
// Permission not granted - don't include IDFA
analytics.setAdvertisingId(nil)
@unknown default:
analytics.setAdvertisingId(nil)
}
}
} else {
// iOS 14.4 and below - IDFA available without ATT
let advertisingId = ASIdentifierManager.shared().advertisingIdentifier.uuidString
analytics.setAdvertisingId(advertisingId)
}
}import SwiftUI
import AppTrackingTransparency
import AdSupport
import MetaRouter
@main
struct MyApp: App {
@StateObject private var analyticsManager = AnalyticsManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(analyticsManager)
.onAppear {
// Request tracking permission after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
requestTrackingAndInitialize()
}
}
}
}
func requestTrackingAndInitialize() {
// Initialize analytics first
analyticsManager.initialize()
// Then request tracking permission and set IDFA
if #available(iOS 14.5, *) {
ATTrackingManager.requestTrackingAuthorization { status in
let advertisingId = status == .authorized
? ASIdentifierManager.shared().advertisingIdentifier.uuidString
: nil
analyticsManager.setAdvertisingId(advertisingId)
}
} else {
let advertisingId = ASIdentifierManager.shared().advertisingIdentifier.uuidString
analyticsManager.setAdvertisingId(advertisingId)
}
}
}
class AnalyticsManager: ObservableObject {
private var analytics: AnalyticsInterface?
func initialize() {
let options = InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com"
)
analytics = MetaRouter.Analytics.initialize(with: options)
}
func setAdvertisingId(_ advertisingId: String?) {
analytics?.setAdvertisingId(advertisingId)
}
func track(_ event: String, properties: [String: Any]? = nil) {
analytics?.track(event, properties: properties)
}
}Once set, the advertisingId will be automatically included in the device context of all subsequent events:
{
"context": {
"device": {
"advertisingId": "your-advertising-id",
"manufacturer": "Apple",
"model": "iPhone 14",
...
}
}
}- Obtain User Consent: Request explicit permission from users before tracking
- Comply with Regulations: Follow GDPR, CCPA, and other applicable privacy laws
- App Store Requirements:
- iOS: Follow Apple's App Tracking Transparency (ATT) framework
- Accurately declare data usage in your App Privacy details in App Store Connect
When users withdraw consent for advertising tracking (e.g., in response to a GDPR data subject request), you must stop collecting their IDFA. Use the clearAdvertisingId() method:
// User withdraws consent for advertising tracking
analytics.clearAdvertisingId()
// Analytics continues to work without IDFA
// Only anonymous ID and user ID will be included in events
analytics.track("checkout_completed", properties: ["order_id": "12345"])When to clear advertising ID:
- User opts out of advertising tracking in your app settings
- User revokes ATT permission in iOS Settings
- Responding to GDPR "right to erasure" requests
- User unsubscribes from personalized advertising
Note: The reset() method also clears the advertising ID along with all other analytics data.
- Request permission contextually: Explain the benefits before showing the ATT prompt
- Respect user choice: Don't repeatedly ask if denied
- Update privacy policy: Clearly state IDFA collection and usage
- App Store privacy label: Declare IDFA under "Identifiers" in App Store Connect
- Handle nil gracefully: Your analytics should work with or without IDFA
- Provide opt-out: Give users an in-app way to withdraw consent and clear their advertising ID
import AppTrackingTransparency
func checkTrackingStatus() -> ATTrackingManager.AuthorizationStatus {
if #available(iOS 14, *) {
return ATTrackingManager.trackingAuthorizationStatus
} else {
return .notDetermined
}
}The SDK validates advertising IDs before setting them:
- Must be a valid UUID format (parsed via
UUID(uuidString:)), e.g."123E4567-E89B-12D3-A456-426614174000" - Invalid values are rejected and logged as warnings
MIT