Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -700,9 +710,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
);
}
let html = readFileSync(indexPath, "utf-8");
const envScript = buildRuntimeEnvScript();
if (envScript) {
html = html.replace("<head>", `<head>${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("<head>", `<head>${headScript}`);
}
return c.html(html);
});
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/src/server/telemetryIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
65 changes: 65 additions & 0 deletions packages/cli/src/server/telemetryIdentity.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
43 changes: 10 additions & 33 deletions packages/studio/src/telemetry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
}
Expand Down
103 changes: 103 additions & 0 deletions packages/studio/src/telemetry/distinctId.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading