Instrument your app with events

A Studio app is instrumented by its kit with nothing to install. For an imported app: the legacy drop-in client and how to send events.

Why events, when Apple already reports sales

App Store Connect tells you what happened at the store: downloads, trials, payments. It says nothing about the forty seconds between the first open and the paywall, which is where most apps lose most people. Apple can tell you 4,000 people downloaded the app and 60 started a trial. It cannot tell you that 2,300 of them never finished onboarding.

Events fill that gap, and the funnel view joins the two, so a drop can be placed between "installed" and "paid" rather than merely counted.

Which of these pages is yours

If Broctic built the app, you are already done. The Studio's kit ships kit/analytics.ts: every track() goes to POST /v1/events from the first build, and also to the app's own PostHog project once one is linked. There is no file to copy and no package to add, and onboarding A/B tests need nothing more — Broctic assigns the arms itself. Linking a PostHog project is optional, and is what turns on session replay. Skip to the event vocabulary and ignore the drop-in sections entirely — adding one to a Studio app is a second analytics client sending every event twice.

If you imported an app, or wrote it yourself, the drop-in clients below are for you. Broctic does not inject a client into an imported project, so without one of them the only option is the raw HTTP contract. They are legacy in the sense that they are no longer what Broctic recommends or builds on — they write to /v1/events and nothing else, and do not fetch an onboarding arm or record sessions — but they are maintained, they are the same wire format as the kit, and a funnel does not change shape because you used one.

/v1/events is not going anywhere, on either path. It is what the App Store join is computed from — download → trial → paid out of Apple's own rows — and what the benchmarked recommendations read. PostHog has never seen the App Store, and will happily draw a confident funnel on six people; the fifty-person rule that refuses to is ours.

The drop-in client (imported and hand-written apps)

Legacy path. A Studio app is instrumented by its kit and must not be given one of these files. This section is for an app Broctic did not generate.

One file, zero dependencies, no package to add. There are two of them — Swift for a native Apple app, TypeScript for Expo, React Native, the browser and Node — and they are the same client: same wire format, same batching, same retry rules, same anonymous-id semantics. A funnel does not change shape because an app is native.

They send to /v1/events only. To get session replay on an imported app, add the PostHog SDK alongside one of these and link the project — the same dual-write the Studio kit does, assembled by hand.

Both batch events, retry what failed, persist the anonymous id and unsent events across launches, and never throw into your app — an analytics library that can crash a paywall is worse than no analytics.

Swift — native iOS, iPadOS, macOS

Drag BrocticAnalytics.swift into your Xcode target. No Swift Package, no CocoaPod, nothing to resolve. It needs iOS 15 or newer and compiles clean under Swift 6 strict concurrency.

BrocticAnalytics.swift
//
//  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 }
}

Then call it. Broctic.start once at launch, and Broctic.track anywhere — it is safe to call from any thread and before start (it drops rather than queues, so a build with no key sends nothing).

usage
// RegistryRadarApp.swift — configure once, as early as the app starts.
import SwiftUI

@main
struct MyApp: App {
    init() {
        Broctic.start(key: "bpk_…")   // safe to ship; it can only write events
        Broctic.track("app_opened")
    }

    var body: some Scene {
        WindowGroup { RootView() }
    }
}

// OnboardingFlow.swift
.onAppear { Broctic.track("onboarding_started") }

Broctic.track("onboarding_step", ["step": 2, "name": "focus"])
Broctic.track("onboarding_completed")

// Paywall.swift
.task { Broctic.track("paywall_viewed", ["placement": "onboarding"]) }

// StoreKit
Broctic.track("purchase_started", ["product": product.id])
Broctic.track("trial_started", [
    "product": product.id,
    "placement": "onboarding",
    "trial_days": 14,
])
Broctic.track("purchase_completed", [
    "product": product.id,
    "price": (product.price as NSDecimalNumber).doubleValue,
    "currency": product.priceFormatStyle.currencyCode,
])

// After sign-in, if the app has accounts. Your own opaque id — never an email.
Broctic.identify(user.id)
The Swift client fills in platform, app version, OS version, locale and country itself, from the bundle and the system. Those are the five context values an integrator most often gets subtly wrong, and every one of them is already known to the app.

Expo and React Native

Copy the TypeScript client into your project as broctic-analytics.ts:

