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
Add the file
Add
AnyAnalytics.swiftto your app target with the code below. Your project's setup page shows it with your write key filled in.Start it at launch
Touch
AnyAnalytics.sharedonce at launch: in yourApp'sinit, or inapplication(_:didFinishLaunchingWithOptions:).Run the app
A
$session_startevent is sent whenAnyAnalytics.sharedis first used. It shows up in Live events within about 10 seconds.
// 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.
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:
// 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:
// 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.