diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 8fe1d4dd14..f48458a1f6 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -16,6 +16,7 @@ import { loadRuntimeSourceSignature, } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; +import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js"; import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js"; import { createStudioManualEditsRenderBodyScript, @@ -566,6 +567,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { return serve(); }); + // CLI → Studio telemetry identity endpoint (Layer 1). Studio reads the + // injected `window.__HF_CLI_DISTINCT_ID` first; this GET is a fallback for + // clients that can't rely on the injected global. Returns the CLI's anonymous + // distinct id (no PII) so the browser session can join the CLI's PostHog + // person, or `{ distinctId: null }` when CLI telemetry is disabled. + app.get("/api/telemetry-identity", (c) => { + return c.json({ distinctId: resolveCliTelemetryDistinctId() }); + }); + app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = (path: string) => { @@ -700,9 +710,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ); } let html = readFileSync(indexPath, "utf-8"); - const envScript = buildRuntimeEnvScript(); - if (envScript) { - html = html.replace("", `${envScript}`); + // Inject before the studio bundle runs. Identity script first (see + // buildStudioHeadScripts) so the CLI distinct id is on `window` by the time + // telemetry init reads it. + const headScript = buildStudioHeadScripts(buildRuntimeEnvScript()); + if (headScript) { + html = html.replace("", `${headScript}`); } return c.html(html); }); diff --git a/packages/cli/src/server/telemetryIdentity.test.ts b/packages/cli/src/server/telemetryIdentity.test.ts new file mode 100644 index 0000000000..9f779841fb --- /dev/null +++ b/packages/cli/src/server/telemetryIdentity.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// CLI → Studio telemetry identity seeding (Layer 1). Verifies the server only +// hands the browser a distinct id when CLI telemetry is enabled, and passes +// through the anonymous machine id (no PII) otherwise. + +const shouldTrack = vi.fn(); +const readConfig = vi.fn(); + +vi.mock("../telemetry/client.js", () => ({ + shouldTrack: (...args: unknown[]) => shouldTrack(...args), +})); +vi.mock("../telemetry/config.js", () => ({ + readConfig: (...args: unknown[]) => readConfig(...args), +})); + +const { resolveCliTelemetryDistinctId, buildCliIdentityScript, buildStudioHeadScripts } = + await import("./telemetryIdentity.js"); + +describe("resolveCliTelemetryDistinctId", () => { + beforeEach(() => { + shouldTrack.mockReset(); + readConfig.mockReset(); + }); + + it("returns the CLI anonymousId when telemetry is enabled", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockReturnValue({ anonymousId: "machine-uuid" }); + expect(resolveCliTelemetryDistinctId()).toBe("machine-uuid"); + }); + + it("returns null when telemetry is disabled (opt-out / dev / CI)", () => { + shouldTrack.mockReturnValue(false); + readConfig.mockReturnValue({ anonymousId: "machine-uuid" }); + expect(resolveCliTelemetryDistinctId()).toBeNull(); + // Must not even read config when suppressed. + expect(readConfig).not.toHaveBeenCalled(); + }); + + it("returns null when there is no anonymousId", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockReturnValue({ anonymousId: "" }); + expect(resolveCliTelemetryDistinctId()).toBeNull(); + }); + + it("never throws — returns null if config reading fails", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockImplementation(() => { + throw new Error("disk error"); + }); + expect(resolveCliTelemetryDistinctId()).toBeNull(); + }); +}); + +describe("buildCliIdentityScript", () => { + beforeEach(() => { + shouldTrack.mockReset(); + readConfig.mockReset(); + }); + + it("emits a script that sets window.__HF_CLI_DISTINCT_ID when telemetry is on", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockReturnValue({ anonymousId: "machine-uuid" }); + expect(buildCliIdentityScript()).toBe( + '', + ); + }); + + it("emits an empty string when telemetry is disabled (nothing to seed)", () => { + shouldTrack.mockReturnValue(false); + expect(buildCliIdentityScript()).toBe(""); + }); + + it("JSON-encodes the id so it can't break out of the script literal", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockReturnValue({ anonymousId: ""; + + it("places the CLI identity script before the env script so the global is set first", () => { + shouldTrack.mockReturnValue(true); + readConfig.mockReturnValue({ anonymousId: "machine-uuid" }); + const head = buildStudioHeadScripts(ENV_SCRIPT); + expect(head.indexOf("__HF_CLI_DISTINCT_ID")).toBeGreaterThanOrEqual(0); + expect(head.indexOf("__HF_CLI_DISTINCT_ID")).toBeLessThan(head.indexOf("__HF_STUDIO_ENV__")); + }); + + it("returns just the env script when identity is suppressed (telemetry off)", () => { + shouldTrack.mockReturnValue(false); + expect(buildStudioHeadScripts(ENV_SCRIPT)).toBe(ENV_SCRIPT); + }); +}); diff --git a/packages/cli/src/server/telemetryIdentity.ts b/packages/cli/src/server/telemetryIdentity.ts new file mode 100644 index 0000000000..f5d4d36df0 --- /dev/null +++ b/packages/cli/src/server/telemetryIdentity.ts @@ -0,0 +1,65 @@ +// --------------------------------------------------------------------------- +// CLI → Studio telemetry identity (Layer 1). +// +// The CLI owns both the Studio launch and the local server, so it seeds the +// browser with its own anonymous `config.anonymousId`. Studio adopts it as its +// distinct_id (see packages/studio/src/telemetry/distinctId.ts), so the CLI's +// `cli_command*` events and the browser's `studio:*` / `studio_*` / render +// events are attributed to one PostHog person. +// +// This uses ONLY the existing anonymous machine id (a random UUID, no PII), so +// the "no personal info" telemetry disclosure stays valid. When CLI telemetry +// is disabled (opt-out / dev / CI / DO_NOT_TRACK) nothing is seeded and Studio +// behaves exactly as if opened standalone. +// +// Kept out of studioServer.ts so it can be unit-tested without pulling in the +// server's heavy render dependencies (@hyperframes/producer, engine, …). +// --------------------------------------------------------------------------- + +import { readConfig } from "../telemetry/config.js"; +import { shouldTrack as telemetryShouldTrack } from "../telemetry/client.js"; + +/** + * The CLI's anonymous distinct id to hand to Studio, or null when CLI telemetry + * is disabled or no id is available. Fail-silent — telemetry must never break + * the preview server. + */ +export function resolveCliTelemetryDistinctId(): string | null { + try { + if (!telemetryShouldTrack()) return null; + const id = readConfig().anonymousId; + return typeof id === "string" && id.length > 0 ? id : null; + } catch { + return null; + } +} + +/** + * `" (or " or open a new tag. + const encoded = JSON.stringify(cliId).replace(/window.__HF_CLI_DISTINCT_ID=${encoded};`; +} + +/** + * Compose the scripts injected into the served Studio `index.html` ``. + * The CLI identity script MUST come first so `window.__HF_CLI_DISTINCT_ID` is + * set before the (deferred) Studio bundle runs telemetry init and reads it; + * `envScript` is the existing `window.__HF_STUDIO_ENV__` injection. Keeping the + * ordering in one pure, tested function guards against a future `` inject + * silently landing ahead of the identity script and reintroducing a boot race. + */ +export function buildStudioHeadScripts(envScript: string): string { + return `${buildCliIdentityScript()}${envScript}`; +} diff --git a/packages/studio/src/telemetry/config.ts b/packages/studio/src/telemetry/config.ts index 2e26813230..f4326b97b0 100644 --- a/packages/studio/src/telemetry/config.ts +++ b/packages/studio/src/telemetry/config.ts @@ -5,36 +5,21 @@ // localStorage.setItem('hyperframes-studio:telemetryDisabled','1') // --------------------------------------------------------------------------- -import { generateId } from "../utils/generateId"; +import { resolveStudioDistinctId } from "./distinctId"; +import { safeLocalStorage, safeSessionStorage } from "../utils/safeStorage"; -const ANON_ID_KEY = "hyperframes-studio:anonymousId"; const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled"; const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown"; -function safeLocalStorage(): Storage | null { - try { - return typeof localStorage === "undefined" ? null : localStorage; - } catch { - return null; - } -} - -function newAnonymousId(): string { - return generateId(); -} - +/** + * Anonymous telemetry id for `studio_*` and render events. + * + * Delegates to the single source of truth in `distinctId.ts` so this id is + * identical to the one used for `studio:*` events (utils/studioTelemetry.ts) + * and, when the CLI launched Studio, to the CLI's own `config.anonymousId`. + */ export function getAnonymousId(): string { - const ls = safeLocalStorage(); - if (!ls) return "anonymous"; - const existing = ls.getItem(ANON_ID_KEY); - if (existing) return existing; - const id = newAnonymousId(); - try { - ls.setItem(ANON_ID_KEY, id); - } catch { - /* private browsing / quota — return the in-memory ID for this session */ - } - return id; + return resolveStudioDistinctId(); } export function isOptedOut(): boolean { @@ -58,14 +43,6 @@ export function markNoticeShown(): void { // Uses sessionStorage directly because the dedupe is per-tab, not per-browser. const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired"; -function safeSessionStorage(): Storage | null { - try { - return typeof sessionStorage === "undefined" ? null : sessionStorage; - } catch { - return null; - } -} - export function hasFiredSessionStart(): boolean { return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1"; } diff --git a/packages/studio/src/telemetry/distinctId.test.ts b/packages/studio/src/telemetry/distinctId.test.ts new file mode 100644 index 0000000000..b420114639 --- /dev/null +++ b/packages/studio/src/telemetry/distinctId.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + resolveStudioDistinctId, + getCliDistinctId, + __resetStudioDistinctIdForTests, + DISTINCT_ID_KEY, + LEGACY_STUDIO_ANON_ID_KEY, +} from "./distinctId"; + +function clearCliId(): void { + delete window.__HF_CLI_DISTINCT_ID; +} + +describe("resolveStudioDistinctId", () => { + beforeEach(() => { + localStorage.clear(); + clearCliId(); + __resetStudioDistinctIdForTests(); + }); + + afterEach(() => { + clearCliId(); + __resetStudioDistinctIdForTests(); + }); + + it("adopts the CLI-seeded id and persists it to both keys", () => { + window.__HF_CLI_DISTINCT_ID = "cli-machine-uuid"; + const id = resolveStudioDistinctId(); + expect(id).toBe("cli-machine-uuid"); + expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe("cli-machine-uuid"); + expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe("cli-machine-uuid"); + }); + + it("prefers the CLI id even over an existing persisted id", () => { + localStorage.setItem(DISTINCT_ID_KEY, "old-browser-id"); + window.__HF_CLI_DISTINCT_ID = "cli-machine-uuid"; + expect(resolveStudioDistinctId()).toBe("cli-machine-uuid"); + }); + + it("ignores an empty CLI id and falls back to the persisted id", () => { + window.__HF_CLI_DISTINCT_ID = ""; + localStorage.setItem(DISTINCT_ID_KEY, "persisted-id"); + expect(resolveStudioDistinctId()).toBe("persisted-id"); + }); + + it("reuses the canonical persisted id when no CLI id is present", () => { + localStorage.setItem(DISTINCT_ID_KEY, "canonical-id"); + const id = resolveStudioDistinctId(); + expect(id).toBe("canonical-id"); + // Backfills the legacy key so both clients agree. + expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe("canonical-id"); + }); + + it("reuses the legacy key when only it exists, and backfills the canonical key", () => { + localStorage.setItem(LEGACY_STUDIO_ANON_ID_KEY, "legacy-id"); + const id = resolveStudioDistinctId(); + expect(id).toBe("legacy-id"); + expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe("legacy-id"); + }); + + it("mints and persists a new id when nothing exists (standalone Studio)", () => { + const id = resolveStudioDistinctId(); + expect(id).toBeTruthy(); + expect(localStorage.getItem(DISTINCT_ID_KEY)).toBe(id); + expect(localStorage.getItem(LEGACY_STUDIO_ANON_ID_KEY)).toBe(id); + }); + + it("memoizes the resolved id within a session", () => { + const first = resolveStudioDistinctId(); + localStorage.setItem(DISTINCT_ID_KEY, "changed-underneath"); + expect(resolveStudioDistinctId()).toBe(first); + }); + + it("memoizes an adopted CLI id even if window.__HF_CLI_DISTINCT_ID changes later", () => { + window.__HF_CLI_DISTINCT_ID = "cli-id-1"; + expect(resolveStudioDistinctId()).toBe("cli-id-1"); + // A late reassignment of the injected global must not change the resolved id. + window.__HF_CLI_DISTINCT_ID = "cli-id-2"; + expect(resolveStudioDistinctId()).toBe("cli-id-1"); + }); +}); + +describe("getCliDistinctId", () => { + beforeEach(() => { + clearCliId(); + }); + + it("returns the injected id when present", () => { + window.__HF_CLI_DISTINCT_ID = "cli-id"; + expect(getCliDistinctId()).toBe("cli-id"); + }); + + it("returns null when absent", () => { + expect(getCliDistinctId()).toBeNull(); + }); + + it("returns null for an empty string", () => { + window.__HF_CLI_DISTINCT_ID = ""; + expect(getCliDistinctId()).toBeNull(); + }); +}); diff --git a/packages/studio/src/telemetry/distinctId.ts b/packages/studio/src/telemetry/distinctId.ts new file mode 100644 index 0000000000..241b9225ac --- /dev/null +++ b/packages/studio/src/telemetry/distinctId.ts @@ -0,0 +1,125 @@ +// --------------------------------------------------------------------------- +// 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; +} diff --git a/packages/studio/src/utils/safeStorage.ts b/packages/studio/src/utils/safeStorage.ts new file mode 100644 index 0000000000..02e94cb4d9 --- /dev/null +++ b/packages/studio/src/utils/safeStorage.ts @@ -0,0 +1,20 @@ +// Best-effort access to Web Storage. Reading the `localStorage` / +// `sessionStorage` globals can throw (SSR, storage disabled, sandboxed or +// partitioned browsing contexts), so callers get `null` instead of an +// exception — telemetry must never break Studio. + +export function safeLocalStorage(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +export function safeSessionStorage(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + return null; + } +} diff --git a/packages/studio/src/utils/studioTelemetry.ts b/packages/studio/src/utils/studioTelemetry.ts index b736919c39..49406bb078 100644 --- a/packages/studio/src/utils/studioTelemetry.ts +++ b/packages/studio/src/utils/studioTelemetry.ts @@ -1,4 +1,4 @@ -import { generateId } from "./generateId"; +import { resolveStudioDistinctId } from "../telemetry/distinctId"; // PostHog public ingest key — write-only, safe to ship in the client bundle const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; @@ -18,26 +18,12 @@ interface QueuedEvent { let queue: QueuedEvent[] = []; let flushTimer: ReturnType | null = null; -let distinctId: string | null = null; +// Delegates to the single source of truth (telemetry/distinctId.ts) so `studio:*` +// events share one id with `studio_*` / render events, and adopt the CLI's +// distinct_id when the CLI launched Studio. function getDistinctId(): string { - if (distinctId) return distinctId; - try { - const stored = localStorage.getItem("hf-studio-anon-id"); - if (stored) { - distinctId = stored; - return stored; - } - } catch { - // localStorage may be unavailable - } - distinctId = generateId(); - try { - localStorage.setItem("hf-studio-anon-id", distinctId); - } catch { - // best-effort persistence - } - return distinctId; + return resolveStudioDistinctId(); } function isEnabled(): boolean {