import type { PostHog } from "posthog-js"; import { sanitizeAnalyticsEventName, sanitizeAnalyticsProps, } from "./analytics-sanitize"; import { ALLOWED_ANALYTICS_EVENTS, type AnalyticsEventName, type AnalyticsPropsFor, } from "./analytics-events"; /** * The PostHog client, once loaded (MIN-94). * * `posthog-js` weighs 227 KB uncompressed: import it statically here the * put in the initial bundle of ALL public pages, via the chain * `cookie-banner` → `use-analytics` → this module. It is now loaded by a * `import()` in `components/posthog-init.tsx`, which drops the client here — this * file only keeps one type, erased at compilation. * * As long as nothing is dropped (no key, localhost, chunk not yet * downloaded), the reference remains `null` and all calls are inert: * exactly the contract from before, where they were tapping on an uninitialized singleton *. */ let client: PostHog | null = null; /** Called by `PostHogInit` just before `posthog.init()`. */ export function setAnalyticsClient(instance: PostHog): void { client = instance; } /** The loaded client, or `null` — to be reread on each call, never to be memorized. */ export function getAnalyticsClient(): PostHog | null { return client; } /** * Emitting an analytics event OUTSIDE the React component (MIN-78). * * `useAnalytics()` covers components; a lot of interesting actions * live on the other hand in the API layer (`lib/*-api.ts`) or in a store * zustand — code without hooks. Both target the same client, with exactly * the same guarantees: typed catalog, allowlist runtime, sanitization of * props. * * No effect if PostHog is not initialized (no key, local host, refusal * cookies) or if the call comes from the server — server routes go through * `lib/server/posthog.ts`. */ /** * Is PostHog initialized? (MIN-78) * * The init is DELAYED (`requestIdleCallback`, up to 800 ms), but the identity * arrives BEFORE: Supabase issues `INITIAL_SESSION` upon mounting, and the current project * is known from the first URL. Without a queue, these calls * were sent to an uninitialized client and were lost — the user * remained anonymous throughout the session, and no more funnels per account were * calculable. * * Both types of call therefore wait for init rather than get lost: * - STATE (identity, group, person properties) is REPLAYED here. This * is not a dated event but a context: applying it with a few * hundreds of milliseconds of delay is correct, losing it is not; * - EVENTS (`trackEvent`) are queued and replayed WITH their * timestamp (MIN-150 — see `pendingEvents`). They used to be * previously not, and it cost all the "seen" events. * * The order of replay matters: identity FIRST, events THEN, so that * an event queued before knowing the account still leaves * under the voucher `distinct_id`. */ let analyticsReady = false; const readyWaiters = new Set<() => void>(); /** Called by `PostHogInit` after `posthog.init()` has passed. */ export function markAnalyticsReady(): void { if (analyticsReady) return; analyticsReady = true; // Copy before iteration: a callback can record another. // oxlint-disable-next-line unicorn/no-useless-spread for (const cb of [...readyWaiters]) { readyWaiters.delete(cb); cb(); } // After the identity, never before: see the header. flushPendingEvents(); flushPendingExceptions(); } /** * Runs `cb` as soon as PostHog is ready — immediately if it is already. * Returns a rollback function (to be called on unmount). */ export function onAnalyticsReady(cb: () => void): () => void { if (analyticsReady) { cb(); return () => {}; } readyWaiters.add(cb); return () => readyWaiters.delete(cb); } /** * Events emitted BEFORE init, waiting for their client (MIN-150). * * They were thrown away, and this was written as an assumed cost of loading * deferred. The measure decided otherwise: `landing_viewed` is issued at the mounting * of EACH visit to the landing, and PostHog had not received a single one in * 180 days — when `cookie_consent_choice` (9) and `landing_cta_clicked` (3), * them, arrived during the same period. Nothing random about it: a * edit effect always comes before a `requestIdleCallback`, a click always * after. So it wasn't "a few events lost at startup", it was a whole CATEGORY of events — the "seen" ones — that didn't exist, and the first step in the acquisition funnel with it. would distort its * timestamp") had an answer in the API: `capture` accepts the time in * third argument. We therefore keep the emission time, not the replay time. * * The queue is limited: beyond that, a tab which never initializes PostHog (no key, refusal of cookies) would accumulate endlessly. Twenty is enough — that's already * more events than a page emits before being interactive. */ const MAX_PENDING_EVENTS = 20; const pendingEvents: { event: string; props: Record | undefined; at: Date; }[] = []; /** Replays the queue on the freshly initialized client, then empties it. */ function flushPendingEvents(): void { const queued = pendingEvents.splice(0, pendingEvents.length); const posthog = getAnalyticsClient(); // No client (no key, local host): the queue empties without leaving. if (!posthog?.__loaded) return; for (const { event, props, at } of queued) { posthog.capture(event, props, { timestamp: at }); } } export function trackEvent( event: E, props?: AnalyticsPropsFor ): void { if (typeof window === "undefined") return; const safeEvent = sanitizeAnalyticsEventName(event); if (!safeEvent || !ALLOWED_ANALYTICS_EVENTS.has(safeEvent as AnalyticsEventName)) return; const safeProps = sanitizeAnalyticsProps(props as Record | undefined); // The init is deferred (see components/posthog-init.tsx): what happens // before she waits her turn instead of disappearing. const posthog = getAnalyticsClient(); if (!posthog?.__loaded) { if (pendingEvents.length < MAX_PENDING_EVENTS) { pendingEvents.push({ event: safeEvent, props: safeProps, at: new Date() }); } return; } posthog.capture(safeEvent, safeProps); } /** * Report an exception caught by a React error boundary (MIN-542). * * Unhandled errors and promise rejections reach PostHog through the exception * autocapture enabled in `components/posthog-init.tsx`. Errors caught by an * `error.tsx` boundary, however, never reach `window.onerror` — React swallows * them — so the boundary must report explicitly, and this is the entry point. * * Exceptions use the raw `posthog.captureException` channel: no catalog * allowlist applies (there is no event name to vet), but the client only * exists when the operator opted into PostHog, and the consent contract * (`components/posthog-init.tsx`) still governs everything it emits. * * As for `trackEvent`, a client not yet initialized (idle callback pending, * chunk downloading) does not lose the error: it is queued — a smaller bound * than events, an error page rarely throws more than a handful of exceptions — * and replayed once `markAnalyticsReady` fires. */ const MAX_PENDING_EXCEPTIONS = 5; const pendingExceptions: unknown[] = []; function flushPendingExceptions(): void { const queued = pendingExceptions.splice(0, pendingExceptions.length); const posthog = getAnalyticsClient(); // No client (no key, local host): the queue empties without leaving. if (!posthog?.__loaded) return; for (const error of queued) { posthog.captureException(error); } } export function captureClientException(error: unknown): void { if (typeof window === "undefined") return; const posthog = getAnalyticsClient(); if (posthog?.__loaded) { posthog.captureException(error); return; } if (pendingExceptions.length < MAX_PENDING_EXCEPTIONS) { pendingExceptions.push(error); } }