Files
hyperframes/packages/cli/src/telemetry/canary.test.ts
T
Vance IngallsandClaude Opus 5 3f69a2c635 fix(cli,core,studio): close 15 review findings + 2 R5 blockers
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>
2026-07-31 01:27:37 -07:00

238 lines
8.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
const configState: {
anonymousId: string;
bucketSeed: string | undefined;
telemetryEnabled: boolean;
} = {
anonymousId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717",
bucketSeed: undefined,
telemetryEnabled: true,
};
const systemState = { is_ci: false };
// null = no runtime opt-out in force; a string names the source (env var,
// dev build, ...) exactly as policy.ts reports it.
const policyState: { runtimeOverride: string | null } = { runtimeOverride: null };
vi.mock("./config.js", () => ({
readConfig: () => ({
anonymousId: configState.anonymousId,
bucketSeed: configState.bucketSeed,
telemetryEnabled: configState.telemetryEnabled,
}),
}));
vi.mock("./system.js", () => ({
getSystemMeta: () => ({ is_ci: systemState.is_ci }),
}));
vi.mock("./policy.js", () => ({
telemetryRuntimeOverride: () => policyState.runtimeOverride,
}));
// The registry is data; pin a known shape so these tests don't move when a
// real canary is added or ramped.
vi.mock("@hyperframes/core/canary-registry", async () => {
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
"@hyperframes/core/canary-registry",
);
return {
...actual,
CANARIES: [
{
name: "test-alpha",
percentage: 100,
description: "always on",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
// A PARTIAL percentage. `exclude` and the unit-id check only apply
// below 100 — at 100 the guard is about to be deleted, so everyone
// must already be on it — so exclusion behaviour cannot be expressed
// against test-alpha.
name: "test-gamma",
percentage: 50,
description: "partial rollout",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-beta",
percentage: 0,
description: "always off",
owner: "t",
sunsetAfter: "2099-01-01",
},
],
findCanary: (n: string) =>
[
{
name: "test-alpha",
percentage: 100,
description: "",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-gamma",
percentage: 50,
description: "",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-beta",
percentage: 0,
description: "",
owner: "t",
sunsetAfter: "2099-01-01",
},
].find((c) => c.name === n),
};
});
const { isCanaryEnabled, resolveCanary, canaryEventProperties, __resetCanaryCacheForTests } =
await import("./canary.js");
beforeEach(() => {
__resetCanaryCacheForTests();
configState.anonymousId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
configState.bucketSeed = undefined;
configState.telemetryEnabled = true;
systemState.is_ci = false;
policyState.runtimeOverride = null;
delete process.env.HF_CANARY_TEST_ALPHA;
delete process.env.HF_CANARY_TEST_BETA;
delete process.env.HF_CANARY_TEST_GAMMA;
});
describe("telemetry opt-out is canary opt-out", () => {
it("does not enrol when the persisted preference is off", () => {
configState.telemetryEnabled = false;
// test-alpha is at 100% — it would be on for everyone otherwise.
expect(resolveCanary("test-alpha")).toEqual({
enabled: false,
reason: "telemetry_opt_out",
});
});
it.each(["HYPERFRAMES_NO_TELEMETRY", "DO_NOT_TRACK", "dev_mode"])(
"does not enrol under the %s runtime override, even with the preference on",
(source) => {
configState.telemetryEnabled = true;
policyState.runtimeOverride = source;
expect(resolveCanary("test-alpha").reason).toBe("telemetry_opt_out");
},
);
it("never buckets an opted-out install — no cohort is assigned at all", () => {
configState.telemetryEnabled = false;
// A bucket number would mean we hashed them into a slice anyway.
expect(resolveCanary("test-alpha").bucket).toBeUndefined();
});
it("still honours an explicit override — the documented way to test with telemetry off", () => {
configState.telemetryEnabled = false;
process.env.HF_CANARY_TEST_BETA = "on";
expect(resolveCanary("test-beta")).toEqual({ enabled: true, reason: "forced_on" });
});
it("reports every canary as false to PostHog shape when opted out", () => {
configState.telemetryEnabled = false;
expect(canaryEventProperties()).toEqual({
"$feature/canary-test-alpha": "false",
"$feature/canary-test-gamma": "false",
"$feature/canary-test-beta": "false",
});
});
});
describe("bucketing unit", () => {
it("buckets on the bucketSeed when present — the unit that survives config wipes", async () => {
const { evaluateCanary } = await import("@hyperframes/core/canary");
configState.bucketSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
const viaBinding = resolveCanary("test-gamma").bucket;
// 50, not 100: at 100 evaluateCanary short-circuits before bucketing and
// reports no bucket at all, which would make this comparison vacuous.
const bySeed = evaluateCanary({
feature: "test-gamma",
unitId: configState.bucketSeed,
percentage: 50,
}).bucket;
const byId = evaluateCanary({
feature: "test-gamma",
unitId: configState.anonymousId,
percentage: 50,
}).bucket;
expect(viaBinding).toBe(bySeed);
// Only meaningful if the two units actually bucket differently.
expect(bySeed).not.toBe(byId);
});
it("falls back to the anonymousId when no seed exists (failed legacy backfill)", async () => {
const { evaluateCanary } = await import("@hyperframes/core/canary");
const viaBinding = resolveCanary("test-gamma").bucket;
const byId = evaluateCanary({
feature: "test-gamma",
unitId: configState.anonymousId,
percentage: 50,
}).bucket;
expect(viaBinding).toBe(byId);
// Both undefined would satisfy toBe — assert a bucket was actually computed.
expect(viaBinding).toEqual(expect.any(Number));
});
});
describe("CLI canary binding", () => {
it("reads the percentage from the registry", () => {
expect(isCanaryEnabled("test-alpha")).toBe(true);
expect(isCanaryEnabled("test-beta")).toBe(false);
});
it("an unregistered name is off, not a throw — a typo must not break a render", () => {
expect(isCanaryEnabled("does-not-exist")).toBe(false);
expect(resolveCanary("does-not-exist").reason).toBe("out_of_cohort");
});
it("HF_CANARY_<FEATURE> overrides the registry in both directions", () => {
process.env.HF_CANARY_TEST_ALPHA = "off";
process.env.HF_CANARY_TEST_BETA = "on";
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: false, reason: "forced_off" });
expect(resolveCanary("test-beta")).toMatchObject({ enabled: true, reason: "forced_on" });
});
it("excludes CI from percentage enrolment, but an override still reaches it", () => {
systemState.is_ci = true;
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "excluded" });
__resetCanaryCacheForTests();
process.env.HF_CANARY_TEST_GAMMA = "on";
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: true, reason: "forced_on" });
});
it("fails closed when the install has no anonymousId", () => {
configState.anonymousId = "";
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "no_unit_id" });
});
it("memoizes so a decision cannot change mid-process", () => {
expect(isCanaryEnabled("test-beta")).toBe(false);
// A late env change must NOT flip a render that already started.
process.env.HF_CANARY_TEST_BETA = "on";
expect(isCanaryEnabled("test-beta")).toBe(false);
__resetCanaryCacheForTests();
expect(isCanaryEnabled("test-beta")).toBe(true);
});
it("emits PostHog flag-shaped properties for every registered canary", () => {
expect(canaryEventProperties()).toEqual({
"$feature/canary-test-alpha": "true",
"$feature/canary-test-gamma": expect.stringMatching(/^(true|false)$/),
"$feature/canary-test-beta": "false",
});
__resetCanaryCacheForTests();
process.env.HF_CANARY_TEST_ALPHA = "off";
expect(canaryEventProperties()["$feature/canary-test-alpha"]).toBe("false");
});
});