// --------------------------------------------------------------------------- // Single source of truth for the Studio telemetry distinct_id. // // Studio historically minted TWO independent anonymous ids: // - `hf-studio-anon-id` (utils/studioTelemetry.ts → studio:* events) // - `hyperframes-studio:anonymousId` (telemetry/config.ts → studio_* + render events) // so a single browser looked like two different people in PostHog. This module // resolves ONE id that both clients (and the render→CLI channel) share. // // CLI→Studio identity stitch (Layer 1, no login / no PII): // When the CLI launches Studio it injects its own `config.anonymousId` // (a random UUID from ~/.hyperframes/config.json) as `window.__HF_CLI_DISTINCT_ID` // (see packages/cli/src/server/studioServer.ts). When present we ADOPT it as the // Studio distinct_id and persist it, so CLI `cli_command*` events and the // browser's `studio:*` / `studio_*` / render events are attributed to the same // PostHog person. When absent (Studio opened standalone) we fall back to the // previous per-browser localStorage id — behaviour is unchanged. // --------------------------------------------------------------------------- import { generateId } from "../utils/generateId"; import { safeLocalStorage } from "../utils/safeStorage"; // Canonical storage key. Both legacy keys are kept in sync (below) so any code // still reading them directly, plus older cached values, resolve to one id. export const DISTINCT_ID_KEY = "hyperframes-studio:anonymousId"; // Legacy key used by utils/studioTelemetry.ts for `studio:*` events. export const LEGACY_STUDIO_ANON_ID_KEY = "hf-studio-anon-id"; // Global injected by the CLI's embedded studio server at page load. Read-only // from the browser's perspective. declare global { interface Window { __HF_CLI_DISTINCT_ID?: string; } } let cachedId: string | null = null; /** * The distinct_id the CLI seeded into the page, if any. A non-empty string * means "this Studio was launched by the HyperFrames CLI, adopt its identity". */ export function getCliDistinctId(): string | null { try { const id = typeof window === "undefined" ? undefined : window.__HF_CLI_DISTINCT_ID; return typeof id === "string" && id.length > 0 ? id : null; } catch { return null; } } // Persist to both the canonical and legacy keys so the two Studio clients and // any cached reads converge on one id. Best-effort — private browsing / quota // failures are non-fatal (we still return the in-memory id for this session). function persist(ls: Storage, id: string): void { for (const key of [DISTINCT_ID_KEY, LEGACY_STUDIO_ANON_ID_KEY]) { try { ls.setItem(key, id); } catch { /* ignore */ } } } /** * Resolve the single Studio telemetry distinct_id. * * Precedence: * 1. CLI-seeded id (`window.__HF_CLI_DISTINCT_ID`) — adopted + persisted so * the browser session joins the CLI machine's PostHog person. * 2. Existing persisted id (canonical or legacy key) — unchanged behaviour. * 3. A freshly generated UUID — persisted for future loads. * * Memoized per module instance so repeated calls in a session are stable even * if localStorage is unavailable. */ export function resolveStudioDistinctId(): string { if (cachedId) return cachedId; const ls = safeLocalStorage(); // 1. CLI-seeded identity wins. Adopt + persist so it's stable across reloads // and shared by every Studio telemetry path. const cliId = getCliDistinctId(); if (cliId) { cachedId = cliId; if (ls) persist(ls, cliId); return cliId; } // 2. Reuse an existing persisted id (prefer canonical, fall back to legacy). if (ls) { // getItem can throw in storage-restricted contexts (partitioned / sandboxed // storage) even when the localStorage reference itself resolved — stay // fail-silent (telemetry must never break Studio) and treat it as "no id". let existing: string | null = null; try { existing = ls.getItem(DISTINCT_ID_KEY) ?? ls.getItem(LEGACY_STUDIO_ANON_ID_KEY); } catch { /* ignore */ } if (existing) { cachedId = existing; // Backfill the other key so both clients agree going forward. persist(ls, existing); return existing; } } else { // No storage at all (SSR / locked-down browser): stable within the session. // `cachedId` is guaranteed null here (early-returned at the top otherwise). cachedId = "anonymous"; return cachedId; } // 3. Mint a new id and persist it. const id = generateId(); cachedId = id; persist(ls, id); return id; } /** Test-only: clear the memoized id so a fresh resolution can be exercised. */ export function __resetStudioDistinctIdForTests(): void { cachedId = null; }