Install

iOS (Swift)

Track iOS and iPadOS apps with one Swift file.

One Swift file tracks an iOS or iPadOS app. It has no dependencies, keeps events on disk while the device is offline, and sends them in batches in the background.

Install

  1. Add the file

    Add AnyAnalytics.swift to your app target with the code below. Your project's setup page shows it with your write key filled in.

  2. Start it at launch

    Touch AnyAnalytics.shared once at launch: in your App's init, or in application(_:didFinishLaunchingWithOptions:).

  3. Run the app

    A $session_start event is sent when AnyAnalytics.shared is first used. It shows up in Live events within about 10 seconds.

AnyAnalytics.swiftswift
// AnyAnalytics.swift: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
import Foundation
#if canImport(UIKit)
import UIKit
#endif

final class AnyAnalytics {
    static let shared = AnyAnalytics()

    private let endpoint = URL(string: "https://api.anyanalytics.org/v1/batch")!
    private let writeKey = "vxw_YOUR_WRITE_KEY"
    private let maxQueue = 1000 // oldest events are dropped beyond this
    private let maxBatch = 100
    private let sessionId = UUID().uuidString.lowercased()
    private let worker = DispatchQueue(label: "anyanalytics") // owns all mutable state below
    private let queueFile = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("anyanalytics_queue.json")
    private var queue: [[String: Any]] = []
    private var inFlight: [[String: Any]] = [] // the one batch being sent
    private var batchSize = 100
    private var backoff: TimeInterval = 0
    private var retryAt = Date.distantPast
    private var warnedFull = false
    private var distinctId: String
    private let context: [String: Any]

