iOS
MetaRouter iOS SDK
Capture behavioral data directly from your iOS app and route it through MetaRouter to any destination — without juggling multiple vendor SDKs.
Why use the Mobile SDK?
One integration, unlimited destinations
Stop embedding separate SDKs for analytics, marketing, and data warehouses. Collect once, route everywhere through MetaRouter.
Update routing without app releases
Add or remove destinations server-side. No code changes, no App Store review cycles.
Reliable delivery, even offline
Events are queued locally and delivered automatically when connectivity returns. Built-in retry logic handles transient failures. Offline mode supported for durable event persistence regardless of network conditions.
Automatic identity resolution
Track users from first app open through sign-up and beyond. The SDK manages anonymous IDs, persists identity across sessions, and connects pre-login activity to known users.
Privacy-ready by design
Push your consent management platform's decisions into the SDK and every event carries them, in the same shape as the web SDK, so your cluster enforces one set of consent rules for web and mobile. Built-in support for IDFA consent via App Tracking Transparency, easy opt-out handling, and clean user data reset for GDPR/CCPA compliance.
Lightweight footprint
Zero external dependencies. Minimal impact on app size and startup time.
Quick start
1) Add the SDK
Add the package via Swift Package Manager in Xcode:
File → Add Package Dependencies
Enter:
https://github.com/metarouterio/ios-sdk.git
Or add it directly to your Package.swift:
dependencies: [
.package(url: "https://github.com/metarouterio/ios-sdk.git", from: "1.8.0")
]2) Initialize
import MetaRouter
// Call once at app launch (e.g. in AppDelegate or your App init).
// No need to capture return value
MetaRouter.Analytics.initialize(
with: InitOptions(
writeKey: "YOUR_WRITE_KEY",
ingestionHost: "https://YOUR_CLUSTER.YOUR_SITE.com"
)
)Your writeKey and ingestionHost are available in the MetaRouter dashboard.
Accessing the client:
After initialization, access the SDK anywhere through the shared instance:
MetaRouter.Analytics.shared.track("Order Completed")
MetaRouter.Analytics.shared.identify("user-123")initialize(with:) returns immediately and binds the underlying client in the background; any calls made before binding completes are queued and replayed, so shared is safe to use right away. You don't need to store or inject the returned reference.
3) Start tracking
// Track user actions
analytics.track("Product Viewed", properties: [
"sku": "SHOE-123",
"price": 89.99
])
// Identify known users
analytics.identify("user-456", traits: [
"email": "[email protected]",
"plan": "premium"
])
// Track screen views
analytics.screen("Product Detail", properties: [
"category": "Footwear"
])Events are batched and delivered automatically. No additional setup required.
Core capabilities
Event tracking
| Method | Purpose |
|---|---|
track(_:properties:) | Capture user actions — purchases, clicks, feature usage |
screen(_:properties:) | Record screen views for navigation analytics |
page(_:properties:) | Record page views (web semantics, if applicable) |
User identity
| Method | Purpose |
|---|---|
identify(_:traits:) | Associate events with a known user |
group(_:traits:) | Associate users with a company, team, or account |
alias(_:) | Link anonymous activity to a newly identified user |
getAnonymousId() | Returns the anonymous ID the SDK assigned to this device |
Lifecycle & privacy
| Method | Purpose |
|---|---|
flush() | Send queued events immediately |
reset() | Clear identity and queued events (use on logout); consent and provider context are kept |
setAdvertisingId(_:) | Set IDFA for attribution (with user consent) |
clearAdvertisingId() | Remove IDFA when user opts out |
Consent & provider context
| Method | Purpose |
|---|---|
registerConsentCategories(_:) | Declare the consent categories your pipeline gates on (before initialize) |
setConsent(_:) | Push the user's current consent; stamped on every event |
getConsent() / clearConsent() | Read the current consent / withdraw it |
subscribeToConsent(_:) | Observe consent changes on the main actor |
setProviderContext(_:key:value:) | Stamp a vendor value (e.g. Firebase appInstanceId) on every event |
Identity that just works
The SDK automatically handles the complexity of user identity:
Before login — Users are assigned a stable anonymous ID
On login — Call identify() to attach a known user ID
Across sessions — Identity persists through app restarts
On logout — Call reset() to clear everything and start fresh
Connect anonymous browsing to authenticated users with alias():
// User browses anonymously, then signs up
analytics.track(
"Product Viewed",
properties: ["sku": "ABC"]
) // tracked as anonymous
// User creates account
analytics.alias("new-user-789") // links anonymous → known
analytics.identify(
"new-user-789",
traits: ["email": "[email protected]"]
)
// Full journey is now connected in your downstream toolsConfiguration Options
Use InitOptions to configure delivery cadence, logging, in-memory buffering, and disk-backed persistence.
let analytics = MetaRouter.Analytics.initialize(
with: InitOptions(
writeKey: "YOUR_WRITE_KEY", // Required
ingestionHost: "https://YOUR_CLUSTER.mr-in.com", // Required
flushIntervalSeconds: 10, // Optional (default: 10)
debug: false, // Optional (default: false)
maxQueueEvents: 2000, // Optional (default: 2000)
maxDiskEvents: 10000, // Optional (default: 10000)
sessionTimeoutMinutes: 30, // Optional (default: 30)
fireSessionStarted: false // Optional (default: false)
)
)| Option | Required | Default | Description |
|---|---|---|---|
writeKey | Yes | — | Your MetaRouter write key. Must not be empty. |
ingestionHost | Yes | — | Your MetaRouter ingestion endpoint. Pass either a String or URL; do not include a trailing slash. |
flushIntervalSeconds | No | 10 | How often the SDK attempts to send queued events. Values below 1 are clamped to 1. |
debug | No | false | Enables verbose SDK logging for troubleshooting. |
maxQueueEvents | No | 2000 | Maximum events held in the in-memory queue. Values below 1 are clamped to 1. |
maxDiskEvents | No | 10000 | Maximum unsent events retained on disk for crash safety and offline recovery. Set to 0 to disable disk persistence. |
trackLifecycleEvents | No | false | Automatically emit the four Application * lifecycle events. |
sessionTimeoutMinutes | No | 30 | Minutes of inactivity before the next event starts a new session. Values below 1 are clamped to 1. |
fireSessionStarted | No | false | Emit a Session Started track event each time a new session begins — for destinations that need an explicit session-start signal (e.g. GA4's session_start). |
onConfigError | No | nil | Called synchronously by initialize when the options were constructed from invalid configuration. See Invalid configuration below. Available since 1.6.0. |
Invalid configuration
Requires SDK 1.6.0+.
Constructing InitOptions never throws. If the configuration is invalid — an empty write key, or an ingestion host that isn't an http(s) URL with a host — the SDK responds by build type when you call initialize:
- Debug builds fail fast with an assertion, so configuration mistakes surface during development.
- Release builds never crash your app: the SDK logs an error, calls your
onConfigErrorcallback if you set one, and stays inert for the session — no events are sent, and every API remains safe to call (awaiting calls likegetAnonymousId()resolve promptly instead of hanging). Callinginitializeagain with valid configuration recovers the session.
Numeric options out of range (flushIntervalSeconds, maxQueueEvents,maxDiskEvents,sessionTimeoutMinutes) are clamped to their nearest valid value with a logged warning rather than failing.
let options = InitOptions(
writeKey: writeKeyFromRemoteConfig,
ingestionHost: hostFromRemoteConfig,
onConfigError: { error in
crashReporter.log("MetaRouter disabled: \(error.description)")
}
)getDebugInfo()["configError"] carries the error description for a refused session, so misconfiguration is diagnosable from the same API as every other SDK state.
Lifecycle Events
Turn on automatic tracking of the four standard application-lifecycle events — Application Installed, Application Updated, Application Opened, and Application Backgrounded — to anchor attribution, retention, and session reporting without instrumenting each one by hand. They flow through the same pipeline as your own track calls.
Enable it
Lifecycle events are opt-in. Set trackLifecycleEvents: true in InitOptions:
let analytics = MetaRouter.Analytics.initialize(
with: InitOptions(
writeKey: "your-write-key",
ingestionHost: "https://your-ingestion-endpoint.com",
trackLifecycleEvents: true
)
)With the flag left at its default (false), the SDK emits none of these events and recordOpenedURL(...) is ignored. Upgrading the SDK never turns them on silently — an app has to opt in.
The events
| Event | When it fires | Properties |
|---|---|---|
Application Installed | First launch after a fresh install (no prior identity or lifecycle state on the device) | version, build |
Application Updated | First launch after the app's version/build changes — also the first launch after upgrading from a pre-lifecycle SDK build | version, build, previous_version, previous_build ("unknown" for the SDK-upgrade case) |
Application Opened | Cold launch while active, and each background → active return | version, build, from_background (false on cold launch, true on return), plus url / referring_application when a deep link was recorded |
Application Backgrounded | App enters the background | — |
On cold launch, Application Installed/Updated fire before Application Opened, so attribution sees the install or update ahead of the session start. Foreground and background transitions come from the app's own didBecomeActive / didEnterBackground notifications. Only a full background → active return emits Application Opened — brief interruptions that leave the app merely inactive (Control Center, a system prompt, Face ID) do not.
If the process is launched into the background (silent push, background fetch), the cold-launch Application Opened is deferred until the first real activation, then emitted with from_background: false.
Install/update state is stored per-device (in UserDefaults, in a namespace separate from identity) — calling reset() clears the user but keeps the install/update history.
Deep links
The SDK doesn't auto-capture deep links; you forward the URL so it can be attached to the next Application Opened event.
| Method | Purpose |
|---|---|
recordOpenedURL(_ url: URL, sourceApplication: String?) | Record a deep-link URL to attach to the next Application Opened event. |
Call it from wherever your app receives URLs — application(_:open:options:), scene(_:openURLContexts:), and application(_:didFinishLaunchingWithOptions:) for cold-launch capture:
// AppDelegate — cold launch
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
if let url = launchOptions?[.url] as? URL {
let source = launchOptions?[.sourceApplication] as? String
MetaRouter.Analytics.shared.recordOpenedURL(url, sourceApplication: source)
}
return true
}
// AppDelegate — while running
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
MetaRouter.Analytics.shared.recordOpenedURL(
url,
sourceApplication: options[.sourceApplication] as? String
)
return true
}Using scenes:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let context = URLContexts.first else { return }
MetaRouter.Analytics.shared.recordOpenedURL(
context.url,
sourceApplication: context.options.sourceApplication
)
}The next Application Opened the SDK emits carries url (and referring_application when you pass a source). The buffer is one-shot — cleared once that event fires — and last-write-wins if you record more than one URL before it.
Deep-link URLs often carry secrets — auth tokens, OTPs, magic-link codes. The SDK forwards the URL as-is and does not sanitize it. Strip sensitive query parameters before callingrecordOpenedURL(...).
Advertising & attribution
For ad attribution, you can include the Identifier for Advertisers (IDFA):
import AppTrackingTransparency
import AdSupport
// Request permission (required on iOS 14.5+)
ATTrackingManager.requestTrackingAuthorization { status in
if status == .authorized {
let idfa = ASIdentifierManager.shared()
.advertisingIdentifier
.uuidString
analytics.setAdvertisingId(idfa)
}
}// When user opts out
analytics.clearAdvertisingId()Privacy note:
iOS requires explicit user consent via App Tracking Transparency before collecting the IDFA.
Add NSUserTrackingUsageDescription to your Info.plist with a clear explanation of why you’re requesting tracking permission.
See the GitHub docs for full implementation details:
https://github.com/metarouterio/ios-sdk
WebView Bridge
Hybrid screens don't have to be a blind spot. If parts of your app render as web content inside a WKWebView, the bridge captures the events your pages fire and delivers them through the native SDK, so web and native activity arrive at your cluster as one user, one pipeline, with native identity and device context on every event.
Attach each WebView when you create it, before loading content:
let webView = WKWebView()
MetaRouter.Analytics.shared.attachWebView(webView, allowedOrigins: ["https://www.example.com"])
webView.load(URLRequest(url: URL(string: "https://www.example.com/checkout")!))Pages loaded from an allowed origin get a window.metarouterBridge object with track and page methods. Properties can be a plain object or a JSON string so a typical integration is a few lines in your existing tag manager:
function logEvent(name, params) {
if (!window.metarouterBridge) return;
var value = JSON.stringify(params);
name === 'page_view'
? window.metarouterBridge.page(name, value)
: window.metarouterBridge.track(name, value);
}The page surface is identical on iOS and Android, one web integration serves both apps.
What arrives at your cluster: the page contributes the event and its page facts (context.page: url, path, search, title, referrer); the SDK contributes everything durable: anonymousId/userId, device and app context, timestamps, message IDs, and — from 1.8.0 — the current consent and any provider context your app has set. Duplicate deliveries are dropped automatically, and every message gets an acknowledgment or a specific error code (malformed input is rejected), never silently lost.
Things to remember:
- Attach before
load(_:)registrations only apply to page loads that start afterwards. - Origins are explicit (
https://host[:port]), no wildcards. The allowlist is what keeps ads and redirects in the same WebView from injecting events into your stream. - One
WKWebViewConfigurationper attached WebView — WebViews sharing a configuration share one bridge registration and one allowlist (the first attach wins). - Attachment survives
reset()and re-initialization; no re-attach needed.
Debugging: enable debug: true and watch the SDK log for attach confirmations, rejected messages with their error codes, and duplicate drops. From Safari's Develop menu you can inspect the WebView directly: check typeof window.metarouterBridge, or watch native replies live with window.__metaRouterNativeChannel.onmessage = e => console.log(e.data).
Requires SDK 1.6.0+.
Session Tracking
Requires SDK 1.7.0+.
Every event automatically carries the analytics session it belongs to:
"context": {
"providers": {
"metarouter": {
"sessionID": "1757400000000",
"sessionCount": 3
}
}
}This is the same path and shape the MetaRouter web SDK produces, so pipeline mappings — for example GA4's session_id and session_number — read one field no matter which platform sent the event. sessionID is the epoch-millisecond timestamp of the session's first event, as a string; sessionCount is a lifetime counter for the install, starting at 1.
There is nothing to enable. A session starts with the first event after launch, extends with every tracked, lifecycle, or WebView-bridge event, and ends after sessionTimeoutMinutes (default 30) of inactivity — the window slides, so continuous activity keeps one session alive indefinitely. Sessions survive app restarts that happen inside the window, and survive reset(): a user logging out mid-session does not split the session.
Session Started event
Set fireSessionStarted: true to emit a Session Started track event (no properties) each time a new session begins:
let options = InitOptions(
writeKey: "YOUR_WRITE_KEY",
ingestionHost: "https://your-ingestion-host.com",
sessionTimeoutMinutes: 30, // default
fireSessionStarted: true // default false
)It is off by default, so upgrading the SDK never changes your event volume.
Reading the current session
let sessionId: String? = await analytics.getSessionId()Returns nil before the first event of the process — sessions are created by events, and reading the id never starts or extends one. The current sessionId and sessionCount also appear in getDebugInfo().
Consent
Requires SDK 1.8.0+.
Your app owns consent acquisition — a CMP SDK callback, a cookie read out of a WebView, your own preference screen. The SDK stores it, completes it, and puts it on every event at context.consent, in the same shape the MetaRouter web SDK emits, so your cluster enforces one set of per-integration rules for web and mobile:
"context": {
"consent": {
"explicit": true,
"optOut": { "c0001": false, "c0002": false, "c0004": true }
}
}explicit—truewhen the user made an explicit choice,falsefor an implicit or regional default. You set it.optOut— opt-out semantics:truemeans blocked. Keys are your CMP's category codes, lowercased.
Consent never gates delivery: events always send, and your cluster applies the rules per integration.
1) Register categories — before initialize
initializeMetaRouter.Analytics.shared.registerConsentCategories([
ConsentCategory(key: "c0001", defaultOptOut: false), // strictly necessary
ConsentCategory(key: "c0002"), // performance
ConsentCategory(key: "c0004"), // targeting
])
MetaRouter.Analytics.initialize(with: options)Registration lets the SDK complete a sparse CMP payload: known categories keep their value, unknown ones are dropped, missing ones take their registered default (opt-out unless you say otherwise). Register before initialize, because launch events such as Application Opened are stamped as soon as they are created.
2) Push consent whenever it changes
MetaRouter.Analytics.shared.setConsent(Consent(
explicit: true,
optOut: ["c0002": false, "c0004": true]
))The change takes effect immediately, persists across launches, and survives reset() — logging out never re-enables tracking the user opted out of. Only clearConsent() removes it. Until the first setConsent, events carry the implicit default (explicit: false, every category at its registered default).
If you pass the CMP's own epoch-millisecond timestamp, a snapshot older than one you already sent is ignored and setConsent returns false, so a cached replay cannot overwrite a fresher choice.
OneTrust
// From the OneTrust SDK's category statuses
MetaRouter.Analytics.shared.setConsent(
OneTrustAdapter.normalize(["C0001": 1, "C0002": 1, "C0004": 0], explicit: true)
)
// From the OptanonConsent cookie's `groups` value (percent-encoded or the whole cookie)
MetaRouter.Analytics.shared.setConsent(
OneTrustAdapter.normalize(groups: cookieValue, explicit: true)
)Pass explicit: true from the banner-interaction callback and false for a regional default. If the cookie is absent, don't call setConsent: an empty value reads as "opted out of everything". Any other CMP can build a Consent directly.
Observing and withdrawing
let subscription = MetaRouter.Analytics.shared.subscribeToConsent { consent in
// Main actor: safe to update UI. Fires with the current value, then on every change.
}
subscription.cancel() // keep the subscription until you no longer need updates
MetaRouter.Analytics.shared.clearConsent() // back to the implicit defaultProvider Context
Requires SDK 1.8.0+.
Some destinations only accept an event that carries a vendor's own identifier — one your app can read but a web page cannot. GA4 app streams, for example, need the Firebase app instance id. setProviderContext stamps such a value onto every event, WebView-bridge events included:
import FirebaseAnalytics
// On every launch, after FirebaseApp.configure() and before initialize.
MetaRouter.Analytics.shared.setProviderContext(
"googleFirebase", key: "appInstanceId", value: FirebaseAnalytics.Analytics.appInstanceID()
)"context": {
"providers": {
"googleFirebase": { "appInstanceId": "c0ffee1234567890abcdef" },
"metarouter": { "sessionID": "1757400000000", "sessionCount": 3 }
}
}- Provider and key names are sent exactly as written, since your pipeline mappings match them verbatim. Any provider your pipeline reads works.
- Values persist across launches and survive
reset(). Anilvalue removes the key;clearProviderContext("googleFirebase")removes a provider,clearAllProviderContext()removes everything. - Re-set values on every launch and clear ones that rotate (an id reset by its vendor), since the saved value is stamped until you change it.
- Bounded because it rides on every event: up to 10 providers, 10 keys each, 256 bytes per value.
metarouteris reserved for the SDK's session block. A rejected write returnsfalseand logs a warning.
Requirements
| Component | Version |
|---|---|
| iOS | 15.0+ |
| macOS | 12.0+ |
| Swift | 6.1+ |
Updated 2 days ago