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
@@ -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}`;
}