// // BrocticAnalytics.swift // LEGACY — Broctic analytics for Apple platforms, the client that sends // product events. // // Not the recommended path any more. Step 6 of // docs/plans/funnel-experiments-plan.md (2026-09-15) made the recommended path the // Studio kit + PostHog: a Studio app ships kit/analytics.ts, which dual-writes // to POST /v1/events and to the app's own PostHog project from the first // build, and session replay, feature flags and experiments come from that // PostHog project. This file cannot do any of them. // // It is kept, and still served at /sdk/BrocticAnalytics.swift, for the case // the kit does not cover: a native Apple app Broctic did not generate, which // would otherwise have only the raw POST /v1/events contract. // // Zero dependencies and one file on purpose: it is published as a drop-in // that a project copies in. Everything it needs is in this file. // // It is the Swift twin of `createBrocticAnalytics` in @broctic/sdk. Same wire // format, same batching, same retry rules, same anonymous-id semantics — so a // funnel does not change shape when an app is native rather than React Native. // // Usage — once, at launch: // // Broctic.start(key: "bpk_…") // // Then, anywhere: // // Broctic.track("paywall_viewed", ["placement": "onboarding"]) // Broctic.identify("user_123") // after sign-in, optional // Broctic.screen("Map") // await Broctic.flush() // when going to the background // // The key is *publishable*: it can only write events for one app, and it is // expected to be extracted from the binary, so being extracted costs nothing. // An organization API key (`brk_…`) must never be used here. // // Requires iOS 15 / macOS 12 / tvOS 15 / watchOS 8 or newer. // import Foundation #if canImport(UIKit) && !os(watchOS) import UIKit #endif // MARK: - Public surface /// A property value. Scalars only — the API rejects nesting rather than /// flattening it, because a funnel cannot filter on a value nobody can name. public enum BrocticValue: Sendable, Encodable { case string(String) case int(Int) case double(Double) case bool(Bool) case null public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer() switch self { case .string(let v): try c.encode(String(v.prefix(500))) case .int(let v): try c.encode(v) case .double(let v): try c.encode(v.isFinite ? v : 0) case .bool(let v): try c.encode(v) case .null: try c.encodeNil() } } } extension BrocticValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByNilLiteral { public init(stringLiteral value: String) { self = .string(value) } public init(integerLiteral value: Int) { self = .int(value) } public init(floatLiteral value: Double) { self = .double(value) } public init(booleanLiteral value: Bool) { self = .bool(value) } public init(nilLiteral: ()) { self = .null } } public typealias BrocticProps = [String: BrocticValue] public struct BrocticOptions: Sendable { /// The app's publishable key, from the app's Events page in the console. public var key: String /// Defaults to https://api.broctic.com. public var baseURL: URL /// Events are sent after this many seconds, or sooner when the batch fills. public var flushInterval: TimeInterval /// Never more than 100 — the API's limit for one request. public var maxBatchSize: Int /// Called with anything that goes wrong. Defaults to silence: analytics /// must never be the reason an app misbehaves. public var onError: (@Sendable (Error) -> Void)? public init( key: String, baseURL: URL = URL(string: "https://api.broctic.com")!, flushInterval: TimeInterval = 3, maxBatchSize: Int = 20, onError: (@Sendable (Error) -> Void)? = nil ) { self.key = key self.baseURL = baseURL self.flushInterval = max(0.25, flushInterval) self.maxBatchSize = min(100, max(1, maxBatchSize)) self.onError = onError } } /// The one you call. A façade over a single shared client so instrumentation /// at a call site is one line and never has to reach for a dependency. /// /// Every method is safe to call before `start` and safe to call from any /// thread. Before `start`, events are dropped rather than queued: an app that /// forgot to configure the key should send nothing, not a burst of events with /// no key the moment one appears. public enum Broctic { private static let lock = NSLock() nonisolated(unsafe) private static var client: BrocticAnalytics? /// Configure once, as early as possible — `application(_:didFinishLaunchingWithOptions:)` /// or the `App` initialiser. Calling it twice replaces the client and /// flushes the old one. public static func start(_ options: BrocticOptions) { let fresh = BrocticAnalytics(options: options) lock.lock() let previous = client client = fresh lock.unlock() if let previous { Task { await previous.flush() } } } /// Convenience for the common case. public static func start(key: String, baseURL: URL? = nil) { if let baseURL { start(BrocticOptions(key: key, baseURL: baseURL)) } else { start(BrocticOptions(key: key)) } } private static var current: BrocticAnalytics? { lock.lock(); defer { lock.unlock() } return client } /// Record that something happened. `name` is `lowercase_with_underscores`. public static func track(_ name: String, _ props: BrocticProps? = nil) { current?.track(name, props) } /// Attach a user id to everything from now on. Call after sign-in; pass /// nil on sign-out. Never an email — an opaque id. public static func identify(_ userId: String?) { current?.identify(userId) } /// Shorthand for `track("screen_viewed", ["screen": …])`. public static func screen(_ screen: String, _ props: BrocticProps? = nil) { current?.screen(screen, props) } /// Send whatever is queued now. public static func flush() async { await current?.flush() } /// The anonymous id in use — useful on a support ticket. public static var anonymousId: String? { current?.anonymousId } /// Testing seam: drop the shared client. public static func reset() { lock.lock(); client = nil; lock.unlock() } } // MARK: - The client /// The batching client. Use `Broctic` rather than this directly unless you /// need two clients in one process (a test, or an app that owns two Broctic /// apps — rare). public final class BrocticAnalytics: @unchecked Sendable { private let options: BrocticOptions private let session: URLSession private let defaults: UserDefaults private let store: Store private static let anonymousKey = "broctic.anonymousId" private static let queueKey = "broctic.queue" /// A gap this long starts a new session, matching the JS client so the /// same person's sessions are counted the same way on both. private static let sessionGap: TimeInterval = 30 * 60 /// Unsent events are capped so a device that has been offline for a month /// cannot grow an unbounded buffer. Oldest go first. private static let maxQueue = 500 public init(options: BrocticOptions, defaults: UserDefaults = .standard, session: URLSession? = nil) { self.options = options self.defaults = defaults if let session { self.session = session } else { let config = URLSessionConfiguration.default config.waitsForConnectivity = false config.timeoutIntervalForRequest = 15 self.session = URLSession(configuration: config) } let anonymous = Self.loadAnonymousId(defaults) self.store = Store( anonymousId: anonymous, restored: Self.loadQueue(defaults), maxQueue: Self.maxQueue, batchSize: options.maxBatchSize, sessionGap: Self.sessionGap ) self.observeLifecycle() } public var anonymousId: String { store.anonymousIdValue } public func track(_ name: String, _ props: BrocticProps? = nil) { // Names are validated here rather than server-side only, because a // rejected batch is silent by design and a typo would otherwise cost // a week of data before anyone noticed. guard Self.isValidEventName(name) else { options.onError?(BrocticError.invalidEventName(name)) return } Task { [weak self] in guard let self else { return } let full = await self.store.enqueue(name: name, props: props) self.persistQueue(await self.store.snapshot()) if full { await self.flush() } else { await self.schedule() } } } public func identify(_ userId: String?) { Task { [weak self] in await self?.store.setUser(userId) } } public func screen(_ screen: String, _ props: BrocticProps? = nil) { var merged = props ?? [:] merged["screen"] = .string(screen) track("screen_viewed", merged) } public func flush() async { // One flush at a time. A second caller returns immediately rather than // sending the same batch twice — the timer and the lifecycle observer // both fire into this. guard await store.beginSending() else { return } while true { let batch = await store.take(options.maxBatchSize) if batch.isEmpty { break } let outcome = await send(batch) switch outcome { case .delivered, .rejected: await store.drop(batch.count) case .retryLater: // Left at the head of the queue for the next flush. break } if outcome == .retryLater { break } } persistQueue(await store.snapshot()) // Released explicitly rather than in a `defer`: a deferred `Task` runs // after this function returns, which leaves a window where the next // flush is refused for no reason. Nothing between here and the guard // can throw, so there is no path that skips it. await store.endSending() } // MARK: Sending private enum Outcome: Equatable { case delivered, rejected, retryLater } private func send(_ batch: [Event]) async -> Outcome { var request = URLRequest(url: options.baseURL.appendingPathComponent("v1/events")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(options.key, forHTTPHeaderField: "x-broctic-key") do { request.httpBody = try JSONEncoder().encode(Batch(events: batch, context: .current)) } catch { // An un-encodable batch will never encode. Dropping it is the only // termination that does not spin forever on the same payload. options.onError?(error) return .rejected } do { let (_, response) = try await session.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 if (200..<300).contains(status) { return .delivered } // 4xx means the batch itself is wrong — a bad key, a bad shape — // and retrying cannot fix it. 429 is the exception: it is a // "later", not a "never". if status == 429 { return .retryLater } if (400..<500).contains(status) { options.onError?(BrocticError.rejected(status: status)) return .rejected } return .retryLater } catch { options.onError?(error) return .retryLater } } private func schedule() async { guard await store.armTimer() else { return } let interval = options.flushInterval Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) guard let self else { return } await self.store.disarmTimer() await self.flush() } } // MARK: Persistence private static func loadAnonymousId(_ defaults: UserDefaults) -> String { if let existing = defaults.string(forKey: anonymousKey), existing.count >= 8 { return existing } let fresh = UUID().uuidString defaults.set(fresh, forKey: anonymousKey) return fresh } private static func loadQueue(_ defaults: UserDefaults) -> [Event] { guard let data = defaults.data(forKey: queueKey) else { return [] } // A queue that fails to decode is a queue from an older shape. Dropping // it loses at most one app run's events; keeping it would fail forever. return (try? JSONDecoder().decode([Event].self, from: data)) ?? [] } private func persistQueue(_ events: [Event]) { if events.isEmpty { defaults.removeObject(forKey: Self.queueKey) return } guard let data = try? JSONEncoder().encode(events) else { return } defaults.set(data, forKey: Self.queueKey) } // MARK: Lifecycle /// Flush when the app leaves the foreground. Without this, everything from /// the last few seconds of a session is lost on the launches that matter /// most — the ones that end at a paywall. private func observeLifecycle() { #if canImport(UIKit) && !os(watchOS) let center = NotificationCenter.default for name in [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification] { center.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in guard let self else { return } Task { await self.flush() } } } #endif } // MARK: Validation /// The API's rule, checked locally: lowercase letters, digits and /// underscores, starting with a letter, at most 64 characters. static func isValidEventName(_ name: String) -> Bool { guard (1...64).contains(name.count), let first = name.first, first.isLowercase, first.isLetter else { return false } return name.allSatisfy { $0.isNumber || $0 == "_" || ($0.isLowercase && $0.isLetter) } } } public enum BrocticError: Error, CustomStringConvertible { case invalidEventName(String) case rejected(status: Int) public var description: String { switch self { case .invalidEventName(let name): return "Broctic: '\(name)' is not a valid event name — use lowercase_with_underscores." case .rejected(let status): return "Broctic rejected the batch (\(status)). Check the publishable key." } } } // MARK: - Wire types private struct Event: Codable, Sendable { let name: String let ts: String let anonymousId: String let userId: String? let sessionId: String let props: [String: BrocticValue]? // Decoding only ever reads back what this file wrote, so the value shape // is known: a queue written by an older version simply fails to decode and // is discarded, which is handled above. init(name: String, ts: String, anonymousId: String, userId: String?, sessionId: String, props: BrocticProps?) { self.name = name self.ts = ts self.anonymousId = anonymousId self.userId = userId self.sessionId = sessionId self.props = props } } extension BrocticValue: Decodable { public init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() if c.decodeNil() { self = .null; return } if let v = try? c.decode(Bool.self) { self = .bool(v); return } if let v = try? c.decode(Int.self) { self = .int(v); return } if let v = try? c.decode(Double.self) { self = .double(v); return } self = .string(try c.decode(String.self)) } } private struct Batch: Encodable { let events: [Event] let context: Context } /// Context the client attaches once per batch rather than once per event. /// Filled in from the platform rather than asked for, because every value here /// is one the app already knows and one an integrator would otherwise get /// subtly wrong. private struct Context: Encodable { let platform: String let appVersion: String? let osVersion: String? let locale: String? let country: String? static var current: Context { #if os(iOS) let platform = "ios" #elseif os(macOS) let platform = "macos" #elseif os(tvOS) || os(watchOS) || os(visionOS) let platform = "other" #else let platform = "other" #endif let os = ProcessInfo.processInfo.operatingSystemVersion let region: String? if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { region = Locale.current.region?.identifier } else { region = Locale.current.regionCode } return Context( platform: platform, appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, osVersion: "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)", locale: Locale.current.identifier, // The API takes two letters. Anything else is left off rather than // truncated into a different country. country: (region?.count == 2) ? region : nil ) } } // MARK: - State /// The mutable state, isolated. An actor rather than a lock because every /// mutation here is already reached from an async context, and the queue is /// touched from whatever thread happened to call `track`. private actor Store { private var queue: [Event] private var userId: String? private var sessionId = UUID().uuidString private var lastActivity = Date.distantPast private var timerArmed = false private var sending = false private let anonymousId: String private let maxQueue: Int private let batchSize: Int private let sessionGap: TimeInterval /// Held by the actor rather than as a shared static. `ISO8601DateFormatter` /// is a class with internal mutable state and is not `Sendable`, so a /// global instance is a data race that Swift 6 rejects outright — and the /// formatter is only ever used from inside this actor anyway. /// /// Fractional seconds because two events in the same millisecond are /// ordinary at a screen transition, and the funnel orders by timestamp. private let iso: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() /// Readable without awaiting: it is written once, at init, and never again. nonisolated let anonymousIdValue: String init(anonymousId: String, restored: [Event], maxQueue: Int, batchSize: Int, sessionGap: TimeInterval) { self.anonymousId = anonymousId self.anonymousIdValue = anonymousId self.queue = restored self.maxQueue = maxQueue self.batchSize = batchSize self.sessionGap = sessionGap } func setUser(_ id: String?) { userId = id } /// Appends, and answers whether the batch is now worth sending immediately. func enqueue(name: String, props: BrocticProps?) -> Bool { let now = Date() if now.timeIntervalSince(lastActivity) > sessionGap { sessionId = UUID().uuidString } lastActivity = now queue.append(Event( name: name, ts: iso.string(from: now), anonymousId: anonymousId, userId: userId, sessionId: sessionId, props: props )) if queue.count > maxQueue { queue.removeFirst(queue.count - maxQueue) } return queue.count >= batchSize } func take(_ n: Int) -> [Event] { Array(queue.prefix(n)) } func drop(_ n: Int) { queue.removeFirst(min(n, queue.count)) } func snapshot() -> [Event] { queue } func beginSending() -> Bool { if sending { return false } sending = true return true } func endSending() { sending = false } func armTimer() -> Bool { if timerArmed { return false } timerArmed = true return true } func disarmTimer() { timerArmed = false } }