    private init() {
        let defaults = UserDefaults.standard
        if let saved = defaults.string(forKey: "anyanalytics_id") {
            distinctId = saved
        } else {
            distinctId = UUID().uuidString.lowercased()
            defaults.set(distinctId, forKey: "anyanalytics_id")
        }
        var system = utsname()
        uname(&system)
        let model = withUnsafeBytes(of: &system.machine) {
            String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self)
        }
        let os = ProcessInfo.processInfo.operatingSystemVersion
        context = [
            "platform": "ios",
            "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
            "os": "iOS",
            "os_version": "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)",
            "device": model,
            "locale": Locale.current.identifier,
        ]
        // Events that weren't delivered last time
        if let data = try? Data(contentsOf: queueFile),
           let saved = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
            queue = saved
        }
        Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in self?.flush() }
        #if canImport(UIKit)
        NotificationCenter.default.addObserver(
            forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil
        ) { [weak self] _ in self?.flush() }
        #endif
        track("$session_start")
    }

    func track(_ event: String, properties: [String: Any] = [:]) {
        guard JSONSerialization.isValidJSONObject(properties) else {
            return print("[AnyAnalytics] \(event): properties must be JSON types")
        }
        let uuid = UUID().uuidString.lowercased() // kept across retries for deduplication
        let timestamp = Self.now()
        worker.async {
            self.queue.append([
                "uuid": uuid,
                "event": event,
                "distinct_id": self.distinctId,
                "session_id": self.sessionId,
                "timestamp": timestamp,
                "properties": properties,
                "context": self.context,
            ])
            if self.queue.count > self.maxQueue {
                self.queue.removeFirst(self.queue.count - self.maxQueue)
                if !self.warnedFull {
                    self.warnedFull = true
                    print("[AnyAnalytics] Queue full, dropping the oldest events.")
                }
            }
            if self.queue.count >= 20 { self.send() }
        }
    }

    func identify(_ userId: String, traits: [String: Any] = [:]) {
        worker.async {
            let anonymousId = self.distinctId
            self.distinctId = userId
            UserDefaults.standard.set(userId, forKey: "anyanalytics_id")
            self.track("$identify", properties: ["$anon_distinct_id": anonymousId, "$set": traits])
        }
    }

    func flush() {
        worker.async {
            self.save()
            self.send()
        }
    }

    // One request at a time; events leave the queue only once the server accepts them.
    private func send() {
        guard inFlight.isEmpty, !queue.isEmpty, Date() >= retryAt else { return }
        inFlight = Array(queue.prefix(batchSize))
        queue.removeFirst(inFlight.count)
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(writeKey)", forHTTPHeaderField: "Authorization")
        request.httpBody = try? JSONSerialization.data(withJSONObject: ["sent_at": Self.now(), "batch": inFlight])
        URLSession.shared.dataTask(with: request) { data, response, error in
            let http = response as? HTTPURLResponse
            let status = error == nil ? (http?.statusCode ?? 0) : 0 // 0 = network error
            let retryAfter = http?.value(forHTTPHeaderField: "Retry-After")
            self.worker.async { self.handle(status: status, retryAfter: retryAfter, body: data) }
        }.resume()
    }

    private func handle(status: Int, retryAfter: String?, body: Data?) {
        let batch = inFlight
        inFlight = []
        switch status {
        case 200..<300:
            backoff = 0
            batchSize = maxBatch
        case 413 where batch.count > 1:
            // Body too large: retry the same events in smaller batches
            batchSize = max(1, batch.count / 2)
            queue.insert(contentsOf: batch, at: 0)
        case 0, 408, 429, 500...:
            // Offline, timeout, rate limited or server busy: keep the events, back off
            // (1s doubling to 60s with jitter; Retry-After wins when sent)
            queue.insert(contentsOf: batch, at: 0)
            backoff = backoff == 0 ? 1 : min(backoff * 2, 60)
            let delay = retryAfter.flatMap { TimeInterval($0) } ?? backoff * .random(in: 0.5...1)
            retryAt = Date().addingTimeInterval(delay)
        default:
            // Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
            let reason = body.map { String(decoding: $0, as: UTF8.self) } ?? ""
            print("[AnyAnalytics] Dropped \(batch.count) event(s): HTTP \(status) \(reason)")
        }
        save()
        if (200..<300).contains(status) || status == 413 { send() } // keep draining
    }

    // Unsent events survive restarts (and are resent with the same uuids)
    private func save() {
        guard let data = try? JSONSerialization.data(withJSONObject: inFlight + queue) else { return }
        try? FileManager.default.createDirectory(
            at: queueFile.deletingLastPathComponent(), withIntermediateDirectories: true)
        try? data.write(to: queueFile, options: .atomic)
    }

    private static func now() -> String {
        ISO8601DateFormatter().string(from: Date())
    }
}
  • Properties must be JSON types: String, Int, Double, Bool, arrays and dictionaries.
  • Undelivered events are saved in Application Support and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.

Track events

Track from anywhere in your app. The call is thread-safe and returns immediately.

Checkout.swiftswift
AnyAnalytics.shared.track("purchase_completed", properties: ["product": "premium", "price": 4.99])

Events are sent every 10 seconds, as soon as 20 are queued, and when the app moves to the background. Anything that couldn't be sent is kept for the next launch.

Identify users

Each install gets a random id, saved in UserDefaults. After sign-in, switch to your own user id:

Login.swiftswift
// After sign-in
AnyAnalytics.shared.identify(user.id, traits: ["email": user.email, "plan": "pro"])

Their earlier anonymous events are linked to the same person, and the traits are saved on their profile. The new id is remembered across launches.

Screen views

Screen views are $screen events with a $screen_name property, the same events the JavaScript SDK sends:

SettingsView.swiftswift
// SwiftUI: record a screen view when it appears
.onAppear { AnyAnalytics.shared.track("$screen", properties: ["$screen_name": "Settings"]) }

Sessions and context

A new session starts each time the app's process starts. Every event carries the app version (CFBundleShortVersionString), the iOS version, the device model and the locale. See Events and people for how events, sessions and people fit together.