// trackd.one Swift SDK — privacy-first analytics for iOS, iPadOS, macOS, tvOS, watchOS and visionOS. // // - No IDFA, no IDFV, no device model, no install id — nothing persistent, no fingerprinting. // - Nothing is written to disk (no UserDefaults, no files, no Keychain). Events and the session id // live in memory only. // - Events are batched and sent from a private serial queue. The SDK never crashes your app. // // Trackd.configure(websiteId: "YOUR-WEBSITE-ID") // Trackd.screen("Home") // Trackd.event("level_complete", data: ["level": 5, "score": 1200]) // Trackd.revenue(4.99, currency: "EUR", orderId: "order-123") import Foundation #if canImport(os) import os.log #endif #if os(iOS) || os(tvOS) || os(visionOS) import UIKit #elseif os(macOS) import AppKit #elseif os(watchOS) import WatchKit #endif // MARK: - Public API public enum Trackd { public static let version = "1.1.0" static let client = TrackdClient(observeLifecycle: true) /// Configure the SDK. Call once at app start (e.g. in your `App.init()` or /// `application(_:didFinishLaunchingWithOptions:)`), preferably on the main thread. /// Events tracked before `configure` are kept in memory and sent afterwards. /// /// - Parameters: /// - websiteId: your trackd.one website / app id (UUID) /// - host: API host, e.g. your self-hosted Worker (default `https://trackd.one`) /// - debug: log SDK activity via `os_log` (subsystem `one.trackd.sdk`) /// - flushInterval: send queued events at the latest after this many seconds (default 30) /// - flushAt: send as soon as this many events are queued (default 20, max 50 per request) public static func configure(websiteId: String, host: String = "https://trackd.one", debug: Bool = false, flushInterval: TimeInterval = 30, flushAt: Int = 20) { // UIDevice / UIScreen / WKInterfaceDevice must only be used on the main thread. Off the main // thread the configuration is applied asynchronously; events tracked meanwhile are queued. if Thread.isMainThread { client.configure(websiteId: websiteId, host: host, debug: debug, context: DeviceContext.collect(), flushInterval: flushInterval, flushAt: flushAt) } else { DispatchQueue.main.async { Trackd.client.configure(websiteId: websiteId, host: host, debug: debug, context: DeviceContext.collect(), flushInterval: flushInterval, flushAt: flushAt) } } } /// Track a screen view (the app equivalent of a pageview), e.g. `"Home"` or `"Settings/Profile"`. public static func screen(_ name: String, title: String? = nil) { client.screen(name, title: title) } /// Alias of `screen(_:title:)`, kept for backward compatibility. public static func screenView(_ name: String, title: String? = nil) { client.screen(name, title: title) } /// Track a custom event. `data` must be flat: `String`, numbers or `Bool`. /// Nested values, `nil`, NaN / infinity are dropped; the JSON is capped at 4 KB. public static func event(_ name: String, data: [String: Any]? = nil) { client.event(name, data: data) } /// Track revenue — sent as event `purchase` with `{amount, currency, order_id}`. public static func revenue(_ amount: Double, currency: String = "EUR", orderId: String? = nil) { client.revenue(amount, currency: currency, orderId: orderId) } /// Send all queued events now (asynchronously). public static func flush() { client.flush() } /// Enable or disable tracking. Disabling clears all queued events immediately. public static func setEnabled(_ enabled: Bool) { client.setEnabled(enabled) } /// Whether tracking is currently enabled. public static var isEnabled: Bool { client.isEnabled } // MARK: Game & media helpers public static func gameStart(level: Int? = nil, mode: String? = nil) { event("game_start", data: compact(["level": level, "mode": mode])) } public static func levelComplete(_ level: Int, score: Int? = nil, timeMs: Int? = nil) { event("level_complete", data: compact(["level": level, "score": score, "time_ms": timeMs])) } public static func gameOver(_ level: Int, reason: String = "died") { event("game_over", data: ["level": level, "reason": reason]) } public static func videoWatch(_ videoId: String, durationMs: Int? = nil, completed: Bool = false) { event("video_watch", data: compact(["video_id": videoId, "duration_ms": durationMs, "completed": completed])) } public static func adView(_ adType: String, adId: String? = nil) { event("ad_view", data: compact(["ad_type": adType, "ad_id": adId])) } public static func tutorialStep(_ step: Int, total: Int, skipped: Bool = false) { event("tutorial_step", data: ["step": step, "total": total, "skipped": skipped]) } private static func compact(_ dict: [String: Any?]) -> [String: Any] { dict.compactMapValues { $0 } } } // MARK: - Client (all mutable state is confined to `queue`) final class TrackdClient: @unchecked Sendable { typealias Transport = (URLRequest, @escaping (_ status: Int?, _ retryAfter: String?) -> Void) -> Void struct Item { let event: [String: Any] let session: String let bytes: Int let timestamp: Int64 /// Set once the item was part of a sent batch — a retry resends exactly that batch (same id). var batchId: String? } struct Batch { let items: [Item] let session: String let id: String } static let defaultFlushAt = 20 static let defaultFlushInterval: TimeInterval = 30 static let userAgent = "trackd-ios/\(Trackd.version)" static let maxBatch = 50 static let maxQueue = 500 static let sessionTimeout: TimeInterval = 30 * 60 static let maxBodyBytes = 60 * 1024 static let maxEventDataBytes = 4096 static let maxEventName = 200 static let maxScreenName = 300 // server keeps 300 chars static let maxKey = 100 static let maxString = 500 // server keeps 500 chars per event_data string static let maxEventDataKeys = 50 // server keeps the first 50 entries static let maxEventAgeMs: Int64 = 72 * 60 * 60 * 1000 static let maxBackoff: TimeInterval = 5 * 60 static let maxRetryAfter: TimeInterval = 60 * 60 static let sessionIdLength = 20 static let batchIdLength = 24 private static let idChars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") let queue = DispatchQueue(label: "one.trackd.sdk", qos: .utility) private let transport: Transport private let clock: () -> Date private let uptime: () -> TimeInterval private var observers: [NSObjectProtocol] = [] private let observesLifecycle: Bool // queue-confined state private(set) var websiteId = "" private(set) var endpoint: URL? private(set) var context: [String: String] = [:] private(set) var items: [Item] = [] private(set) var sessionId = "" private(set) var enabled = true private var debug = false private var lastActivity: TimeInterval = 0 private var backgroundAt: TimeInterval? private var flushWork: DispatchWorkItem? private var retryWork: DispatchWorkItem? private var retryAt: TimeInterval = 0 private var attempt = 0 private var inFlight = false private var epoch = 0 private(set) var flushAt = TrackdClient.defaultFlushAt private(set) var flushInterval = TrackdClient.defaultFlushInterval private var drainWaiters: [() -> Void] = [] #if canImport(os) private let logger = OSLog(subsystem: "one.trackd.sdk", category: "Trackd") #endif init(transport: Transport? = nil, clock: @escaping () -> Date = { Date() }, uptime: @escaping () -> TimeInterval = TrackdClient.monotonicNow, observeLifecycle: Bool = false) { self.transport = transport ?? TrackdClient.urlSessionTransport() self.clock = clock self.uptime = uptime self.observesLifecycle = observeLifecycle if observeLifecycle { observeAppLifecycle() } } deinit { for o in observers { NotificationCenter.default.removeObserver(o) } } // MARK: Public entry points (any thread) func configure(websiteId: String, host: String, debug: Bool, context: [String: String], flushInterval: TimeInterval = TrackdClient.defaultFlushInterval, flushAt: Int = TrackdClient.defaultFlushAt) { let id = websiteId.trimmingCharacters(in: .whitespacesAndNewlines) var base = host.trimmingCharacters(in: .whitespacesAndNewlines) while base.hasSuffix("/") { base.removeLast() } let url = URL(string: base + "/api/collect") queue.async { self.debug = debug guard !id.isEmpty else { return self.log("configure(): websiteId is empty — nothing will be sent") } guard let url = url, let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { return self.log("configure(): invalid host \(host) — nothing will be sent") } self.websiteId = id self.endpoint = url self.context = context self.flushInterval = flushInterval > 0 && flushInterval.isFinite ? flushInterval : Self.defaultFlushInterval self.flushAt = flushAt > 0 ? flushAt : Self.defaultFlushAt self.log("configured: endpoint=\(url.absoluteString) context=\(context)") if !self.items.isEmpty { self.scheduleFlush() } } } func screen(_ name: String, title: String?) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return queue.async { self.log("screen(): name is empty") } } var e: [String: Any] = ["type": "screen_view", "name": String(trimmed.prefix(Self.maxScreenName))] if let t = title?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty, t != trimmed { e["title"] = String(t.prefix(Self.maxScreenName)) } track(e) } func event(_ name: String, data: [String: Any]?) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return queue.async { self.log("event(): name is empty") } } var e: [String: Any] = ["type": "event", "event_name": String(trimmed.prefix(Self.maxEventName))] if let clean = Self.sanitizeEventData(data) { e["event_data"] = clean } track(e) } func revenue(_ amount: Double, currency: String, orderId: String?) { guard amount.isFinite else { return queue.async { self.log("revenue(): amount must be finite") } } let cur = String(currency.trimmingCharacters(in: .whitespacesAndNewlines).prefix(3)).uppercased() var data: [String: Any] = ["amount": amount, "currency": cur.isEmpty ? "EUR" : cur] if let orderId = orderId, !orderId.isEmpty { data["order_id"] = String(orderId.prefix(100)) } event("purchase", data: data) } func flush() { queue.async { self.drain() } } func setEnabled(_ value: Bool) { queue.async { if !value { self.epoch += 1 self.items.removeAll() self.cancelWork() self.retryAt = 0 self.attempt = 0 } self.enabled = value self.log(value ? "tracking enabled" : "tracking disabled, queue cleared") } } var isEnabled: Bool { queue.sync { enabled } } /// Runs `block` on the SDK queue and returns its result (used by tests). func sync(_ block: () -> T) -> T { queue.sync(execute: block) } // MARK: Queue private func track(_ event: [String: Any]) { let timestamp = Int64((clock().timeIntervalSince1970 * 1000).rounded()) let up = uptime() queue.async { self.enqueue(event, timestamp: timestamp, uptime: up) } } private func enqueue(_ e: [String: Any], timestamp: Int64, uptime up: TimeInterval) { guard enabled else { return } if sessionId.isEmpty { sessionId = Self.newSessionId() } else if up - lastActivity >= Self.sessionTimeout { rotateSession("inactivity") } lastActivity = up var event = e event["timestamp"] = timestamp guard let data = Self.jsonData(event) else { return log("dropped invalid event \(e)") } items.append(Item(event: event, session: sessionId, bytes: data.count, timestamp: timestamp)) if items.count > Self.maxQueue { let dropped = items.count - Self.maxQueue items.removeFirst(dropped) log("queue full, dropped \(dropped) oldest event(s)") } log("queued \(event)") if items.count >= flushAt { drain() } else { scheduleFlush() } } static func newSessionId() -> String { randomId(sessionIdLength) } static func randomId(_ length: Int) -> String { var generator = SystemRandomNumberGenerator() // cryptographically secure on Apple platforms return String((0.. now { scheduleRetry(retryAt - now) return drainFinished() } guard let batch = takeBatch() else { return drainFinished() } guard let body = makeBody(batch) else { log("could not encode batch — \(batch.items.count) event(s) dropped") drain() return } var request = URLRequest(url: endpoint, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") // replaces the default "/ CFNetwork/… Darwin/…" User-Agent request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.httpShouldHandleCookies = false request.httpBody = body inFlight = true let myEpoch = epoch transport(request) { [weak self] status, retryAfter in guard let self = self else { return } self.queue.async { self.inFlight = false self.handleResponse(batch: batch, status: status, retryAfter: retryAfter, epoch: myEpoch) } } } private func handleResponse(batch: Batch, status: Int?, retryAfter: String?, epoch myEpoch: Int) { // tracking was disabled meanwhile: the batch is discarded and no retry pause is kept guard myEpoch == epoch else { drain() return } if let code = status, (200..<300).contains(code) { attempt = 0 retryAt = 0 log("sent \(batch.items.count) event(s)") drain() return } if let code = status, code != 408, code != 429, code < 500 { attempt = 0 retryAt = 0 log("server rejected batch (HTTP \(code)) — \(batch.items.count) event(s) dropped") drain() return } // network error, 408, 429, 5xx → keep events and retry the same batch (same batch_id) with backoff guard enabled else { return drainFinished() } items.insert(contentsOf: batch.items, at: 0) if items.count > Self.maxQueue { items.removeFirst(items.count - Self.maxQueue) } let backoff = min(Self.maxBackoff, pow(2, Double(attempt))) let jittered = backoff * Double.random(in: 0.8...1.2) let delay = min(Self.maxRetryAfter, max(jittered, Self.parseRetryAfter(retryAfter, now: clock()))) attempt = min(attempt + 1, 20) retryAt = uptime() + delay log("send failed (\(status.map { "HTTP \($0)" } ?? "network")), retry in \(Int(delay))s") scheduleRetry(delay) drainFinished() } /// Runs `completion` on the SDK queue once the queue is drained, a retry is scheduled or nothing can be sent. func flushAll(_ completion: @escaping () -> Void) { drainWaiters.append(completion) drain() } private func drainFinished() { guard !drainWaiters.isEmpty else { return } let waiters = drainWaiters drainWaiters.removeAll() for w in waiters { w() } } /// Removes the next batch: same session, ≤ 50 events, ≤ 60 KB. Drops events older than 72 h. /// A batch that failed before is resent unchanged with the same batch_id (server-side dedupe). func takeBatch() -> Batch? { let minTs = Int64(clock().timeIntervalSince1970 * 1000) - Self.maxEventAgeMs while let first = items.first, first.timestamp < minTs { items.removeFirst() } guard let first = items.first else { return nil } let session = first.session let retryId = first.batchId var batch: [Item] = [] var bytes = 512 while batch.count < Self.maxBatch, let next = items.first, next.session == session, next.batchId == retryId { if next.timestamp < minTs { items.removeFirst() continue } if !batch.isEmpty && bytes + next.bytes + 1 > Self.maxBodyBytes { break } bytes += next.bytes + 1 batch.append(items.removeFirst()) } if batch.isEmpty { return nil } let id = retryId ?? Self.randomId(Self.batchIdLength) for i in batch.indices { batch[i].batchId = id } return Batch(items: batch, session: session, id: id) } func makeBody(_ batch: Batch) -> Data? { var ctx: [String: Any] = context ctx["session_id"] = batch.session ctx["sdk"] = Self.userAgent let body: [String: Any] = [ "website_id": websiteId, "batch_id": batch.id, "sent_at": Int64((clock().timeIntervalSince1970 * 1000).rounded()), // lets the server correct a wrong device clock "context": ctx, "events": batch.items.map { $0.event }, ] return Self.jsonData(body) } // MARK: Lifecycle func appDidEnterBackground() { let up = uptime() let flush = { (done: @escaping () -> Void) in self.queue.async { self.backgroundAt = up self.log("app in background — flushing") self.flushAll(done) } } #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) guard observesLifecycle else { return flush {} } // Ask the system for a little background time to finish the request. Unlike // UIApplication.beginBackgroundTask this also works in app extensions. let done = DispatchSemaphore(value: 0) ProcessInfo.processInfo.performExpiringActivity(withReason: "trackd: send analytics batch") { expired in if expired { done.signal() // time is up — stop waiting; unsent events stay queued in memory return } flush { done.signal() } _ = done.wait(timeout: .now() + 25) } #else flush {} #endif } func appWillEnterForeground() { let up = uptime() queue.async { if let bg = self.backgroundAt, up - bg >= Self.sessionTimeout, !self.sessionId.isEmpty { self.rotateSession("background timeout") } self.backgroundAt = nil } } private func observeAppLifecycle() { let center = NotificationCenter.default #if os(iOS) || os(tvOS) || os(visionOS) observers.append(center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in self?.appDidEnterBackground() }) observers.append(center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in self?.appWillEnterForeground() }) #elseif os(macOS) observers.append(center.addObserver(forName: NSApplication.didResignActiveNotification, object: nil, queue: nil) { [weak self] _ in self?.flush() }) observers.append(center.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: nil) { [weak self] _ in self?.flush() }) #elseif os(watchOS) observers.append(center.addObserver(forName: WKExtension.applicationDidEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in self?.appDidEnterBackground() }) observers.append(center.addObserver(forName: WKExtension.applicationWillEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in self?.appWillEnterForeground() }) #endif } // MARK: Helpers static func jsonData(_ object: Any) -> Data? { // isValidJSONObject guards against NaN/Infinity, which would raise an uncatchable ObjC exception guard JSONSerialization.isValidJSONObject(object) else { return nil } return try? JSONSerialization.data(withJSONObject: object, options: []) } /// Keeps only flat String / finite number / Bool values. At most 50 entries and ≤ 4 KB measured like /// the server does (2 + Σ |key| + |value| + 2); the largest values are dropped first. static func sanitizeEventData(_ data: [String: Any]?) -> [String: Any]? { guard let data = data, !data.isEmpty else { return nil } var out: [String: Any] = [:] var sizes: [String: Int] = [:] for (rawKey, value) in data { let key = String(rawKey.prefix(maxKey)) guard !key.isEmpty, out[key] == nil, let clean = normalize(value), let encoded = jsonData([key: clean]) else { continue } out[key] = clean sizes[key] = encoded.count - 1 // {"k":v} = |k| + |v| + 3 → server counts |k| + |v| + 2 } var total = 2 + sizes.values.reduce(0, +) while !out.isEmpty, out.count > maxEventDataKeys || total > maxEventDataBytes { // drop the largest entry first (ties: alphabetically last key) to keep as many fields as possible guard let largest = sizes.max(by: { a, b in a.value != b.value ? a.value < b.value : a.key < b.key }) else { break } out.removeValue(forKey: largest.key) sizes.removeValue(forKey: largest.key) total -= largest.value } return out.isEmpty ? nil : out } static func normalize(_ value: Any) -> Any? { if let s = value as? String { return String(s.prefix(maxString)) } if let s = value as? Substring { return String(s.prefix(maxString)) } if type(of: value) == Bool.self, let b = value as? Bool { return b } #if canImport(ObjectiveC) if let n = value as? NSNumber, type(of: value) is NSNumber.Type, CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue } #endif switch value { case let v as Int: return v case let v as Int8: return Int(v) case let v as Int16: return Int(v) case let v as Int32: return Int(v) case let v as Int64: return v case let v as UInt8: return Int(v) case let v as UInt16: return Int(v) case let v as UInt32: return Int64(v) case let v as UInt: return v <= UInt(Int64.max) ? Int64(v) : nil case let v as UInt64: return v <= UInt64(Int64.max) ? Int64(v) : nil case let v as Double: return v.isFinite ? v : nil case let v as Float: return v.isFinite ? Double(v) : nil case let v as NSNumber: let d = v.doubleValue return d.isFinite ? v : nil default: return nil // nil, arrays, dictionaries, dates, custom objects … } } /// Monotonic seconds that keep counting while the device sleeps (unlike `systemUptime`), so the /// 30-minute session timeout also works across screen-lock / sleep. static func monotonicNow() -> TimeInterval { #if canImport(Darwin) return TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC)) / 1_000_000_000 #else return ProcessInfo.processInfo.systemUptime #endif } static func parseRetryAfter(_ header: String?, now: Date) -> TimeInterval { guard let header = header?.trimmingCharacters(in: .whitespaces), !header.isEmpty else { return 0 } if let secs = Double(header), secs.isFinite { return max(0, secs) } let fmt = DateFormatter() fmt.locale = Locale(identifier: "en_US_POSIX") fmt.timeZone = TimeZone(identifier: "GMT") fmt.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" guard let date = fmt.date(from: header) else { return 0 } return max(0, date.timeIntervalSince(now)) } static func urlSessionTransport() -> Transport { let config = URLSessionConfiguration.ephemeral config.httpCookieStorage = nil config.httpShouldSetCookies = false config.urlCache = nil config.requestCachePolicy = .reloadIgnoringLocalCacheData config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 config.waitsForConnectivity = false let session = URLSession(configuration: config) return { request, completion in session.dataTask(with: request) { _, response, error in if error != nil { completion(nil, nil) return } let http = response as? HTTPURLResponse completion(http?.statusCode, http?.value(forHTTPHeaderField: "Retry-After")) }.resume() } } private func log(_ message: @autoclosure () -> String) { guard debug else { return } #if canImport(os) os_log("%{public}@", log: logger, type: .default, message()) #else print("[trackd] \(message())") #endif } } // MARK: - Device context (coarse, non-identifying) enum DeviceContext { static func collect(bundle: Bundle = .main) -> [String: String] { var ctx: [String: String] = [:] let info = platformInfo() ctx["platform"] = info.platform ctx["device"] = info.device let v = ProcessInfo.processInfo.operatingSystemVersion var version = "\(v.majorVersion).\(v.minorVersion)" if v.patchVersion > 0 { version += ".\(v.patchVersion)" } ctx["os_version"] = String("\(info.osName) \(version)".prefix(50)) if let appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, !appVersion.isEmpty { ctx["app_version"] = String(appVersion.prefix(50)) } if let language = Locale.preferredLanguages.first, !language.isEmpty { ctx["language"] = String(language.prefix(35)) } if let screen = screenResolution() { ctx["screen"] = screen } return ctx } static func platformInfo() -> (platform: String, osName: String, device: String) { #if targetEnvironment(macCatalyst) return ("macos", "macOS", "desktop") #elseif os(iOS) if UIDevice.current.userInterfaceIdiom == .pad { return ("ipados", "iPadOS", "tablet") } return ("ios", "iOS", "mobile") #elseif os(visionOS) return ("visionos", "visionOS", "desktop") #elseif os(tvOS) return ("tvos", "tvOS", "desktop") #elseif os(watchOS) return ("watchos", "watchOS", "mobile") #elseif os(macOS) return ("macos", "macOS", "desktop") #else return ("apple", "Unknown", "desktop") #endif } static func screenResolution() -> String? { #if os(iOS) || os(tvOS) let bounds = UIScreen.main.nativeBounds // pixels, portrait-up let w = Int(min(bounds.width, bounds.height)) let h = Int(max(bounds.width, bounds.height)) return w > 0 && h > 0 ? "\(w)x\(h)" : nil #elseif os(macOS) guard let screen = NSScreen.main else { return nil } let scale = screen.backingScaleFactor let w = Int(screen.frame.width * scale) let h = Int(screen.frame.height * scale) return w > 0 && h > 0 ? "\(w)x\(h)" : nil #elseif os(watchOS) let device = WKInterfaceDevice.current() let w = Int(device.screenBounds.width * device.screenScale) let h = Int(device.screenBounds.height * device.screenScale) return w > 0 && h > 0 ? "\(w)x\(h)" : nil #else return nil #endif } }