mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// 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, but RANDOM per session rather than a shared literal.
|
|
//
|
|
// It used to be the constant "anonymous", which made every
|
|
// storage-restricted profile one bucketing unit: computed against the
|
|
// shipped hash, `calibration-50:anonymous` lands in bucket 44, so 100% of
|
|
// that population was enrolled at a nominal 50% and would flip together
|
|
// on any ramp. It also merged unrelated users into a single PostHog
|
|
// person. A per-session id spreads them and keeps them distinct, while
|
|
// persisting nothing.
|
|
// `cachedId` is guaranteed null here (early-returned at the top otherwise).
|
|
cachedId = generateId();
|
|
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;
|
|
}
|