feat(core): emit canary assignments as PostHog flag properties

Replaces the single `canaries: "a,b"` telemetry property with PostHog's own
flag shape, one property per registered canary:

    $feature/canary-de-parallel-router: "true" | "false"

PostHog treats `$feature/<key>` as a first-class flag property, so breakdowns,
funnels split by cohort and the experiment surfaces work on a canary with
nothing configured server-side. The decision still happens locally: the render
path forbids render-time network calls, behaviour must not depend on analytics
being reachable, and neither the CLI nor Studio ships posthog-js (both
hand-roll a batch POST, so there is no SDK to evaluate a real flag with).
Decide locally, analyse natively.

Two decisions worth recording:

- BOTH ARMS ARE EMITTED. A non-enrolled install reports "false" rather than
  omitting the property. Absent means "this build predates the canary", which
  is a different fact from "this install is control" — collapsing them makes a
  ramp unreadable, because you cannot separate a control group from an old
  version.

- KEYS ARE NAMESPACED with a `canary-` infix. A real PostHog flag namespace
  already exists in this project, owned by the web app (`enable-chat-tab`, set
  by posthog-js from `$lib=web` events). Namespacing guarantees a canary key
  can never alias a real flag key and have the two fight over one property.

Values are the strings "true"/"false" to match how PostHog records boolean
flag values, so the property is directly comparable to a real flag.

98 core / 1437, 166 cli / 2194, 269 studio / 2982 green; tsc clean across all
three packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-30 15:14:58 -07:00
co-authored by Claude Opus 5
parent 71ee156dac
commit a1682e1228
10 changed files with 176 additions and 49 deletions
+7 -4
View File
@@ -32,7 +32,7 @@ vi.mock("@hyperframes/core/canary-registry", async () => {
const {
isCanaryEnabled,
resolveCanary,
activeCanaryNames,
canaryEventProperties,
canaryParamName,
__resetStudioCanaryCacheForTests,
} = await import("./canary");
@@ -164,11 +164,14 @@ describe("cohort identity", () => {
});
describe("telemetry", () => {
it("reports enrolled canaries, undefined when none", () => {
expect(activeCanaryNames()).toBe("on-everywhere");
it("emits the same PostHog flag-shaped properties as the CLI", () => {
expect(canaryEventProperties()).toEqual({
"$feature/canary-on-everywhere": "true",
"$feature/canary-off-everywhere": "false",
});
__resetStudioCanaryCacheForTests();
setSearch("?hf_canary_on_everywhere=off");
expect(activeCanaryNames()).toBeUndefined();
expect(canaryEventProperties()["$feature/canary-on-everywhere"]).toBe("false");
});
});