/** * 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, }; }