From 3aea78668734772f9cc83bac81e2495cae2140b0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 28 Jul 2026 05:43:26 -0700 Subject: [PATCH] feat(core): percentage-based canary rollouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable staged-rollout primitive so a change can ship to a stable slice of installs instead of all-or-nothing. The gap it fills: the repo carries ~49 HF_*/PRODUCER_* booleans and every one is binary — a feature is either off (and therefore unexercised on real traffic) or on for everyone (and therefore a fleet-wide bet). The parallel-drawElement router sat in that gap for weeks: default-off produced almost no signal, and flipping it default-on would have exposed 100% of eligible installs at once. Shape: - packages/core/src/canary.ts — pure evaluator. No fs, no network, no `process`; the caller supplies the unit id and overrides, so it imports cleanly into the CLI, producer, engine, studio-server, the browser-side studio bundle and the embeddable player. FNV-1a rather than node:crypto for the same reason. - packages/core/src/canaryRegistry.ts — every rollout in one table (name, percentage, owner, description, sunsetAfter), so "what is rolling out, to whom, owned by whom" is answerable without grepping 49 env vars. - packages/cli/src/telemetry/canary.ts — supplies the three things only the CLI knows: anonymousId, the HF_CANARY_ override, and is_ci. Day-to-day API is `isCanaryEnabled("name")`. Three properties the tests pin, because getting them wrong is subtle: - Slices are INDEPENDENT per feature: the bucket hashes `feature:unitId`, not the id alone. Bucketing on the id would hand every concurrent experiment to the same unlucky cohort and make two rollouts unreadable apart. - Ramping is INCLUSIVE: `bucket < percentage`, so widening 10 -> 25 keeps the original cohort and before/after comparisons survive the ramp. - It fails CLOSED: no unit id, unknown name, or CI install means not enrolled. A canary exists to bound blast radius, so "we don't know who this is" must never mean "enrol everyone". Registry entries also carry a sunset date, and a test fails once one is past due — a canary that outlives its rollout is a permanent fork of the product with none of the review a permanent fork would get. Ships with de-parallel-router registered at 0%: inert, and ready to ramp in a patch release once #2840 lands. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/telemetry/canary.test.ts | 113 ++++++++++++++ packages/cli/src/telemetry/canary.ts | 88 +++++++++++ packages/core/src/canary.test.ts | 171 ++++++++++++++++++++++ packages/core/src/canary.ts | 136 +++++++++++++++++ packages/core/src/canaryRegistry.ts | 77 ++++++++++ packages/core/src/index.ts | 16 ++ 6 files changed, 601 insertions(+) create mode 100644 packages/cli/src/telemetry/canary.test.ts create mode 100644 packages/cli/src/telemetry/canary.ts create mode 100644 packages/core/src/canary.test.ts create mode 100644 packages/core/src/canary.ts create mode 100644 packages/core/src/canaryRegistry.ts diff --git a/packages/cli/src/telemetry/canary.test.ts b/packages/cli/src/telemetry/canary.test.ts new file mode 100644 index 000000000..0fca15134 --- /dev/null +++ b/packages/cli/src/telemetry/canary.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const configState = { anonymousId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717" }; +const systemState = { is_ci: false }; + +vi.mock("./config.js", () => ({ + readConfig: () => ({ anonymousId: configState.anonymousId }), +})); +vi.mock("./system.js", () => ({ + getSystemMeta: () => ({ is_ci: systemState.is_ci }), +})); + +// 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", async () => { + const actual = await vi.importActual("@hyperframes/core"); + return { + ...actual, + CANARIES: [ + { + name: "test-alpha", + percentage: 100, + description: "always on", + 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-beta", + percentage: 0, + description: "", + owner: "t", + sunsetAfter: "2099-01-01", + }, + ].find((c) => c.name === n), + }; +}); + +const { isCanaryEnabled, resolveCanary, activeCanaryNames, __resetCanaryCacheForTests } = + await import("./canary.js"); + +beforeEach(() => { + __resetCanaryCacheForTests(); + configState.anonymousId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717"; + systemState.is_ci = false; + delete process.env.HF_CANARY_TEST_ALPHA; + delete process.env.HF_CANARY_TEST_BETA; +}); + +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_ 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-alpha")).toMatchObject({ enabled: false, reason: "excluded" }); + + __resetCanaryCacheForTests(); + process.env.HF_CANARY_TEST_ALPHA = "on"; + expect(resolveCanary("test-alpha")).toMatchObject({ enabled: true, reason: "forced_on" }); + }); + + it("fails closed when the install has no anonymousId", () => { + configState.anonymousId = ""; + expect(resolveCanary("test-alpha")).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("reports enrolled canaries for telemetry, undefined when none", () => { + expect(activeCanaryNames()).toBe("test-alpha"); + __resetCanaryCacheForTests(); + process.env.HF_CANARY_TEST_ALPHA = "off"; + expect(activeCanaryNames()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/canary.ts b/packages/cli/src/telemetry/canary.ts new file mode 100644 index 000000000..69d19b4db --- /dev/null +++ b/packages/cli/src/telemetry/canary.ts @@ -0,0 +1,88 @@ +/** + * CLI binding for the canary registry. + * + * `@hyperframes/core` owns the decision (pure, browser-safe, caller supplies + * everything). This file supplies the three things only the CLI knows: the + * install's stable id, the env override, and whether we're on CI. + * + * Using one, from anywhere in the CLI / producer call path: + * + * ```ts + * import { isCanaryEnabled } from "../telemetry/canary.js"; + * if (isCanaryEnabled("de-parallel-router")) { ...ramped path... } + * ``` + * + * That is the whole API. Percentage lives in the registry, not at the call + * site, so ramping is a one-line edit in a patch release and never touches + * the feature's own code. + */ + +import { + CANARIES, + canaryEnvVar, + evaluateCanary, + findCanary, + parseCanaryOverride, + type CanaryDecision, +} from "@hyperframes/core"; +import { readConfig } from "./config.js"; +import { getSystemMeta } from "./system.js"; + +/** + * Decisions are memoized per process: a `--batch` run asks the same question + * once per row, and a canary must not change its mind mid-process — a render + * that starts enrolled has to finish enrolled, and its telemetry has to agree + * with what actually ran. + */ +const decisions = new Map(); + +/** Test-only: drop memoized decisions so cases don't leak into each other. */ +export function __resetCanaryCacheForTests(): void { + decisions.clear(); +} + +/** + * Full decision for a registered canary, including the reason — use this when + * you want to record WHY, not just whether. + * + * An unregistered name resolves to off rather than throwing: a canary is a + * rollout control, and a typo in one must never take down a render. + */ +export function resolveCanary(name: string): CanaryDecision { + const cached = decisions.get(name); + if (cached) return cached; + + const definition = findCanary(name); + const decision: CanaryDecision = definition + ? evaluateCanary({ + feature: definition.name, + unitId: readConfig().anonymousId, + percentage: definition.percentage, + override: parseCanaryOverride(process.env[canaryEnvVar(definition.name)]), + // CI installs regenerate their config per run, so their ids are + // ephemeral — they would hop cohorts between runs, adding noise to the + // rollout signal while saying nothing about real users. An explicit + // override still gets through, which is how you test a canary in CI. + exclude: getSystemMeta().is_ci, + }) + : { enabled: false, reason: "out_of_cohort" }; + + decisions.set(name, decision); + return decision; +} + +/** Is this canary on for this install? The everyday call. */ +export function isCanaryEnabled(name: string): boolean { + return resolveCanary(name).enabled; +} + +/** + * Comma-joined names of the canaries this install is enrolled in, or + * undefined when none — attach to telemetry so every event can be segmented + * by cohort. One low-cardinality property beats a dynamic property per + * canary, and `contains` filtering works fine in PostHog. + */ +export function activeCanaryNames(): string | undefined { + const active = CANARIES.filter((c) => resolveCanary(c.name).enabled).map((c) => c.name); + return active.length > 0 ? active.join(",") : undefined; +} diff --git a/packages/core/src/canary.test.ts b/packages/core/src/canary.test.ts new file mode 100644 index 000000000..455f8d1d1 --- /dev/null +++ b/packages/core/src/canary.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { canaryBucket, evaluateCanary, parseCanaryOverride, type CanaryInput } from "./canary.js"; +import { CANARIES, canaryEnvVar, findCanary, overdueCanaries } from "./canaryRegistry.js"; + +const base = (over: Partial = {}): CanaryInput => ({ + feature: "test-feature", + unitId: "db0c1f4a-b95e-4c35-90c6-1a15bd76f717", + percentage: 10, + ...over, +}); + +/** A realistic population: install ids are v4 UUIDs (`randomUUID()`). */ +function uuids(n: number): string[] { + return Array.from({ length: n }, () => randomUUID()); +} + +describe("evaluateCanary", () => { + it("is deterministic for the same feature + unit", () => { + const a = evaluateCanary(base()); + const b = evaluateCanary(base()); + expect(a).toEqual(b); + }); + + it("honours an explicit override in both directions, over any percentage", () => { + expect(evaluateCanary(base({ percentage: 0, override: true }))).toEqual({ + enabled: true, + reason: "forced_on", + }); + expect(evaluateCanary(base({ percentage: 100, override: false }))).toEqual({ + enabled: false, + reason: "forced_off", + }); + }); + + it("0% is off for everyone and 100% is on for everyone", () => { + for (const id of uuids(50)) { + expect(evaluateCanary(base({ unitId: id, percentage: 0 })).enabled).toBe(false); + expect(evaluateCanary(base({ unitId: id, percentage: 100 })).enabled).toBe(true); + } + }); + + it("fails closed without a unit id — unknown must never mean everyone", () => { + for (const id of [undefined, "", " "]) { + expect(evaluateCanary(base({ unitId: id, percentage: 100 }))).toEqual({ + enabled: false, + reason: "no_unit_id", + }); + } + }); + + it("excludes flagged units (CI) from percentage enrolment but not from an override", () => { + expect(evaluateCanary(base({ percentage: 100, exclude: true })).reason).toBe("excluded"); + expect(evaluateCanary(base({ percentage: 100, exclude: true, override: true })).enabled).toBe( + true, + ); + }); + + it("clamps out-of-range and fractional percentages", () => { + expect(evaluateCanary(base({ percentage: -5 })).enabled).toBe(false); + expect(evaluateCanary(base({ percentage: 999 })).enabled).toBe(true); + // 10.9 truncates to 10 — same cohort as an even 10, no surprise widening. + const ids = uuids(300); + const at10 = ids.filter((id) => evaluateCanary(base({ unitId: id, percentage: 10 })).enabled); + const at109 = ids.filter( + (id) => evaluateCanary(base({ unitId: id, percentage: 10.9 })).enabled, + ); + expect(at109).toEqual(at10); + }); +}); + +describe("cohort properties", () => { + it("ramping is INCLUSIVE — widening never drops an already-enrolled install", () => { + // If a ramp reshuffled the cohort, before/after comparisons across the + // ramp would be meaningless and some users would flap in and out. + const ids = uuids(500); + const enrolledAt = (pct: number) => + new Set(ids.filter((id) => evaluateCanary(base({ unitId: id, percentage: pct })).enabled)); + const p5 = enrolledAt(5); + const p25 = enrolledAt(25); + const p100 = enrolledAt(100); + for (const id of p5) expect(p25.has(id)).toBe(true); + for (const id of p25) expect(p100.has(id)).toBe(true); + expect(p25.size).toBeGreaterThan(p5.size); + }); + + it("different features select INDEPENDENT slices of the same population", () => { + // The whole reason the hash includes the feature name: bucketing on the + // unit id alone would hand every simultaneous experiment to one unlucky + // cohort, and make two rollouts impossible to read apart. + const ids = uuids(2000); + const a = new Set( + ids.filter((id) => evaluateCanary({ feature: "feat-a", unitId: id, percentage: 10 }).enabled), + ); + const b = new Set( + ids.filter((id) => evaluateCanary({ feature: "feat-b", unitId: id, percentage: 10 }).enabled), + ); + const overlap = [...a].filter((id) => b.has(id)).length; + // Independent 10% slices overlap ~1% of the population (~20 of 2000). + // Identical slices would overlap ~200. Assert well below that. + expect(overlap).toBeLessThan(70); + expect(a.size).toBeGreaterThan(0); + expect(b.size).toBeGreaterThan(0); + }); + + it("selects approximately the requested share of a UUID population", () => { + const ids = uuids(4000); + for (const pct of [5, 10, 25]) { + const hits = ids.filter( + (id) => evaluateCanary(base({ unitId: id, percentage: pct })).enabled, + ).length; + const actual = (hits / ids.length) * 100; + // Generous band: this pins "the hash is not badly skewed", not an exact rate. + expect(actual).toBeGreaterThan(pct * 0.6); + expect(actual).toBeLessThan(pct * 1.4); + } + }); + + it("spreads buckets across the full 0-99 range", () => { + const seen = new Set(uuids(2000).map((id) => canaryBucket("spread", id))); + expect(seen.size).toBeGreaterThan(80); + }); +}); + +describe("parseCanaryOverride", () => { + it("accepts the spellings people actually type", () => { + for (const v of ["1", "true", "TRUE", "on", "yes", " On "]) { + expect(parseCanaryOverride(v)).toBe(true); + } + for (const v of ["0", "false", "FALSE", "off", "no", " Off "]) { + expect(parseCanaryOverride(v)).toBe(false); + } + }); + + it("treats unset, empty and unrecognised values as 'no override'", () => { + // An exported-but-empty var must not force a feature on. + for (const v of [undefined, "", " ", "maybe"]) { + expect(parseCanaryOverride(v)).toBeUndefined(); + } + }); +}); + +describe("registry", () => { + it("has unique, kebab-case names", () => { + const names = CANARIES.map((c) => c.name); + expect(new Set(names).size).toBe(names.length); + for (const n of names) expect(n).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + }); + + it("has in-range percentages and a parseable sunset date", () => { + for (const c of CANARIES) { + expect(c.percentage).toBeGreaterThanOrEqual(0); + expect(c.percentage).toBeLessThanOrEqual(100); + expect(Number.isNaN(Date.parse(`${c.sunsetAfter}T00:00:00Z`))).toBe(false); + expect(c.owner.length).toBeGreaterThan(0); + expect(c.description.length).toBeGreaterThan(0); + } + }); + + it("derives the override env var from the name", () => { + expect(canaryEnvVar("de-parallel-router")).toBe("HF_CANARY_DE_PARALLEL_ROUTER"); + expect(findCanary("de-parallel-router")?.name).toBe("de-parallel-router"); + expect(findCanary("nope")).toBeUndefined(); + }); + + it("no canary is past its sunset date", () => { + // Fails the suite when a rollout has been left half-finished. Either take + // it to 100 and delete the entry, or move the date deliberately. + expect(overdueCanaries()).toEqual([]); + }); +}); diff --git a/packages/core/src/canary.ts b/packages/core/src/canary.ts new file mode 100644 index 000000000..0b00e0641 --- /dev/null +++ b/packages/core/src/canary.ts @@ -0,0 +1,136 @@ +/** + * Canary rollouts — ship a change to a stable slice of installs instead of + * all-or-nothing. + * + * The problem this solves: the repo has ~49 `HF_*` / `PRODUCER_*` boolean + * toggles, and every one of them is binary. A change is either off (and + * therefore untested on real traffic) or on for everyone (and therefore a + * fleet-wide bet). The parallel-drawElement router spent weeks in that gap: + * default-off collected almost no signal, and flipping it default-on exposed + * 100% of eligible installs at once. A percentage slice is the missing rung. + * + * Design notes worth knowing before you add one: + * + * - **Pure and universal.** No fs, no network, no `process` — the caller + * supplies the unit id and the overrides. That keeps this importable from + * the CLI, the producer, the engine, studio-server, the browser-side studio + * bundle, and the embeddable player alike. + * + * - **Independent slices.** The bucket is a hash of `feature:unitId`, NOT of + * `unitId` alone. If every canary bucketed on the id by itself, they would + * all select the SAME installs — one unlucky cohort would receive every + * experiment simultaneously, and no two rollouts could be read + * independently. + * + * - **Ramping is inclusive.** `bucket < percentage` means widening 10 → 25 + * keeps every install that was already at 10. Cohorts never reshuffle, so + * before/after comparisons stay valid across a ramp. + * + * - **Stable per install, for the life of the install.** The same id and + * feature always resolve the same way, with no persisted state to keep in + * sync and nothing to look up at runtime. + */ + +/** Why a canary resolved the way it did. Attach to telemetry — a rollout you + * can't segment by enrolment reason is a rollout you can't debug. */ +export type CanaryReason = + | "forced_on" + | "forced_off" + | "in_cohort" + | "out_of_cohort" + | "no_unit_id" + | "excluded"; + +export interface CanaryDecision { + enabled: boolean; + reason: CanaryReason; + /** 0-99 slot this unit landed in for this feature; undefined when not computed. */ + bucket?: number; +} + +export interface CanaryInput { + /** Registry key, e.g. "de-parallel-router". Part of the hash, so each feature gets its own slice. */ + feature: string; + /** Stable per-install id — the CLI's telemetry `anonymousId`. Missing/blank fails closed. */ + unitId: string | undefined; + /** 0 = off for everyone, 100 = on for everyone. Values outside 0-100 are clamped. */ + percentage: number; + /** + * Explicit override, both directions — support escalations, dogfooding, a + * bisect, or a panic-off. Always wins over the percentage. + */ + override?: boolean | undefined; + /** + * Exclude this unit from percentage-based enrolment (an explicit override + * still applies). Callers pass `isCI` here: CI installs regenerate their + * config constantly, so their ids are ephemeral — they would hop cohorts + * between runs, adding noise to the rollout signal while telling you + * nothing about real users. + */ + exclude?: boolean | undefined; +} + +/** + * FNV-1a (32-bit). Chosen over `node:crypto` deliberately: this module has to + * run in the browser-side studio bundle and the embeddable player too, and a + * six-line hash beats shipping a polyfill or maintaining two code paths. + * Distribution is uniform enough for bucketing (pinned by a test). + */ +function fnv1a32(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + // hash * 16777619, kept in 32-bit unsigned range without Math.imul overflow + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash >>> 0; +} + +/** The 0-99 slot a unit occupies for a given feature. Exported for tests and diagnostics. */ +export function canaryBucket(feature: string, unitId: string): number { + return fnv1a32(`${feature}:${unitId}`) % 100; +} + +/** + * Resolve whether a feature is on for this unit. + * + * Fails closed on a missing id: the canary exists to bound blast radius, so + * "we don't know who this is" must mean "not enrolled", never "enrol + * everyone". + */ +export function evaluateCanary(input: CanaryInput): CanaryDecision { + if (input.override === true) return { enabled: true, reason: "forced_on" }; + if (input.override === false) return { enabled: false, reason: "forced_off" }; + + const pct = Math.max(0, Math.min(100, Math.trunc(input.percentage))); + if (pct <= 0) return { enabled: false, reason: "out_of_cohort" }; + + if (input.exclude) return { enabled: false, reason: "excluded" }; + + const unitId = input.unitId?.trim(); + if (!unitId) return { enabled: false, reason: "no_unit_id" }; + + if (pct >= 100) + return { enabled: true, reason: "in_cohort", bucket: canaryBucket(input.feature, unitId) }; + + const bucket = canaryBucket(input.feature, unitId); + return bucket < pct + ? { enabled: true, reason: "in_cohort", bucket } + : { enabled: false, reason: "out_of_cohort", bucket }; +} + +/** + * Parse a canary override from an env-var value. + * + * Accepts the spellings people actually type. Returns undefined for + * unset/empty so the percentage decides — matching how the existing HF_* + * knobs treat a set-but-empty var, and avoiding the failure mode where an + * exported-but-empty variable silently forces a feature on. + */ +export function parseCanaryOverride(raw: string | undefined): boolean | undefined { + const v = raw?.trim().toLowerCase(); + if (v === undefined || v === "") return undefined; + if (v === "1" || v === "true" || v === "on" || v === "yes") return true; + if (v === "0" || v === "false" || v === "off" || v === "no") return false; + return undefined; +} diff --git a/packages/core/src/canaryRegistry.ts b/packages/core/src/canaryRegistry.ts new file mode 100644 index 000000000..b0b6dba87 --- /dev/null +++ b/packages/core/src/canaryRegistry.ts @@ -0,0 +1,77 @@ +/** + * The canary registry — every staged rollout in the product, in one file. + * + * Why a registry rather than a percentage inlined at each call site: the repo + * already carries ~49 loose `HF_*` / `PRODUCER_*` toggles with no index, so + * nobody can answer "what is currently rolling out, to how many people, and + * who owns it" without grepping. One table fixes that, and gives the + * telemetry and `doctor` surfaces something to enumerate. + * + * ## Adding one + * + * 1. Add an entry below. Start at `percentage: 0` and merge that — a canary + * at 0 is dead code you can land safely and ramp without a code review. + * 2. Read it at the decision point via the surface's binding (in the CLI, + * `isCanaryEnabled("your-feature")`). + * 3. Ramp by editing `percentage` in a patch release: 0 → 5 → 25 → 100. + * Widening is inclusive, so the earlier cohort stays enrolled and the + * before/after comparison survives the ramp. + * 4. At 100 and holding, DELETE the entry and the branch it guarded. That is + * the point of `sunsetAfter`. + * + * ## Overriding + * + * `HF_CANARY_` with the feature name upper-snake-cased, e.g. + * `HF_CANARY_DE_PARALLEL_ROUTER=on` (also: off/true/false/1/0/yes/no). + * An override always wins over the percentage, in both directions. + */ + +export interface CanaryDefinition { + /** Registry key. Kebab-case; also the hash input, so renaming reshuffles the cohort. */ + name: string; + /** 0-100. Start at 0, ramp in patch releases. */ + percentage: number; + /** What turning this on actually changes, in one line. */ + description: string; + /** Who to ask. */ + owner: string; + /** + * ISO date after which this canary is overdue for removal. A canary that + * outlives its rollout is a permanent fork of the product with none of the + * review a permanent fork would have received. `assertNoOverdueCanaries` + * turns the date into a failing test rather than a good intention. + */ + sunsetAfter: string; +} + +export const CANARIES: readonly CanaryDefinition[] = [ + { + name: "de-parallel-router", + percentage: 0, + description: + "Route auto multi-worker renders to verified parallel drawElement streaming (HF_DE_PARALLEL_ROUTER). Ramp only alongside the per-install circuit breaker.", + owner: "vance", + sunsetAfter: "2026-10-01", + }, +] as const; + +export function findCanary(name: string): CanaryDefinition | undefined { + return CANARIES.find((c) => c.name === name); +} + +/** Env-var name for a feature's manual override: `de-parallel-router` → `HF_CANARY_DE_PARALLEL_ROUTER`. */ +export function canaryEnvVar(name: string): string { + return `HF_CANARY_${name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`; +} + +/** + * Names of canaries whose sunset date has passed — either finish the rollout + * and delete the entry, or push the date with a reason. Exposed as a function + * (not a lint rule) so the check runs in the normal test suite. + */ +export function overdueCanaries(now: Date = new Date()): string[] { + return CANARIES.filter((c) => { + const sunset = Date.parse(`${c.sunsetAfter}T00:00:00Z`); + return Number.isFinite(sunset) && now.getTime() > sunset; + }).map((c) => c.name); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b23ea509f..8da2e39fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -329,3 +329,19 @@ export { isBlockItem, isComponentItem, } from "./registry/index.js"; + +export { + canaryBucket, + evaluateCanary, + parseCanaryOverride, + type CanaryDecision, + type CanaryInput, + type CanaryReason, +} from "./canary.js"; +export { + CANARIES, + canaryEnvVar, + findCanary, + overdueCanaries, + type CanaryDefinition, +} from "./canaryRegistry.js";