broctic-analytics.ts
/**
 * LEGACY — Broctic analytics for apps, 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*: an app built in the Studio ships
 * `kit/analytics.ts`, which dual-writes every `track()` to `POST /v1/events`
 * **and** to the app's own PostHog project, from the first build, with nothing
 * for anybody to install. Session replay, feature flags and A/B experiments
 * come from linking that PostHog project; this file cannot do any of them and
 * never will.
 *
 * It is kept, and still served at `/sdk/broctic-analytics.ts`, for the one
 * case the kit does not cover: an **imported or hand-written app** that
 * Broctic did not generate. `modules/studio/import.ts` injects no client, so
 * without this file such an app has only the raw `POST /v1/events` contract.
 * Do not add it to a Studio app — that is two analytics clients sending the
 * same events twice.
 *
 * Zero dependencies and one file on purpose: it is also published as a drop-in
 * (`broctic-analytics.ts`) that a project copies in without adding a package.
 * Everything it needs is in this file. It runs in Expo, React Native, the
 * browser and Node.
 *
 * Usage:
 *
 *   const analytics = createBrocticAnalytics({
 *     key: "bpk_…",                       // the app's publishable key
 *     appVersion: "1.4.0",
 *     platform: "ios",
 *     storage: AsyncStorage,              // optional: persists the anonymous id
 *   });
 *
 *   analytics.track("onboarding_completed");
 *   analytics.track("paywall_viewed", { placement: "onboarding" });
 *   analytics.identify("user_123");       // after sign-in, optional
 *   analytics.screen("Home");
 *
 * The key is *publishable*: it can only write events for one app, and it is
 * expected to be extracted from the binary. An organization API key (`brk_…`)
 * must never be used here.
 */

export type BrocticPlatform = "ios" | "android" | "web" | "macos" | "other";

export type BrocticEventProps = Record<string, string | number | boolean | null>;

/** Anything with getItem/setItem — AsyncStorage, localStorage, MMKV via a
 *  two-line adapter. Used only to persist the anonymous id and any unsent
 *  events across launches. Without it, ids are per-launch and unsent events
 *  are lost when the app closes. */
export interface BrocticStorage {
  getItem(key: string): Promise<string | null> | string | null;
  setItem(key: string, value: string): Promise<void> | void;
}

export interface BrocticAnalyticsOptions {
  /** The app's publishable key, from the app's Events page in the console. */
  key: string;
  platform?: BrocticPlatform;
  appVersion?: string;
  osVersion?: string;
  locale?: string;
  /** Two-letter region, if the app knows it. */
  country?: string;
  storage?: BrocticStorage;
  /** Defaults to https://api.broctic.com. */
  baseUrl?: string;
  /** Events are batched and sent after this many milliseconds, or sooner when
   *  the batch is full. Default 3000. */
  flushIntervalMs?: number;
  /** Default 20. Never more than 100. */
  maxBatchSize?: number;
  /** Optional fetch override (tests). */
  fetch?: typeof fetch;
  /** Called with anything that goes wrong. Defaults to silence — analytics
   *  must never crash the app. */
  onError?: (error: unknown) => void;
}

interface QueuedEvent {
  name: string;
  ts: string;
  anonymousId: string;
  userId?: string;
  sessionId: string;
  props?: BrocticEventProps;
}

const STORAGE_ANON = "broctic.anonymousId";
const STORAGE_QUEUE = "broctic.queue";
const MAX_QUEUE = 500;
const SESSION_GAP_MS = 30 * 60 * 1000;

function randomId(): string {
  const g = globalThis as { crypto?: { randomUUID?: () => string } };
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
  let out = "";
  for (let i = 0; i < 32; i += 1) out += Math.floor(Math.random() * 16).toString(16);
  return out;
}

export interface BrocticAnalytics {
  /** Record that something happened. `name` is `lowercase_with_underscores`. */
  track(name: string, props?: BrocticEventProps): void;
  /** Attach a user id to everything from now on. Call after sign-in. */
  identify(userId: string | null): void;
  /** Shorthand for `track("screen_viewed", { screen })`. */
  screen(screen: string, props?: BrocticEventProps): void;
  /** Send whatever is queued now. Call when the app goes to the background. */
  flush(): Promise<void>;
  /** The anonymous id in use — useful for support tickets. */
  anonymousId(): Promise<string>;
}

