feat(telemetry): unify CLI and Studio PostHog identity (Layer 1) (#1829)

* 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 <head>
  (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.

* fix(telemetry): keep Studio distinct_id resolver fail-silent on getItem

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).

* 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 </script>
  sequence can form) (Miga #1)
This commit is contained in:
James Russo
2026-07-01 09:21:18 -07:00
committed by GitHub
parent b33d54f54b
commit 9c4d9e50a0
8 changed files with 448 additions and 55 deletions
+10 -33
View File
@@ -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";
}
@@ -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();
});
});
+125
View File
@@ -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;
}