mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user