export function createBrocticAnalytics(options: BrocticAnalyticsOptions): BrocticAnalytics {
  const baseUrl = (options.baseUrl ?? "https://api.broctic.com").replace(/\/+$/, "");
  const doFetch = options.fetch ?? globalThis.fetch;
  const onError = options.onError ?? (() => {});
  const maxBatch = Math.min(100, Math.max(1, options.maxBatchSize ?? 20));
  const interval = Math.max(250, options.flushIntervalMs ?? 3000);

  let queue: QueuedEvent[] = [];
  let userId: string | undefined;
  let sessionId = randomId();
  let lastActivity = Date.now();
  let timer: ReturnType<typeof setTimeout> | null = null;
  let sending: Promise<void> | null = null;

  // The anonymous id is resolved once, asynchronously, because storage may be
  // async; events tracked before it resolves wait for it rather than being
  // stamped with a temporary id that would split one person into two.
  const anonymous: Promise<string> = (async () => {
    try {
      const stored = await options.storage?.getItem(STORAGE_ANON);
      if (stored) return stored;
    } catch (err) {
      onError(err);
    }
    const fresh = randomId();
    try {
      await options.storage?.setItem(STORAGE_ANON, fresh);
    } catch (err) {
      onError(err);
    }
    return fresh;
  })();

  // Events that did not get sent last time the app ran.
  const restored: Promise<void> = (async () => {
    try {
      const raw = await options.storage?.getItem(STORAGE_QUEUE);
      if (raw) {
        const parsed = JSON.parse(raw) as QueuedEvent[];
        if (Array.isArray(parsed)) queue = [...parsed, ...queue].slice(-MAX_QUEUE);
        await options.storage?.setItem(STORAGE_QUEUE, "[]");
      }
    } catch (err) {
      onError(err);
    }
  })();

  function persistQueue(): void {
    if (!options.storage) return;
    try {
      void options.storage.setItem(STORAGE_QUEUE, JSON.stringify(queue));
    } catch (err) {
      onError(err);
    }
  }

  function touchSession(): void {
    const now = Date.now();
    if (now - lastActivity > SESSION_GAP_MS) sessionId = randomId();
    lastActivity = now;
  }

  function schedule(): void {
    if (timer) return;
    timer = setTimeout(() => {
      timer = null;
      void flush();
    }, interval);
  }

  async function send(batch: QueuedEvent[]): Promise<boolean> {
    const context = {
      platform: options.platform,
      appVersion: options.appVersion,
      osVersion: options.osVersion,
      locale: options.locale,
      country: options.country,
    };
    try {
      const res = await doFetch(`${baseUrl}/v1/events`, {
        method: "POST",
        headers: { "content-type": "application/json", "x-broctic-key": options.key },
        body: JSON.stringify({ events: batch, context }),
        keepalive: true,
      });
      // 4xx means the batch itself is wrong (bad key, bad shape) and retrying
      // will not help; drop it rather than retry forever. 5xx and network
      // failures are retried next flush.
      if (res.ok) return true;
      if (res.status >= 400 && res.status < 500) {
        onError(new Error(`Broctic rejected events (${res.status})`));
        return true;
      }
      return false;
    } catch (err) {
      onError(err);
      return false;
    }
  }

  async function flush(): Promise<void> {
    if (sending) return sending;
    sending = (async () => {
      await restored;
      while (queue.length > 0) {
        const batch = queue.slice(0, maxBatch);
        const ok = await send(batch);
        if (!ok) break;
        queue = queue.slice(batch.length);
      }
      persistQueue();
    })().finally(() => {
      sending = null;
    });
    return sending;
  }

  function enqueue(name: string, props?: BrocticEventProps): void {
    touchSession();
    const ts = new Date().toISOString();
    const sid = sessionId;
    void anonymous.then((anonymousId) => {
      queue.push({ name, ts, anonymousId, userId, sessionId: sid, props });
      if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
      persistQueue();
      if (queue.length >= maxBatch) void flush();
      else schedule();
    });
  }

  // In a browser, send what is left when the page is hidden. `keepalive` above
  // lets the request outlive the page.
  const doc = (globalThis as { document?: { addEventListener?: Function; visibilityState?: string } }).document;
  if (doc?.addEventListener) {
    doc.addEventListener("visibilitychange", () => {
      if (doc.visibilityState === "hidden") void flush();
    });
  }

  return {
    track: (name, props) => enqueue(name, props),
    identify: (id) => {
      userId = id ?? undefined;
    },
    screen: (screen, props) => enqueue("screen_viewed", { ...props, screen }),
    flush,
    anonymousId: () => anonymous,
  };
}

Give it AsyncStorage (or anything with getItem/setItem — MMKV needs a two-line adapter). Without storage the anonymous id is per-launch, which splits one person into a new person on every cold start and makes every funnel rate wrong in the same optimistic direction.

analytics.ts
// analytics.ts — one module the whole app imports.
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Application from "expo-application";
import { createBrocticAnalytics } from "./broctic-analytics";

export const analytics = createBrocticAnalytics({
  key: process.env.EXPO_PUBLIC_BROCTIC_KEY!, // bpk_… — safe to ship
  platform: "ios",
  appVersion: Application.nativeApplicationVersion ?? undefined,
  storage: AsyncStorage, // persists the anonymous id and unsent events
});

// App.tsx
import { AppState } from "react-native";
import { analytics } from "./analytics";

analytics.track("app_opened");

// Analytics must never lose a batch because the app was backgrounded.
AppState.addEventListener("change", (state) => {
  if (state !== "active") void analytics.flush();
});

// Paywall.tsx
useEffect(() => {
  analytics.track("paywall_viewed", { placement: "onboarding" });
}, []);

// After a successful sign-in
analytics.identify(user.id);
analytics.track("signup_completed");
Put the key in EXPO_PUBLIC_BROCTIC_KEY rather than inline, not because it is secret — it is not — but so a build for a different app cannot ship pointing at the wrong one.