Files
hyperframes/packages/cli/src/telemetry/canary.ts
T
Vance IngallsandClaude Fable 5 19defeabfe feat(core,cli): ship the DE parallel router fleet-wide — remove the canary gate
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.

## Why, and why not a ramp

Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.

PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.

Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.

## Both stated blockers are void

- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
  of 4,281 across every CPU tier, software GL gates it out — and the router
  requires it. No percentage could ever expose Docker, so no ramp closes that
  gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
  0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
  this router. It is real, still live on 0.7.101, and belongs to the
  screenshot/beginframe path. 11 reproduction runs across four configurations
  on the enriched profile (darwin/arm64 25.5.0) came back clean.

## Safety unchanged

The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.

Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.

## Corrects two claims that shipped wrong

`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.

## Tests

Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.

core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.

Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:10:25 -07:00

202 lines
8.4 KiB
TypeScript

/**
* 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("your-feature")) { ...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.
*/
// Leaf subpath imports, not the "@hyperframes/core" barrel: this resolves on
// the CLI startup path, and the barrel pulls the whole core surface. Same
// reason the producer is lazily loaded.
import {
canaryFeatureProperties,
evaluateCanary,
parseCanaryOverride,
type CanaryDecision,
} from "@hyperframes/core/canary";
import { CANARIES, canaryEnvVar, findCanary } from "@hyperframes/core/canary-registry";
import { readConfig } from "./config.js";
import { telemetryRuntimeOverride } from "./policy.js";
import { getSystemMeta } from "./system.js";
/**
* Opting out of telemetry opts you out of canaries.
*
* A canary is a measured rollout: we enrol a slice precisely so we can compare
* it against everyone else. An install that sends nothing can't be compared,
* so enrolling it buys no signal — it only changes that user's code path, on
* an experimental feature, without their knowledge and with no way for us to
* see the result. That is the wrong side of an opt-out.
*
* Deliberately mirrors `shouldTrack()` rather than importing it: client.ts
* already imports this module for `canaryEventProperties`, so depending on it
* here would be a cycle. Both read the same two inputs (`policy.ts`'s runtime
* override, then the persisted preference), so they cannot disagree.
*/
function telemetryActive(): boolean {
if (telemetryRuntimeOverride() !== null) return false;
return readConfig().telemetryEnabled;
}
/**
* 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<string, CanaryDecision>();
// The memo is scoped to a telemetry posture, not to the process. Within one
// render nothing may change (a render that starts enrolled must finish
// enrolled), but `hyperframes preview` is a long-lived server that re-serves
// decisions on every page load — and it used to keep serving pre-opt-out
// decisions for hours after `hyperframes telemetry disable`, which is exactly
// what the docs promise cannot happen.
let decisionsTelemetryPosture: boolean | undefined;
/** Test-only: drop memoized decisions so cases don't leak into each other. */
export function __resetCanaryCacheForTests(): void {
decisions.clear();
decisionsTelemetryPosture = undefined;
}
/** The uncached decision. Split out so `resolveCanary` is purely the memo. */
function decideCanary(name: string): CanaryDecision {
const definition = findCanary(name);
if (!definition) return { enabled: false, reason: "out_of_cohort" };
const override = parseCanaryOverride(process.env[canaryEnvVar(definition.name)]);
// Resolved ahead of evaluate so an opted-out install is never bucketed at
// all. An explicit HF_CANARY_* override still wins: that is a deliberate
// local choice (support session, bisect, developer testing), not silent
// enrolment, and it stays the way to exercise a canary with telemetry off.
if (override === undefined && !telemetryActive()) {
return { enabled: false, reason: "telemetry_opt_out" };
}
const config = readConfig();
return evaluateCanary({
feature: definition.name,
// The bucket seed, NOT the anonymousId: the seed is inherited across
// config.json re-mints via the install-state file, so cohorts hold when
// the telemetry id re-rolls. Both files live in ~/.hyperframes, so
// deleting that directory clears the cohort too — intentional. Fallback
// covers only a failed backfill write on a legacy config.
unitId: config.bucketSeed ?? config.anonymousId,
percentage: definition.percentage,
override,
// 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,
});
}
/**
* 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 posture = telemetryActive();
if (decisionsTelemetryPosture !== posture) {
decisions.clear();
decisionsTelemetryPosture = posture;
}
const cached = decisions.get(name);
if (cached) return cached;
const decision = decideCanary(name);
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;
}
/**
* One canary's outcome as handed to Studio: the answer, plus whether it came
* from an explicit override rather than a percentage roll.
*/
export interface CliCanaryDecision {
enabled: boolean;
forced: boolean;
}
/**
* Every registered canary's resolved on/off for this process, for handing to
* a CLI-launched Studio.
*
* Studio adopts these wholesale instead of re-deriving, because re-deriving
* cannot agree in three cases:
*
* - **Telemetry off.** The CLI resolves `telemetry_opt_out`; Studio's own
* opt-out is a separate localStorage flag on a different machine-level
* switch, so it would evaluate normally and could enrol.
* - **`HF_CANARY_*` override.** Env vars do not cross into the browser at
* all — Studio only reads its URL param / sessionStorage — so a support
* session forcing a canary on got the CLI forced and Studio guessing.
* - **No seed injected.** Whenever the seed is withheld Studio falls back
* to a different unit id, i.e. a different bucket.
*
* Shipping the decision rather than the inputs makes divergence structurally
* impossible: one evaluation, two surfaces. It is also strictly less to
* expose — booleans about features, not the seed the buckets derive from.
*/
export function canaryDecisionsForStudio(): Record<string, CliCanaryDecision> {
const out: Record<string, CliCanaryDecision> = {};
for (const canary of CANARIES) {
const { enabled, reason } = resolveCanary(canary.name);
out[canary.name] = {
enabled,
// Provenance, not decoration. Studio has its own telemetry opt-out that
// the CLI cannot see, and it must be able to refuse ordinary cohort
// enrolment while still honouring a deliberate `HF_CANARY_*` override.
// A bare boolean cannot express that difference, so Studio would have
// had to either ignore its own opt-out or drop the override.
forced: reason === "forced_on" || reason === "forced_off",
};
}
return out;
}
/**
* Canary assignments as PostHog flag properties — `$feature/canary-<name>`
* set to `"true"` / `"false"` for every registered canary, each paired with a
* `canary_reason_<name>`. Spread onto every event so any metric can be broken
* down by cohort using PostHog's native flag tooling, with nothing configured
* server-side. See `canaryFeatureProperties` for why non-enrolled canaries are
* emitted too, and why the reason has to ride along.
*
* The reason is what makes a cohort flip attributable. `resolveCanary` has
* computed it all along and this function used to drop it, so an install
* reporting both values looked identical whether a developer had toggled
* `HF_CANARY_*` or the bucketer had genuinely disagreed with itself.
*/
export function canaryEventProperties(): Record<string, string> {
return canaryFeatureProperties(
CANARIES.map((c) => {
const { enabled, reason } = resolveCanary(c.name);
return { name: c.name, enabled, reason };
}),
);
}