From bd333f4ddf24648783ec5d53ae0aa542a170df79 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 00:42:08 -0700 Subject: [PATCH 1/3] feat(telemetry): unify CLI and Studio PostHog identity (Layer 1) Seed the CLI's anonymous distinct_id into Studio at launch so a developer's CLI and their Studio browser session resolve to the same PostHog person. Also unifies Studio's two previously-independent anonymous ids into one source of truth. Uses only the existing anonymous machine id (no new PII). - cli: inject window.__HF_CLI_DISTINCT_ID into the served index.html (mirrors the existing __HF_STUDIO_ENV__ injection) + add a fallback GET /api/telemetry-identity endpoint. Only seeds when CLI telemetry is enabled; empty/no-op otherwise. - studio: new telemetry/distinctId.ts single source of truth; adopts the CLI-seeded id when present, else falls back to the existing per-browser localStorage id. Both Studio clients (studio:* and studio_*/render) now share this one id. --- packages/cli/src/server/studioServer.ts | 18 ++- .../cli/src/server/telemetryIdentity.test.ts | 82 ++++++++++++ packages/cli/src/server/telemetryIdentity.ts | 52 ++++++++ packages/studio/src/telemetry/config.ts | 26 ++-- .../studio/src/telemetry/distinctId.test.ts | 95 ++++++++++++++ packages/studio/src/telemetry/distinctId.ts | 123 ++++++++++++++++++ packages/studio/src/utils/studioTelemetry.ts | 24 +--- 7 files changed, 381 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/server/telemetryIdentity.test.ts create mode 100644 packages/cli/src/server/telemetryIdentity.ts create mode 100644 packages/studio/src/telemetry/distinctId.test.ts create mode 100644 packages/studio/src/telemetry/distinctId.ts diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 8fe1d4dd14..0cdcf71b72 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 { buildCliIdentityScript, 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,11 @@ 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 so the CLI + // distinct id is on `window` by the time telemetry init reads it. + const headScript = `${buildCliIdentityScript()}${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..2ea32186f1 --- /dev/null +++ b/packages/cli/src/server/telemetryIdentity.test.ts @@ -0,0 +1,82 @@ +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 } = + 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: "`; +} diff --git a/packages/studio/src/telemetry/config.ts b/packages/studio/src/telemetry/config.ts index 2e26813230..e16cc8d5a2 100644 --- a/packages/studio/src/telemetry/config.ts +++ b/packages/studio/src/telemetry/config.ts @@ -5,9 +5,8 @@ // localStorage.setItem('hyperframes-studio:telemetryDisabled','1') // --------------------------------------------------------------------------- -import { generateId } from "../utils/generateId"; +import { resolveStudioDistinctId } from "./distinctId"; -const ANON_ID_KEY = "hyperframes-studio:anonymousId"; const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled"; const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown"; @@ -19,22 +18,15 @@ function safeLocalStorage(): Storage | 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 { diff --git a/packages/studio/src/telemetry/distinctId.test.ts b/packages/studio/src/telemetry/distinctId.test.ts new file mode 100644 index 0000000000..e45fa88a89 --- /dev/null +++ b/packages/studio/src/telemetry/distinctId.test.ts @@ -0,0 +1,95 @@ +// @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 as { __HF_CLI_DISTINCT_ID?: string }).__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); + }); +}); + +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..e21d0bcfff --- /dev/null +++ b/packages/studio/src/telemetry/distinctId.ts @@ -0,0 +1,123 @@ +// --------------------------------------------------------------------------- +// 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"; + +// 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; + +function safeLocalStorage(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return 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) { + const existing = ls.getItem(DISTINCT_ID_KEY) ?? ls.getItem(LEGACY_STUDIO_ANON_ID_KEY); + 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 ??= "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/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 { From ec93a266e805c8f568a543bd4719ccd9dfd3b44e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 00:57:39 -0700 Subject: [PATCH 2/3] fix(telemetry): keep Studio distinct_id resolver fail-silent on getItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveStudioDistinctId read localStorage.getItem() outside a try/catch while every other external access in the module is guarded. In a storage-restricted context where the localStorage reference resolves but getItem throws, the resolver threw — breaking the module's fail-silent contract (telemetry must never break Studio). Guard the reads and treat a throw as "no id". Also drop an unnecessary `as` cast in the test per the repo CLAUDE.md convention (the optional global is already declared). --- packages/studio/src/telemetry/distinctId.test.ts | 2 +- packages/studio/src/telemetry/distinctId.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/telemetry/distinctId.test.ts b/packages/studio/src/telemetry/distinctId.test.ts index e45fa88a89..f2bfdc44f0 100644 --- a/packages/studio/src/telemetry/distinctId.test.ts +++ b/packages/studio/src/telemetry/distinctId.test.ts @@ -10,7 +10,7 @@ import { } from "./distinctId"; function clearCliId(): void { - delete (window as { __HF_CLI_DISTINCT_ID?: string }).__HF_CLI_DISTINCT_ID; + delete window.__HF_CLI_DISTINCT_ID; } describe("resolveStudioDistinctId", () => { diff --git a/packages/studio/src/telemetry/distinctId.ts b/packages/studio/src/telemetry/distinctId.ts index e21d0bcfff..4ef5ba5d8f 100644 --- a/packages/studio/src/telemetry/distinctId.ts +++ b/packages/studio/src/telemetry/distinctId.ts @@ -97,7 +97,15 @@ export function resolveStudioDistinctId(): string { // 2. Reuse an existing persisted id (prefer canonical, fall back to legacy). if (ls) { - const existing = ls.getItem(DISTINCT_ID_KEY) ?? ls.getItem(LEGACY_STUDIO_ANON_ID_KEY); + // 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. From 5574eb40865b36efb5149af59a0b31f67c6340da Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 01:11:21 -0700 Subject: [PATCH 3/3] refactor(telemetry): address review feedback on identity unification - dedup safeLocalStorage/safeSessionStorage into utils/safeStorage.ts, used by both telemetry/config.ts and telemetry/distinctId.ts (Miga #6) - replace redundant `??=` with `=` in the no-storage branch; cachedId is guaranteed null there (Miga #2) - extract buildStudioHeadScripts() so the "identity script before env script" head-injection ordering is a pure, tested invariant (Miga #5) - add tests: head-script ordering + telemetry-off passthrough, and a Studio memoization test proving an adopted CLI id survives a later window.__HF_CLI_DISTINCT_ID reassignment (Rames) - clarify the XSS-escaping comment (both < and / escaped so no sequence can form) (Miga #1) --- packages/cli/src/server/studioServer.ts | 9 +++---- .../cli/src/server/telemetryIdentity.test.ts | 24 ++++++++++++++++++- packages/cli/src/server/telemetryIdentity.ts | 17 +++++++++++-- packages/studio/src/telemetry/config.ts | 17 +------------ .../studio/src/telemetry/distinctId.test.ts | 8 +++++++ packages/studio/src/telemetry/distinctId.ts | 12 +++------- packages/studio/src/utils/safeStorage.ts | 20 ++++++++++++++++ 7 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 packages/studio/src/utils/safeStorage.ts diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 0cdcf71b72..f48458a1f6 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -16,7 +16,7 @@ import { loadRuntimeSourceSignature, } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; -import { buildCliIdentityScript, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js"; +import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js"; import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js"; import { createStudioManualEditsRenderBodyScript, @@ -710,9 +710,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ); } let html = readFileSync(indexPath, "utf-8"); - // Inject before the studio bundle runs. Identity script first so the CLI - // distinct id is on `window` by the time telemetry init reads it. - const headScript = `${buildCliIdentityScript()}${buildRuntimeEnvScript()}`; + // 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}`); } diff --git a/packages/cli/src/server/telemetryIdentity.test.ts b/packages/cli/src/server/telemetryIdentity.test.ts index 2ea32186f1..9f779841fb 100644 --- a/packages/cli/src/server/telemetryIdentity.test.ts +++ b/packages/cli/src/server/telemetryIdentity.test.ts @@ -14,7 +14,7 @@ vi.mock("../telemetry/config.js", () => ({ readConfig: (...args: unknown[]) => readConfig(...args), })); -const { resolveCliTelemetryDistinctId, buildCliIdentityScript } = +const { resolveCliTelemetryDistinctId, buildCliIdentityScript, buildStudioHeadScripts } = await import("./telemetryIdentity.js"); describe("resolveCliTelemetryDistinctId", () => { @@ -80,3 +80,25 @@ describe("buildCliIdentityScript", () => { expect(script).toContain("window.__HF_CLI_DISTINCT_ID="); }); }); + +describe("buildStudioHeadScripts", () => { + beforeEach(() => { + shouldTrack.mockReset(); + readConfig.mockReset(); + }); + + const ENV_SCRIPT = ""; + + 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 index 65eb957a58..f5d4d36df0 100644 --- a/packages/cli/src/server/telemetryIdentity.ts +++ b/packages/cli/src/server/telemetryIdentity.ts @@ -45,8 +45,21 @@ export function buildCliIdentityScript(): string { const cliId = resolveCliTelemetryDistinctId(); if (!cliId) return ""; // The id is a randomUUID() so this is belt-and-suspenders, but JSON.stringify - // does not escape "<" — escape it (and "/") so the value can never terminate - // the inline " (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 e16cc8d5a2..f4326b97b0 100644 --- a/packages/studio/src/telemetry/config.ts +++ b/packages/studio/src/telemetry/config.ts @@ -6,18 +6,11 @@ // --------------------------------------------------------------------------- import { resolveStudioDistinctId } from "./distinctId"; +import { safeLocalStorage, safeSessionStorage } from "../utils/safeStorage"; 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; - } -} - /** * Anonymous telemetry id for `studio_*` and render events. * @@ -50,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 index f2bfdc44f0..b420114639 100644 --- a/packages/studio/src/telemetry/distinctId.test.ts +++ b/packages/studio/src/telemetry/distinctId.test.ts @@ -72,6 +72,14 @@ describe("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", () => { diff --git a/packages/studio/src/telemetry/distinctId.ts b/packages/studio/src/telemetry/distinctId.ts index 4ef5ba5d8f..241b9225ac 100644 --- a/packages/studio/src/telemetry/distinctId.ts +++ b/packages/studio/src/telemetry/distinctId.ts @@ -18,6 +18,7 @@ // --------------------------------------------------------------------------- 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. @@ -35,14 +36,6 @@ declare global { let cachedId: string | null = null; -function safeLocalStorage(): Storage | null { - try { - return typeof localStorage === "undefined" ? null : localStorage; - } catch { - return 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". @@ -114,7 +107,8 @@ export function resolveStudioDistinctId(): string { } } else { // No storage at all (SSR / locked-down browser): stable within the session. - cachedId ??= "anonymous"; + // `cachedId` is guaranteed null here (early-returned at the top otherwise). + cachedId = "anonymous"; return cachedId; } 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; + } +}