Instrument an app with an AI assistant
A single prompt you can paste into Claude Code, Cursor or any coding agent to instrument your app correctly the first time.
Paste this to your AI
Copy the block below into Claude Code, Cursor, Codex, or whatever is writing your app, with the repository open. It contains the event vocabulary, the whole dependency-free client, the HTTP contract for native code, and a verification step. Replace nothing — the assistant is told where your key goes.
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<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,
};
}
Or point the agent at the URL
The same text is served as plain text at broctic.com/llms.txt, so an agent with web access can fetch it rather than having it pasted:
Read https://broctic.com/llms.txt and instrument this app exactly as it says.
My publishable key is bpk_… (it is safe to commit — it can only act for this one app, and cannot read the organization).Before you run it
- Create the app's publishable key first — the app's page in the console, under Events. It has the prefix
bpk_. - Never give an assistant an organization API key (
brk_). It reads and changes your whole organization, and an agent will happily commit it. - Decide which funnel shape the app is (subscription, lead capture, marketplace, content, utility) — the prompt lists the steps for each, and the assistant will pick badly if the app is ambiguous and you say nothing.
After it finishes, check the work
The prompt tells the assistant to walk the funnel and confirm the events arrived, but an agent reporting success is not evidence. Open the app's Events page yourself and look for two things:
- Every event name you expect, with a person count above zero.
- Each of them marked standard, not custom. A custom name is a misspelling against the vocabulary, and it is compared to nothing.
app_opened fired on every screen instead of once per foreground run. It inflates the first step and makes every rate after it look worse than it is — check its count against your sessions before believing a cliff.The human-readable version of all of this is in events.