Skip to content

metarouterio/ios-sdk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

96 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MetaRouter iOS SDK

Swift Package Manager Platform MIT License

A lightweight iOS analytics SDK that transmits events to your MetaRouter cluster.

Table of Contents

Installation

Swift Package Manager

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

Usage

Basic Setup

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)

SwiftUI Usage

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
            ])
        }
    }
}

UIKit Usage

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
    }
}

Direct Usage

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()

API Reference

MetaRouter.Analytics.initialize(with:)

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 key
  • ingestionHost (String or URL, required): Your MetaRouter ingestor host
  • debug (Bool, optional): Enable debug mode
  • flushIntervalSeconds (Int, optional): Interval in seconds to flush events (default 10)
  • maxQueueEvents (Int, optional): Maximum events buffered in the in-memory queue (default 2000, must be > 0)
  • maxDiskEvents (Int, optional): Maximum events retained on disk across crash-safety snapshots and extended-offline overflow (default 10000). Set to 0 to 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.
  • 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:

// 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:

  • track(_ event: String, properties: [String: Any]?): Track custom events
  • identify(_ userId: String, traits: [String: Any]?): Identify users
  • group(_ groupId: String, traits: [String: Any]?): Group users
  • screen(_ name: String, properties: [String: Any]?): Track screen views
  • page(_ name: String, properties: [String: Any]?): Track page views
  • alias(_ newUserId: String): Connect anonymous users to known user IDs. See Using the alias() Method for details
  • setAdvertisingId(_ advertisingId: String?): Set the advertising identifier (IDFA) for ad tracking. See Advertising ID section for usage and compliance requirements
  • clearAdvertisingId(): Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking
  • getAnonymousId() async -> String: Retrieve the device's anonymous ID. Awaits initialization internally so the returned value is always valid — never nil or empty
  • flush(): Flush events immediately
  • reset(): Reset analytics state and clear all stored data (includes clearing advertising ID)
  • 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
  • recordOpenedURL(_ 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 for wiring

Testing APIs

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()

⚠️ Use these only in tests — they block until initialization/reset completes.

Features

  • 🎯 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 Any conversion
  • 🚀 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 / Backgrounded tracking with deep-link attribution support

✅ Compatibility

Component Supported Versions
iOS >= 15.0
macOS >= 12.0
Swift >= 5.5
Xcode >= 13.0

Debugging

If you're not seeing API calls being made, here are some steps to troubleshoot:

1. Enable Debug Logging

// 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()

2. Check Debug Information

// 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)

3. Enable Tracing

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.

4. Force Flush Events

// Manually flush events to see if they're being sent
analytics.flush()

5. Common Issues

  • 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

Delivery & Backoff (How events flow under failures)

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:

  • anonymousId is a stable, persisted UUID for the device/user before identify; it does not include timestamps.
  • messageId is generated as <epochMillis>-<uuid> (e.g., 1734691572843-6f0c7e85-...) to aid debugging.

Identity Persistence

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 Four Identity Fields

1. userId (Common User ID)

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)

2. anonymousId

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.

3. groupId

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"

4. advertisingId (Optional)

The advertisingId is used for ad tracking and attribution (IDFA on iOS). See the Advertising ID section below for detailed usage and compliance requirements.

Persistence Summary

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()

Event Enrichment Flow

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)
    }
  }
}

Resetting Identity

Call reset() to clear all identity data, typically when a user logs out:

analytics.reset()

What reset() does:

  • Clears userId, anonymousId, groupId, and advertisingId from 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

Best Practices

  1. On Login: Call identify() immediately after successful authentication
  2. On Logout: Call reset() to clear user identity
  3. Cross-Session Tracking: The SDK handles this automatically - no action needed
  4. Group Associations: Set groupId after determining the user's organization/team
  5. Pre-Login Tracking: Events are tracked with anonymousId before login
  6. Connecting Sessions: Use alias() to connect pre-login and post-login activity

Example: Complete User Journey

// 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 init

Storage Location

All 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

App Lifecycle Handling

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

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. 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)

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; 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
)

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):

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)
}

Buffer Semantics

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 recordOpenedURL 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:

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.

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.

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: 0 to 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
    • maxQueueEvents still 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.

Using the alias() Method

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.

When to Use alias()

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.

How It Works

analytics.alias(newUserId)

This does two things:

  1. Sets the new userId (same as identify())
  2. Sends an alias event to your analytics backend, telling it: "This anonymousId and this userId are the same person"

Example: User Sign-Up Flow

// 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"

alias() vs identify()

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

Best Practices

  1. First-time sign-up: Call alias() to connect anonymous activity to the new account
  2. Subsequent logins: Use identify() - no need to alias again
  3. Backend support: Ensure your analytics backend supports alias events for merging user profiles
  4. One-time operation: You typically only need alias() once per user - when they first create an account

Real-World Example: E-Commerce App

// 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 → converted

Advertising ID (IDFA)

The SDK supports including advertising identifiers (IDFA - Identifier for Advertisers) in event context for ad tracking and attribution purposes.

Usage

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.

Prerequisites

  1. iOS 14.5+: App Tracking Transparency (ATT) is required
  2. Info.plist: Add NSUserTrackingUsageDescription to explain why you need tracking permission
  3. Frameworks: Import AppTrackingTransparency and AdSupport

1. Update Info.plist

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>

2. Request Tracking Authorization

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)
    }
}

3. SwiftUI Example

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",
      ...
    }
  }
}

Privacy & Compliance

⚠️ Important: Advertising identifiers are Personally Identifiable Information (PII). Before collecting advertising IDs, you must:

  1. Obtain User Consent: Request explicit permission from users before tracking
  2. Comply with Regulations: Follow GDPR, CCPA, and other applicable privacy laws
  3. App Store Requirements:

GDPR Compliance: Clearing Advertising ID

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.

Best Practices

  1. Request permission contextually: Explain the benefits before showing the ATT prompt
  2. Respect user choice: Don't repeatedly ask if denied
  3. Update privacy policy: Clearly state IDFA collection and usage
  4. App Store privacy label: Declare IDFA under "Identifiers" in App Store Connect
  5. Handle nil gracefully: Your analytics should work with or without IDFA
  6. Provide opt-out: Give users an in-app way to withdraw consent and clear their advertising ID

Checking ATT Status

import AppTrackingTransparency

func checkTrackingStatus() -> ATTrackingManager.AuthorizationStatus {
    if #available(iOS 14, *) {
        return ATTrackingManager.trackingAuthorizationStatus
    } else {
        return .notDetermined
    }
}

Validation

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

License

MIT

About

MetaRouter iOS SDK

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages