diff --git a/docs/contributing/canary-rollouts.mdx b/docs/contributing/canary-rollouts.mdx
index 0db264ee6..4b7344dac 100644
--- a/docs/contributing/canary-rollouts.mdx
+++ b/docs/contributing/canary-rollouts.mdx
@@ -167,18 +167,24 @@ experiment, and it survives uninstall), and account identity covers only
accepted limit — but its worst consequence is mitigated, and its size is now
directly measurable rather than inferred:
+- **Cohorts survive the wipe.** Canaries bucket on a dedicated `bucketSeed` —
+ not the telemetry id — and the seed is inherited across config wipes via a
+ machine-local state file (write-once: the first install's seed is the
+ lineage's seed forever). A wipe re-rolls the telemetry id, never the canary
+ assignment. The seed is never emitted, so it does not link the old id to
+ the new one.
- **A re-rolled install cannot re-enter a path that already failed on that
- machine.** The circuit breaker's tripped state is mirrored to the
- machine-local state file, so it survives the same config wipe that re-rolls
- the cohort.
+ machine.** The circuit breaker's tripped state is mirrored to the same
+ state file.
- **`install_predecessor_found`** on every event says whether this install's
mint found a previous install's state marker. Its true-share splits check
2's drift into the recoverable part (config wiped, machine persisted) and
the part no local mechanism can link (fresh machine, container, new user).
-The calibration read is therefore two numbers — total drift, and the fraction
-the rollover already covers — and canary window lengths get picked from the
-residual, not the raw turnover.
+With the seed carryover, check 2's residual drift comes from the
+unrecoverable buckets only — fresh machines, containers, and genuinely new
+users — and canary window lengths get picked from that residual, not the raw
+turnover.
## Behaviour worth knowing
@@ -186,11 +192,16 @@ residual, not the raw turnover.
in the 10. Cohorts never reshuffle, so a before/after comparison stays valid
across a ramp.
-**Slices are independent per feature.** The bucket hashes `feature:installId`,
-not the install id alone — two canaries at 10% select two different 10%s. If
+**Slices are independent per feature.** The bucket hashes `feature:seed`,
+not the seed alone — two canaries at 10% select two different 10%s. If
they shared a slice, one unlucky cohort would receive every experiment at
once and no two rollouts could be read apart.
+**Cohorts are keyed to the machine, not the config.** The bucketing unit is a
+dedicated seed inherited across config wipes (see the calibration section) —
+distinct from the telemetry id, never emitted, and shared with a CLI-launched
+Studio so both surfaces agree.
+
**It fails closed.** No install id, an unregistered name, or a CI machine all
resolve to *not enrolled*. A canary exists to bound blast radius, so "we
don't know who this is" must never mean "enrol everyone". CI is excluded
diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts
index 358189ad6..9bf7e9742 100644
--- a/packages/cli/src/server/studioServer.ts
+++ b/packages/cli/src/server/studioServer.ts
@@ -16,7 +16,11 @@ import {
loadRuntimeSourceSignature,
} from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
-import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js";
+import {
+ buildStudioHeadScripts,
+ resolveCliBucketSeed,
+ resolveCliTelemetryDistinctId,
+} from "./telemetryIdentity.js";
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
import { isDevMode } from "../utils/env.js";
import {
@@ -652,7 +656,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// 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() });
+ return c.json({
+ distinctId: resolveCliTelemetryDistinctId(),
+ bucketSeed: resolveCliBucketSeed(),
+ });
});
app.get("/api/events", (c) => {
diff --git a/packages/cli/src/server/telemetryIdentity.test.ts b/packages/cli/src/server/telemetryIdentity.test.ts
index 9f779841f..cd5535bd2 100644
--- a/packages/cli/src/server/telemetryIdentity.test.ts
+++ b/packages/cli/src/server/telemetryIdentity.test.ts
@@ -66,6 +66,14 @@ describe("buildCliIdentityScript", () => {
);
});
+ it("also seeds window.__HF_CLI_BUCKET_SEED when the config carries a bucket seed", () => {
+ shouldTrack.mockReturnValue(true);
+ readConfig.mockReturnValue({ anonymousId: "machine-uuid", bucketSeed: "seed-uuid" });
+ expect(buildCliIdentityScript()).toBe(
+ '',
+ );
+ });
+
it("emits an empty string when telemetry is disabled (nothing to seed)", () => {
shouldTrack.mockReturnValue(false);
expect(buildCliIdentityScript()).toBe("");
diff --git a/packages/cli/src/server/telemetryIdentity.ts b/packages/cli/src/server/telemetryIdentity.ts
index f5d4d36df..9a24765e4 100644
--- a/packages/cli/src/server/telemetryIdentity.ts
+++ b/packages/cli/src/server/telemetryIdentity.ts
@@ -41,15 +41,39 @@ export function resolveCliTelemetryDistinctId(): string | null {
* `url_hash` telemetry or browser history. Empty string when there's nothing to
* seed (telemetry off / no id).
*/
+/**
+ * The CLI's canary bucket seed to hand to Studio, or null. Injected alongside
+ * the distinct id so a CLI-launched Studio buckets canaries on the SAME unit
+ * as the CLI — without it the two surfaces would agree only while the seed
+ * still equals whatever Studio falls back to, and a rollout spanning render
+ * and editor would split one user across cohorts. Same telemetry gate as the
+ * distinct id: seeding is part of the identity stitch, not a separate channel.
+ */
+export function resolveCliBucketSeed(): string | null {
+ try {
+ if (!telemetryShouldTrack()) return null;
+ const seed = readConfig().bucketSeed;
+ return typeof seed === "string" && seed.length > 0 ? seed : null;
+ } catch {
+ return null;
+ }
+}
+
+// JSON.stringify does not escape "<" or "/". Escaping both means no
+// "" (or "…") sequence can form in the emitted value, so it can
+// never terminate the inline " (or "…")
- // sequence can form in the emitted value, so it can never terminate the
- // inline `;
+ const parts = [`window.__HF_CLI_DISTINCT_ID=${encodeInlineScriptValue(cliId)};`];
+ const seed = resolveCliBucketSeed();
+ if (seed) parts.push(`window.__HF_CLI_BUCKET_SEED=${encodeInlineScriptValue(seed)};`);
+ return ``;
}
/**
diff --git a/packages/cli/src/telemetry/canary.test.ts b/packages/cli/src/telemetry/canary.test.ts
index 0d5043170..f1d8b8b10 100644
--- a/packages/cli/src/telemetry/canary.test.ts
+++ b/packages/cli/src/telemetry/canary.test.ts
@@ -1,10 +1,13 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
-const configState = { anonymousId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717" };
+const configState: { anonymousId: string; bucketSeed: string | undefined } = {
+ anonymousId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717",
+ bucketSeed: undefined,
+};
const systemState = { is_ci: false };
vi.mock("./config.js", () => ({
- readConfig: () => ({ anonymousId: configState.anonymousId }),
+ readConfig: () => ({ anonymousId: configState.anonymousId, bucketSeed: configState.bucketSeed }),
}));
vi.mock("./system.js", () => ({
getSystemMeta: () => ({ is_ci: systemState.is_ci }),
@@ -60,11 +63,44 @@ const { isCanaryEnabled, resolveCanary, canaryEventProperties, __resetCanaryCach
beforeEach(() => {
__resetCanaryCacheForTests();
configState.anonymousId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
+ configState.bucketSeed = undefined;
systemState.is_ci = false;
delete process.env.HF_CANARY_TEST_ALPHA;
delete process.env.HF_CANARY_TEST_BETA;
});
+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-alpha").bucket;
+ const bySeed = evaluateCanary({
+ feature: "test-alpha",
+ unitId: configState.bucketSeed,
+ percentage: 100,
+ }).bucket;
+ const byId = evaluateCanary({
+ feature: "test-alpha",
+ unitId: configState.anonymousId,
+ percentage: 100,
+ }).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-alpha").bucket;
+ const byId = evaluateCanary({
+ feature: "test-alpha",
+ unitId: configState.anonymousId,
+ percentage: 100,
+ }).bucket;
+ expect(viaBinding).toBe(byId);
+ });
+});
+
describe("CLI canary binding", () => {
it("reads the percentage from the registry", () => {
expect(isCanaryEnabled("test-alpha")).toBe(true);
diff --git a/packages/cli/src/telemetry/canary.ts b/packages/cli/src/telemetry/canary.ts
index 56d41ae7e..49d86918e 100644
--- a/packages/cli/src/telemetry/canary.ts
+++ b/packages/cli/src/telemetry/canary.ts
@@ -55,10 +55,15 @@ export function resolveCanary(name: string): CanaryDecision {
if (cached) return cached;
const definition = findCanary(name);
+ const config = readConfig();
const decision: CanaryDecision = definition
? evaluateCanary({
feature: definition.name,
- unitId: readConfig().anonymousId,
+ // The bucket seed, NOT the anonymousId: the seed is inherited across
+ // config wipes via the install-state file, so the machine keeps its
+ // cohorts when the telemetry id re-rolls. Fallback covers only a
+ // failed backfill write on a legacy config.
+ unitId: config.bucketSeed ?? config.anonymousId,
percentage: definition.percentage,
override: parseCanaryOverride(process.env[canaryEnvVar(definition.name)]),
// CI installs regenerate their config per run, so their ids are
diff --git a/packages/cli/src/telemetry/config.test.ts b/packages/cli/src/telemetry/config.test.ts
index 571ef736a..646284eda 100644
--- a/packages/cli/src/telemetry/config.test.ts
+++ b/packages/cli/src/telemetry/config.test.ts
@@ -204,11 +204,18 @@ describe("install-state rollover (breaker survives a config re-mint)", () => {
expect(readConfigFresh().deParallelRouterTrialFired).toBe(true);
});
- it("the state file holds no identity — only the marker timestamp and breaker fact", () => {
+ it("the state file holds no telemetry identity — marker, breaker fact, bucket seed only", () => {
const config = readConfig();
config.deParallelRouterTrialFired = true;
writeConfig(config);
- expect(Object.keys(stateFile()).sort()).toEqual(["deParallelRouterTrialFired", "markerAt"]);
+ expect(Object.keys(stateFile()).sort()).toEqual([
+ "bucketSeed",
+ "deParallelRouterTrialFired",
+ "markerAt",
+ ]);
+ // The seed is a separate random UUID, never the anonymousId — the old
+ // telemetry id must not survive a wipe in any form.
+ expect(config.bucketSeed).not.toBe(config.anonymousId);
expect(JSON.stringify(stateFile())).not.toContain(config.anonymousId);
});
@@ -292,3 +299,70 @@ describe("install-state rollover (breaker survives a config re-mint)", () => {
expect(fsState.files.has(LEGACY_STATE_PATH)).toBe(false);
});
});
+
+describe("bucket-seed carryover (cohorts survive a config wipe)", () => {
+ let readConfig: typeof import("./config.js").readConfig;
+ let readConfigFresh: typeof import("./config.js").readConfigFresh;
+ let writeConfig: typeof import("./config.js").writeConfig;
+ let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
+ let STATE_PATH: typeof import("./config.js").STATE_PATH;
+
+ beforeEach(async () => {
+ fsState.files.clear();
+ vi.resetModules();
+ ({ readConfig, readConfigFresh, writeConfig, CONFIG_PATH, STATE_PATH } =
+ await import("./config.js"));
+ });
+
+ it("a fresh install mints a seed distinct from its anonymousId and mirrors it to the state file", () => {
+ const config = readConfig();
+ expect(config.bucketSeed).toBeTruthy();
+ expect(config.bucketSeed).not.toBe(config.anonymousId);
+ const state = JSON.parse(fsState.files.get(STATE_PATH) as string) as { bucketSeed?: string };
+ expect(state.bucketSeed).toBe(config.bucketSeed);
+ });
+
+ it("the seed survives a config wipe — the machine keeps its cohorts, only the id re-rolls", () => {
+ const first = readConfig();
+ fsState.files.delete(CONFIG_PATH);
+ const second = readConfigFresh();
+ expect(second.bucketSeed).toBe(first.bucketSeed);
+ expect(second.anonymousId).not.toBe(first.anonymousId);
+ });
+
+ it("the seed survives config corruption via the same mint path", () => {
+ const first = readConfig();
+ fsState.files.set(CONFIG_PATH, "{not valid json");
+ expect(readConfigFresh().bucketSeed).toBe(first.bucketSeed);
+ });
+
+ it("the state-file seed is write-once — a later install never overwrites the lineage seed", () => {
+ const first = readConfig();
+ fsState.files.delete(CONFIG_PATH);
+ const second = readConfigFresh();
+ // Force more config writes from the second install; the state seed must not move.
+ second.commandCount = 7;
+ writeConfig(second);
+ const state = JSON.parse(fsState.files.get(STATE_PATH) as string) as { bucketSeed?: string };
+ expect(state.bucketSeed).toBe(first.bucketSeed);
+ });
+
+ it("a legacy config without a seed is backfilled ONCE and stays stable across fresh reads", () => {
+ const base = readConfig();
+ const { bucketSeed: _dropped, ...legacy } = base;
+ fsState.files.set(CONFIG_PATH, JSON.stringify(legacy));
+ fsState.files.delete(STATE_PATH); // no lineage either — pure legacy machine
+ const first = readConfigFresh();
+ expect(first.bucketSeed).toBeTruthy();
+ const second = readConfigFresh();
+ expect(second.bucketSeed).toBe(first.bucketSeed); // persisted, not re-rolled per read
+ });
+
+ it("a legacy config adopts the lineage seed from the state file when one exists", () => {
+ const base = readConfig(); // wrote the state file with a seed
+ const lineageSeed = base.bucketSeed;
+ const { bucketSeed: _dropped, ...legacy } = base;
+ fsState.files.set(CONFIG_PATH, JSON.stringify(legacy));
+ expect(readConfigFresh().bucketSeed).toBe(lineageSeed);
+ });
+});
diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts
index 87831bec1..23c254268 100644
--- a/packages/cli/src/telemetry/config.ts
+++ b/packages/cli/src/telemetry/config.ts
@@ -56,6 +56,12 @@ interface InstallState {
markerAt: string;
/** Rolled-over circuit-breaker state — see HyperframesConfig's field. */
deParallelRouterTrialFired?: boolean;
+ /**
+ * The machine's canary bucketing seed — see HyperframesConfig's field.
+ * Write-once: the first install's seed is the lineage's seed forever, so a
+ * config wipe re-mints the telemetry id but NOT the canary cohorts.
+ */
+ bucketSeed?: string;
}
/** Parse one state file; any parse/shape failure reads as absent. */
@@ -67,6 +73,10 @@ function parseInstallState(file: string): InstallState | null {
return {
markerAt: parsed.markerAt,
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
+ bucketSeed:
+ typeof parsed.bucketSeed === "string" && parsed.bucketSeed.length > 0
+ ? parsed.bucketSeed
+ : undefined,
};
} catch {
return null;
@@ -136,18 +146,35 @@ function writeInstallState(next: InstallState): void {
* no breaker write site can forget it. Never throws — same contract as the
* rest of this file, telemetry must not break the CLI.
*/
+function sameInstallState(a: InstallState, b: InstallState): boolean {
+ return (
+ a.deParallelRouterTrialFired === b.deParallelRouterTrialFired && a.bucketSeed === b.bucketSeed
+ );
+}
+
+/** Latching: once tripped (by any install in this machine's lineage), stays tripped. */
+function latchedFired(state: InstallState | null, config: HyperframesConfig): true | undefined {
+ return (
+ state?.deParallelRouterTrialFired === true ||
+ config.deParallelRouterTrialFired === true ||
+ undefined
+ );
+}
+
/** What the state file should say after this config write; null = already correct. */
-function nextInstallState(state: InstallState | null, wantFired: boolean): InstallState | null {
- const hadFired = state?.deParallelRouterTrialFired === true;
- if (state !== null && (hadFired || !wantFired)) return null;
- // Every path reaching here has hadFired === false (state is either null, or
- // the guard above already returned when hadFired was true) — the field is
- // simply wantFired, not a merge of the two (review nit, two independent
- // reviewers).
- return {
+function nextInstallState(
+ state: InstallState | null,
+ config: HyperframesConfig,
+): InstallState | null {
+ const next: InstallState = {
markerAt: state?.markerAt ?? new Date().toISOString(),
- deParallelRouterTrialFired: wantFired || undefined,
+ deParallelRouterTrialFired: latchedFired(state, config),
+ // Write-once: an existing lineage seed always wins, so cohorts stay
+ // anchored to the FIRST install in this config dir, not the latest one.
+ bucketSeed: state?.bucketSeed ?? config.bucketSeed,
};
+ if (state !== null && sameInstallState(state, next)) return null;
+ return next;
}
function syncInstallState(config: HyperframesConfig): void {
@@ -155,7 +182,7 @@ function syncInstallState(config: HyperframesConfig): void {
if (stateMarkerSynced && (stateFiredSynced || !wantFired)) return;
try {
const state = readInstallState();
- const next = nextInstallState(state, wantFired);
+ const next = nextInstallState(state, config);
if (next !== null) writeInstallState(next);
stateMarkerSynced = true;
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
@@ -176,8 +203,11 @@ function mintConfig(): HyperframesConfig {
anonymousId: randomUUID(),
predecessorFound: state !== null,
// The rollover itself: a breaker tripped by a previous install on this
- // machine stays tripped for the new one.
+ // machine stays tripped for the new one, and the canary bucketing seed is
+ // inherited so the machine keeps its cohorts — a wipe re-rolls the
+ // telemetry id, never the canary assignment.
deParallelRouterTrialFired: state?.deParallelRouterTrialFired === true ? true : undefined,
+ bucketSeed: state?.bucketSeed ?? randomUUID(),
};
}
@@ -264,6 +294,17 @@ export interface HyperframesConfig {
* existed — a different fact from `false` (minted fresh, no predecessor).
*/
predecessorFound?: boolean;
+ /**
+ * The unit canary percentages bucket on — deliberately NOT the anonymousId.
+ * A fresh random UUID, mirrored write-once into the install-state file and
+ * inherited at mint, so a config wipe re-rolls the telemetry id but keeps
+ * the machine's canary cohorts: no cumulative-exposure drift from wipes,
+ * and before/after comparisons survive a reinstall. It is never emitted in
+ * telemetry (only the resulting true/false assignments are), so it does not
+ * link the old id to the new one server-side. Backfilled once for configs
+ * predating the field.
+ */
+ bucketSeed?: string;
/**
* Ring of the last few local renders (newest last). `hyperframes feedback`
* attaches these ids — which are the `render_job_id` /
@@ -310,6 +351,26 @@ const DEFAULT_CONFIG: HyperframesConfig = {
let cachedConfig: HyperframesConfig | null = null;
+/** A non-empty string, or undefined — hand-edited configs can carry anything. */
+function parseNonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+/** Shape-validate the recent-renders ring from a parsed config. */
+function parseRecentRenders(value: unknown): RecentRenderRecord[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ return value
+ .filter(
+ (r): r is RecentRenderRecord =>
+ typeof r === "object" &&
+ r !== null &&
+ typeof (r as RecentRenderRecord).id === "string" &&
+ typeof (r as RecentRenderRecord).at === "string" &&
+ typeof (r as RecentRenderRecord).ok === "boolean",
+ )
+ .slice(-MAX_RECENT_RENDERS);
+}
+
/**
* Read the config file, creating it with defaults if it doesn't exist.
* Returns a mutable copy — call `writeConfig()` to persist changes.
@@ -356,20 +417,23 @@ export function readConfig(): HyperframesConfig {
: undefined,
predecessorFound:
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
- recentRenders: Array.isArray(parsed.recentRenders)
- ? parsed.recentRenders
- .filter(
- (r): r is RecentRenderRecord =>
- typeof r === "object" &&
- r !== null &&
- typeof (r as RecentRenderRecord).id === "string" &&
- typeof (r as RecentRenderRecord).at === "string" &&
- typeof (r as RecentRenderRecord).ok === "boolean",
- )
- .slice(-MAX_RECENT_RENDERS)
- : undefined,
+ bucketSeed: parseNonEmptyString(parsed.bucketSeed),
+ recentRenders: parseRecentRenders(parsed.recentRenders),
};
+ // One-time backfill for configs predating the bucket seed: prefer the
+ // lineage seed if a previous install already recorded one, else mint.
+ // Persisted immediately — an unpersisted seed would re-roll every process.
+ if (config.bucketSeed === undefined) {
+ config.bucketSeed = readInstallState()?.bucketSeed ?? randomUUID();
+ writeConfig(config);
+ // Cache even if the write failed, so the seed is at least stable for
+ // the life of this process (a re-roll per readConfigFresh would flip
+ // cohorts mid-session).
+ cachedConfig = config;
+ return { ...config };
+ }
+
cachedConfig = config;
return { ...config };
} catch {
diff --git a/packages/studio/src/telemetry/canary.test.ts b/packages/studio/src/telemetry/canary.test.ts
index 5ae407721..bd51b998b 100644
--- a/packages/studio/src/telemetry/canary.test.ts
+++ b/packages/studio/src/telemetry/canary.test.ts
@@ -47,6 +47,7 @@ beforeEach(() => {
sessionStorage.clear();
setSearch("");
delete window.__HF_CLI_DISTINCT_ID;
+ delete window.__HF_CLI_BUCKET_SEED;
Object.defineProperty(navigator, "webdriver", { value: false, configurable: true });
__resetStudioCanaryCacheForTests();
__resetStudioDistinctIdForTests();
@@ -131,19 +132,35 @@ describe("automated browsers", () => {
});
describe("cohort identity", () => {
- it("buckets on the Studio distinct id unmodified — so a CLI-launched Studio shares the CLI's cohort", () => {
- // distinctId.ts adopts window.__HF_CLI_DISTINCT_ID when the CLI launched
- // Studio. This asserts the binding passes that id through untouched: if it
- // prefixed or re-hashed it, the editor would land in a different cohort
- // than the terminal for the same user, and a rollout spanning both would
- // be incoherent.
+ it("buckets on the CLI's bucket seed when injected — the unit that survives config wipes", () => {
+ // The CLI buckets on its bucketSeed (inherited across config wipes via
+ // the install-state file), so a CLI-launched Studio must bucket on the
+ // SAME seed or the two surfaces would split one machine across cohorts.
+ const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
+ const cliSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
+ window.__HF_CLI_DISTINCT_ID = cliId;
+ window.__HF_CLI_BUCKET_SEED = cliSeed;
+ __resetStudioDistinctIdForTests();
+ __resetStudioCanaryCacheForTests();
+
+ // Telemetry identity still adopts the DISTINCT id — the seed only buckets.
+ expect(resolveStudioDistinctId()).toBe(cliId);
+ const viaBinding = resolveCanary("on-everywhere").bucket;
+ const bySeed = evaluateCanary({
+ feature: "on-everywhere",
+ unitId: cliSeed,
+ percentage: 100,
+ }).bucket;
+ expect(viaBinding).toBe(bySeed);
+ });
+
+ it("buckets on the Studio distinct id when no seed is injected (standalone Studio)", () => {
const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
window.__HF_CLI_DISTINCT_ID = cliId;
__resetStudioDistinctIdForTests();
__resetStudioCanaryCacheForTests();
expect(resolveStudioDistinctId()).toBe(cliId);
- // 50% so the answer is id-dependent rather than trivially true.
const viaBinding = resolveCanary("on-everywhere").bucket;
const direct = evaluateCanary({
feature: "on-everywhere",
diff --git a/packages/studio/src/telemetry/canary.ts b/packages/studio/src/telemetry/canary.ts
index bf8b13905..af95db2b8 100644
--- a/packages/studio/src/telemetry/canary.ts
+++ b/packages/studio/src/telemetry/canary.ts
@@ -14,11 +14,11 @@
//
// Three things differ from the CLI, each for a reason:
//
-// 1. UNIT ID — `resolveStudioDistinctId()` instead of the CLI's config file.
-// That function already adopts `window.__HF_CLI_DISTINCT_ID` when the CLI
-// launched Studio, so a CLI-launched Studio lands in the SAME cohort as the
-// CLI itself: a rollout spanning render and editor is coherent for that
-// user instead of enrolling their terminal but not their editor.
+// 1. UNIT ID — the CLI's bucket seed (`window.__HF_CLI_BUCKET_SEED`) when the
+// CLI launched Studio, so both surfaces bucket on the SAME unit and a
+// rollout spanning render and editor is coherent for that user. Standalone
+// Studio falls back to `resolveStudioDistinctId()` — the browser has no
+// second storage location, so its localStorage id doubles as the seed.
//
// 2. OVERRIDE — there is no `process.env` in a page, so the override is a URL
// query param mirrored into sessionStorage (see `readOverride`).
@@ -48,6 +48,30 @@ export function canaryParamName(name: string): string {
return `hf_canary_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
}
+// Injected by the CLI's studio server alongside __HF_CLI_DISTINCT_ID.
+declare global {
+ interface Window {
+ __HF_CLI_BUCKET_SEED?: string;
+ }
+}
+
+/**
+ * The bucketing unit. A CLI-launched Studio buckets on the CLI's SEED (which
+ * survives config wipes via the install-state file), not its distinct id —
+ * the CLI itself buckets on the seed, and the two surfaces must agree per
+ * machine. Standalone Studio falls back to its own distinct id: the browser
+ * has no second storage location, so localStorage IS both id and seed there.
+ */
+function resolveBucketUnit(): string {
+ try {
+ const seed = typeof window === "undefined" ? undefined : window.__HF_CLI_BUCKET_SEED;
+ if (typeof seed === "string" && seed.length > 0) return seed;
+ } catch {
+ /* fall through */
+ }
+ return resolveStudioDistinctId();
+}
+
const STORAGE_PREFIX = "hyperframes-studio:canary:";
/**
@@ -130,7 +154,7 @@ export function resolveCanary(name: string): CanaryDecision {
const decision: CanaryDecision = definition
? evaluateCanary({
feature: definition.name,
- unitId: resolveStudioDistinctId(),
+ unitId: resolveBucketUnit(),
percentage: definition.percentage,
override: readOverride(definition.name),
exclude: isAutomatedBrowser(),