Ctrl K
ComunidadGuías…

lib/analytics.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, unknown> | 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<E extends AnalyticsEventName>(
  event: E,
  props?: AnalyticsPropsFor<E>
): 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<string, unknown> | 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);
  }
}