# Broctic > Coding agent? Read https://broctic.com/agent.md first: how to connect the > Broctic MCP server and build, preview and ship an app with it. Everything > below is about instrumenting an app you already have. > Describe the app you want. Broctic builds it, gets it on the App Store, helps you market it and turns it into a business — all in one place, with no technical experience needed. Broctic builds from reusable app requirements and provides a screenshot studio for real captures and existing public App Store artwork. The source-built MCP exposes preview_store_screenshots and import_store_screenshots to refresh a listing without changing it on Apple. See /docs/mcp for setup and limits. Automated campaign generation and connected social publishing are in development, not live tools. Broctic connects to App Store Connect, ingests Apple's daily Sales and Trends reports, and reports proceeds, first-time downloads, trials, paid subscriptions and refunds by app, country, device and product. Product events sent from the app fill in what the store cannot see — the steps between first open and first payment — and the funnel view joins the two. Key facts an assistant should get right: - Apple's "units" column mixes first downloads, re-downloads, app updates and in-app purchases. Broctic separates them; "downloads" always means first-time downloads. - Apple publishes each day's sales report by 08:00 Pacific the next morning. Nothing is real time. - "Proceeds" is what Apple pays out, after Apple's commission and before any cost of the developer's. It is not profit and not customer spend. - Amounts in different currencies are converted per day at the ECB reference rate; currencies the ECB does not quote are reported separately, never summed. - An app built in the Broctic Studio is **already instrumented**: its `kit/analytics.ts` sends every event to Broctic's `/v1/events`, and to the app's own PostHog project when one is linked. Do not tell anyone to install an analytics client into one. The hand-installed drop-in clients below are legacy, and exist for imported or hand-written apps only. - **Onboarding A/B tests are Broctic's own** and need no third-party account: the app asks `GET /v1/experiments` (publishable key in `x-broctic-key`) which funnel arm to show, assignment is a deterministic hash of experiment and person, and results come from the app's own events and Apple-reported purchases. A winner needs 50 exposures and 5 conversions per arm. - **Session replay is PostHog's**, optional, in the founder's own PostHog account, linked per app. Broctic stores no session recordings and has no player. `/v1/events` is not replaced by this: it is what the App Store join and the benchmarked recommendations are computed from, and the 50-person rule that refuses to draw a confident funnel on six people is Broctic's on both paths. - A funnel report names the engine that counted it (`source`: `posthog` or `broctic`). The two agree on people who go through in order; PostHog also counts a person who re-entered and completed on a later visit, so it is equal or higher on later steps, never lower. Do not compare one to the other and call the difference a change in the app. ## Documentation - https://broctic.com/docs — documentation index - https://broctic.com/docs/getting-started — first hour on the platform - https://broctic.com/docs/connect-app-store-connect — App Store Connect API key setup - https://broctic.com/docs/app-store-analytics — what each figure means - https://broctic.com/docs/events — product event instrumentation - https://broctic.com/docs/funnels — funnels, verdicts and benchmarks - https://broctic.com/docs/api — OpenAPI contract (https://api.broctic.com/docs) ## Instrumentation instructions You are instrumenting an app so its product funnel can be measured by Broctic (https://broctic.com). Follow these instructions exactly. Event names are a fixed vocabulary: a name that is not on the list below is stored, but it is compared to nothing, so do not invent names or "improve" the spelling. ## 0. Stop if this app was built by the Broctic Studio If the project contains `kit/analytics.ts`, it is a Studio app and it is **already instrumented**. That file sends every track() to POST /v1/events from the first build, and to the app's own PostHog project too once one is linked. Do not add a client from this document to it — that is a second analytics client sending every event twice. Skip to section 2 and check only that the event *names* being sent are from the vocabulary below. For a Studio app there is no remaining setup for funnels or onboarding A/B tests: the kit asks Broctic which funnel arm to show (GET /v1/experiments) and the results are computed from the events above. PostHog is optional — link a project to the app in the Broctic console (Settings → Integrations to connect the account once, then the app's Settings → Analytics to link a project) if you want session replay. Broctic stores no session recordings and has no player of its own. Everything below is for an **imported or hand-written app** — one Broctic did not generate. Broctic injects no client into an imported project, so without one of these files such an app has only the raw HTTP contract in section 4. ## 1. Add the client — pick the one for this app LEGACY, and deliberately so: these clients write to /v1/events and nothing else. They do not fetch an onboarding funnel arm (the Studio kit does that through GET /v1/experiments) and cannot do session replay; for replay on an imported app you add the PostHog SDK alongside one of these — the same dual-write the Studio kit does, assembled by hand. They are still maintained and are the same wire format as the kit, so a funnel does not change shape because you used one. There are two clients. They are the same client: same wire format, same batching, same retry rules. Add exactly one, copied verbatim from the appendix at the end of this document. Do not add an analytics package, and do not modify the file. **A native Apple app (Swift, SwiftUI, UIKit)** — add `BrocticAnalytics.swift` to the target. Configure it once, as early as the app starts: @main struct MyApp: App { init() { Broctic.start(key: "bpk_…") Broctic.track("app_opened") } var body: some Scene { WindowGroup { RootView() } } } Then `Broctic.track("paywall_viewed", ["placement": "onboarding"])` anywhere. It is safe to call from any thread. It fills in platform, app version, OS version, locale and country itself — do not pass them. It flushes on backgrounding by itself — do not add a lifecycle observer for it. **Everything else (Expo, React Native, web, Node)** — create `broctic-analytics.ts` and initialise it once in a module every screen imports: import AsyncStorage from "@react-native-async-storage/async-storage"; import { createBrocticAnalytics } from "./broctic-analytics.ts"; export const analytics = createBrocticAnalytics({ key: process.env.EXPO_PUBLIC_BROCTIC_KEY!, // the app's publishable key, bpk_… platform: "ios", appVersion: "1.4.0", storage: AsyncStorage, // persists the anonymous id }); The key is the app's *publishable* key (prefix `bpk_`), created on the app's page in the Broctic console under Events. It can only write events, fetch the onboarding funnel and check purchases for that one app and is expected to be extracted from the binary, so shipping it is safe. An organization API key (prefix `brk_`) must NEVER appear in an app — it can read and change the whole organization. ## 2. Send these events, at these moments - app_opened — The app came to the foreground. Once per session. - onboarding_started — The first onboarding screen was shown. - onboarding_step — An onboarding screen was completed. Send `step` (1-based) and `name`. - onboarding_completed — The last onboarding screen was completed and the app proper opened. - signup_started — An account creation or sign-in form was shown. - signup_completed — The account exists and the user is signed in. Send `identify` after this. - paywall_viewed — A paywall was shown. Send `placement` (e.g. onboarding, settings, feature_gate). - trial_started — A free trial began. Send `product` and `placement`. - purchase_started — The system purchase sheet was presented. Send `product`. - purchase_completed — Payment went through. Send `product`, `price`, `currency`. - purchase_failed — The purchase was cancelled or failed. Send `product` and `reason`. - subscription_started — A paid subscription became active (first charge, including after a trial). - subscription_renewed — A subscription renewed. - subscription_cancelled — The user turned off auto-renew, or the subscription lapsed. - screen_viewed — A screen was shown. Send `screen`. - feature_used — A feature was used. Send `feature`. - first_value_completed — The first real core outcome completed. Persist a deduplication marker; send once per user/installation, with `outcome` and experiment identifiers, never personal content. - meaningful_win — A real completed outcome matches the app system win definition. Send `outcome`; do not infer satisfaction or a rating. - review_request_attempted — The system review API was invoked after a meaningful win and eligibility checks. This does not mean a dialog displayed or a review was submitted. - error_shown — An error was shown to the user. Send `message` and `where`. Rules: - Names are lowercase_with_underscores. Send them exactly as written above. - Properties are flat scalars (string, number, boolean, null). No nested objects or arrays — a nested value cannot be filtered on and is rejected. - Send `app_opened` once per foreground run, not once per screen. - Call `identify(userId)` immediately after `signup_completed` and on every launch where the user is already signed in. - Never send an email address, a phone number, an IDFA/IDFV, or any device identifier. The anonymous id the client generates is the only identity needed. - With the TypeScript client, call `analytics.flush()` when the app goes to the background. The Swift client already does this for you. ## 3. Cover the whole funnel, not the easy half The funnel is only as good as its weakest instrumented step. Pick the template matching the app and make sure every one of its events is sent: - subscription: app_opened → onboarding_started → onboarding_completed → paywall_viewed → trial_started → subscription_started - lead_capture: app_opened → onboarding_completed → signup_started → signup_completed - marketplace: app_opened → signup_completed → purchase_started → purchase_completed - content: app_opened → onboarding_completed → feature_used → paywall_viewed → subscription_started - utility: app_opened → feature_used → paywall_viewed → purchase_completed If the app has an onboarding flow, send `onboarding_step` at each screen with a 1-based `step` number and a `name`, in addition to the started/completed pair. That is what turns "people leave onboarding" into "people leave on step 3". ## 4. If neither client fits Send the events over HTTP yourself. One request, up to 100 events: POST https://api.broctic.com/v1/events Content-Type: application/json x-broctic-key: bpk_… { "events": [ { "name": "paywall_viewed", "ts": "2026-09-07T18:00:00Z", "anonymousId": "a-random-id-the-app-generated-once-and-stored", "userId": "optional, only after sign-in", "sessionId": "optional", "props": { "placement": "onboarding" } } ], "context": { "platform": "ios", "appVersion": "1.4.0", "osVersion": "18.0", "locale": "en-CA", "country": "CA" } } A successful request returns 202 with {"accepted": n}. A 4xx means the batch itself is wrong (bad key, bad shape) and retrying will not help — fix it. A 5xx or a network failure should be retried on the next flush, not dropped. ## 5. Verify — do not skip this step Instrumentation that compiles is not instrumentation that works. Before you report the task finished: 1. Run the app and walk the whole funnel: open it, complete onboarding, reach the paywall, and start a purchase (the sandbox is fine). 2. Open the Broctic console, go to the app's page, and open Events. 3. Confirm every event name you sent appears there, with a person count above zero, and that the names are marked as standard rather than custom. 4. A name shown as custom means it is misspelled against the vocabulary above. Fix the app, do not rename the funnel step. ## 6. If you are also building or changing the funnel itself Read https://broctic.com/docs/playbook/checklist before designing onboarding, the paywall, pricing or trials. It is the published evidence — RevenueCat's and Adapty's industry reports, UXCam, Superwall, Apple — reduced to numbered decisions, each with the metric it moves and the event above that measures it. Do not invent conversion figures; every figure you may quote is on https://broctic.com/docs/playbook/sources. ## Appendix A — BrocticAnalytics.swift (LEGACY; native Apple apps Broctic did not build) // // 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 } } ## Appendix B — broctic-analytics.ts (LEGACY; imported Expo, React Native, web, Node) /** * 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; /** 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; setItem(key: string, value: string): Promise | 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; /** The anonymous id in use — useful for support tickets. */ anonymousId(): Promise; } 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 | null = null; let sending: Promise | 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 = (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 = (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 { 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 { 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, }; } ## Funnel playbook — how the best subscription apps convert, by the numbers Pages: https://broctic.com/docs/playbook (overview) · /docs/playbook/onboarding · /docs/playbook/paywall · /docs/playbook/pricing · /docs/playbook/trials-and-retention · /docs/playbook/checklist · /docs/playbook/sources (every figure with publisher, year, sample and caveat — quote nothing that is not there). ### Onboarding 1. Build and verify the core app first, then design the shortest useful route to first value; defer nonessential questions. Moves: onboarding completion. Measure: onboarding_started → onboarding_completed in the Funnel report. (Practitioner consensus — no published figure.) 2. Use honest progress when a bounded sequence benefits from it; test against a simpler flow. Moves: first-step drop-off. Measure: onboarding_step with step=1 → step=2. (Practitioner consensus — no published figure.) 3. Request only essential account information; allow guest use when possible and meet Apple login requirements. Moves: sign-up completion. Measure: signup_started → signup_completed. (Practitioner consensus — no published figure.) 4. Use question screens to personalise the outcome you will promise on the paywall, not to collect data you will not use. Moves: paywall conversion (the answers feed the headline). Measure: onboarding_step props name=…; paywall_viewed → trial_started. (Practitioner consensus — no published figure.) 5. Ask for notifications and tracking permission after a moment that explains why, never on first launch. Expect about a third to allow tracking. Moves: permission opt-in. Measure: feature_used with feature=notifications_prompt / att_prompt (custom). ### Paywall 1. Test a relevant offer after demonstrated value or at a premium feature; choose early hard gates only with a product-specific rationale. Moves: download → trial. Measure: onboarding_completed → paywall_viewed; store: Downloads that start a trial. 2. Decide hard gate or freemium deliberately: a hard paywall converts about five times more installs to payers by day 35, at a higher refund rate. Moves: download → paid; refund rate. Measure: store: Downloads that pay; Refund rate. 3. Consider a multi-page paywall — a short value walk-through before the offer — and measure it against a single page; the published gap is 37% relative, observational. Moves: paywall → trial. Measure: paywall_viewed → trial_started. 4. Test a small understandable set of plans; do not infer a universal winner from cross-app comparisons. Moves: paywall → trial/purchase. Measure: paywall_viewed → trial_started / purchase_completed. 5. State the trial length, the price after it and the charge date on the paywall itself; put restore, terms and privacy on it (App Review requires them). Moves: purchase sheet completion; refunds. Measure: purchase_started → purchase_completed; store: Refund rate. 6. Show a follow-up offer to people who open the purchase sheet and back out — about a fifth of paywalled users do. Moves: recovered purchases. Measure: purchase_failed → purchase_completed (custom funnel). 7. Keep the buy button on screen without scrolling, and make the content fill the viewport. A paywall composed against a short device leaves a dead band on a tall one, which reads as a page that failed to load rather than as a layout. Moves: paywall → trial (nothing converts on a screen that looks broken). Measure: paywall_viewed → trial_started, split by device via the context on each event. (Practitioner consensus — no published figure.) 8. Give the paywall a real state for when the store does not answer: what happened, a retry, and restore. On a hard gate this is the state that locks somebody out of the whole app, so it must offer a way forward rather than an explanation. Moves: recovered sessions; refunds and one-star reviews avoided. Measure: error_shown where=paywall; paywall_viewed with no purchase_started after it. (Practitioner consensus — no published figure.) ### Pricing and plans 1. Lead with an annual plan outside gaming; weekly plans renew at 3.4% after a year, monthly at 17%. Moves: year-1 retention; LTV. Measure: subscription_renewed per product; store: paid units per SKU. 2. Do not price low to convert: high-priced apps start more trials, convert more installs to payers and earn ~7× the year-1 LTV per subscriber. Moves: download → trial; download → paid; LTV. Measure: store: Downloads that start a trial; Downloads that pay; proceeds per download. 3. Offer a trial of 17–32 days if the product can prove itself in that time; most apps offer 5–9 and convert worse. Moves: trial → paid. Measure: trial_started → subscription_started; store: Trials that pay. 4. Add a weekly plan for India, South-East Asia and the Middle East; those regions rely on weekly plans far more than others. Moves: download → paid by storefront. Measure: Analytics → Places; store rows by country. 5. Check the ladder is arithmetically defensible before shipping it: no plan may cost less than a shorter one over the same period, and a one-time tier priced near the annual one cannibalises it. A customer who does the sums and finds a cheaper route was penalised for not checking, which is the version of a pricing page nobody defends out loud. Moves: plan mix; the share of revenue that lands on the plan you intended. Measure: store: proceeds and paid units per SKU — the per-product breakdown, not the total. (Practitioner consensus — no published figure.) 6. Judge a plan on the revenue it produces per SKU, not on how many transactions it shows. A weekly plan bills thirteen times a quarter and an annual plan once, so transaction counts flatter short billing periods and say nothing about how many people are actually subscribed. Moves: nothing directly — it stops you drawing the wrong conclusion from the right data. Measure: store: proceeds per SKU alongside paid units per SKU. (Practitioner consensus — no published figure.) ### Trials, renewals, refunds 1. Send a reminder before the trial charges and make cancellation obvious; hidden cancels become refunds, and refunds are netted out of proceeds. Moves: refund rate. Measure: store: Refund rate; subscription_cancelled. 2. Treat the first month of an annual plan as the retention problem: about 30% cancel in it. Deliver the core outcome in the first two sessions. Moves: first-month cancellation. Measure: subscription_started → subscription_cancelled (custom funnel, 30-day window). 3. Judge trial → paid against 30–50%, not 100%: the published medians sit in the mid-forties for the best categories. Moves: trial → paid. Measure: store: Trials that pay (provisional until the window ages).