mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +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:
@@ -16,6 +16,7 @@ import {
|
|||||||
loadRuntimeSourceSignature,
|
loadRuntimeSourceSignature,
|
||||||
} from "./runtimeSource.js";
|
} from "./runtimeSource.js";
|
||||||
import { VERSION as version } from "../version.js";
|
import { VERSION as version } from "../version.js";
|
||||||
|
import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js";
|
||||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||||
import {
|
import {
|
||||||
createStudioManualEditsRenderBodyScript,
|
createStudioManualEditsRenderBodyScript,
|
||||||
@@ -566,6 +567,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
return serve();
|
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) => {
|
app.get("/api/events", (c) => {
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
const listener = (path: string) => {
|
const listener = (path: string) => {
|
||||||
@@ -700,9 +710,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let html = readFileSync(indexPath, "utf-8");
|
let html = readFileSync(indexPath, "utf-8");
|
||||||
const envScript = buildRuntimeEnvScript();
|
// Inject before the studio bundle runs. Identity script first (see
|
||||||
if (envScript) {
|
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
|
||||||
html = html.replace("<head>", `<head>${envScript}`);
|
// telemetry init reads it.
|
||||||
|
const headScript = buildStudioHeadScripts(buildRuntimeEnvScript());
|
||||||
|
if (headScript) {
|
||||||
|
html = html.replace("<head>", `<head>${headScript}`);
|
||||||
}
|
}
|
||||||
return c.html(html);
|
return c.html(html);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
'<script>window.__HF_CLI_DISTINCT_ID="machine-uuid";</script>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "</script><script>alert(1)" });
|
||||||
|
const script = buildCliIdentityScript();
|
||||||
|
// The raw closing tag must be escaped by JSON.stringify, not emitted literally.
|
||||||
|
expect(script).not.toContain("</script><script>alert(1)");
|
||||||
|
expect(script).toContain("window.__HF_CLI_DISTINCT_ID=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildStudioHeadScripts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
shouldTrack.mockReset();
|
||||||
|
readConfig.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ENV_SCRIPT = "<script>window.__HF_STUDIO_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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<script>` tag to inject into the served index.html `<head>`, publishing the
|
||||||
|
* CLI distinct id as `window.__HF_CLI_DISTINCT_ID` before the studio bundle
|
||||||
|
* runs. Preferred over a URL param so the id never leaks into `$current_url` /
|
||||||
|
* `url_hash` telemetry or browser history. Empty string when there's nothing to
|
||||||
|
* seed (telemetry off / no id).
|
||||||
|
*/
|
||||||
|
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 "<" or "/". Escaping both means no "</script>" (or "</…")
|
||||||
|
// sequence can form in the emitted value, so it can never terminate the
|
||||||
|
// inline <script> or open a new tag.
|
||||||
|
const encoded = JSON.stringify(cliId).replace(/</g, "\\u003c").replace(/\//g, "\\/");
|
||||||
|
return `<script>window.__HF_CLI_DISTINCT_ID=${encoded};</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the scripts injected into the served Studio `index.html` `<head>`.
|
||||||
|
* 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 `<head>` inject
|
||||||
|
* silently landing ahead of the identity script and reintroducing a boot race.
|
||||||
|
*/
|
||||||
|
export function buildStudioHeadScripts(envScript: string): string {
|
||||||
|
return `${buildCliIdentityScript()}${envScript}`;
|
||||||
|
}
|
||||||
@@ -5,36 +5,21 @@
|
|||||||
// localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
|
// 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 OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
||||||
const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
|
const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
|
||||||
|
|
||||||
function safeLocalStorage(): Storage | null {
|
/**
|
||||||
try {
|
* Anonymous telemetry id for `studio_*` and render events.
|
||||||
return typeof localStorage === "undefined" ? null : localStorage;
|
*
|
||||||
} catch {
|
* Delegates to the single source of truth in `distinctId.ts` so this id is
|
||||||
return null;
|
* identical to the one used for `studio:*` events (utils/studioTelemetry.ts)
|
||||||
}
|
* and, when the CLI launched Studio, to the CLI's own `config.anonymousId`.
|
||||||
}
|
*/
|
||||||
|
|
||||||
function newAnonymousId(): string {
|
|
||||||
return generateId();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAnonymousId(): string {
|
export function getAnonymousId(): string {
|
||||||
const ls = safeLocalStorage();
|
return resolveStudioDistinctId();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOptedOut(): boolean {
|
export function isOptedOut(): boolean {
|
||||||
@@ -58,14 +43,6 @@ export function markNoticeShown(): void {
|
|||||||
// Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
|
// Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
|
||||||
const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired";
|
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 {
|
export function hasFiredSessionStart(): boolean {
|
||||||
return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1";
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// PostHog public ingest key — write-only, safe to ship in the client bundle
|
||||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||||
@@ -18,26 +18,12 @@ interface QueuedEvent {
|
|||||||
|
|
||||||
let queue: QueuedEvent[] = [];
|
let queue: QueuedEvent[] = [];
|
||||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
let flushTimer: ReturnType<typeof setInterval> | 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 {
|
function getDistinctId(): string {
|
||||||
if (distinctId) return distinctId;
|
return resolveStudioDistinctId();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user