mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): browser canary binding + leaf subpath imports
Adds the Studio (browser) binding so a canary can span the CLI and the editor,
and fixes a bundling mistake the studio test suite caught.
## The binding
Same public API as the CLI — `isCanaryEnabled("name")` — so a call site reads
identically whether it runs in Node or the browser. Three inputs differ:
- UNIT ID: `resolveStudioDistinctId()`, which already adopts
`window.__HF_CLI_DISTINCT_ID` when the CLI launched Studio. A CLI-launched
Studio therefore lands in the SAME cohort as the CLI: a rollout spanning
render and editor is coherent for that user instead of enrolling their
terminal but not their editor. A test pins that the id is passed through
unmodified — prefixing or re-hashing it would silently break that parity.
- OVERRIDE: no `process.env` in a page, so `?hf_canary_<name>=on` mirrored
into sessionStorage. Session scope is deliberate. A URL is the right carrier
(shareable — "support: open this link"), but persisting a URL-borne override
to localStorage would let one click silently pin a browser into a cohort
forever, long after anyone remembers why. Closing the tab is the reset;
`=reset` clears it explicitly.
- EXCLUSION: `navigator.webdriver` stands in for the CLI's `is_ci`. Automated
browsers mint a fresh localStorage id per run, so they would hop cohorts
between runs — noise in the signal, nothing learned about real users. An
override still reaches them, which is how you test a canary under Playwright.
Studio's `trackEvent` now attaches `canaries` to every event, mirroring the CLI.
## The bundling fix
Importing the `@hyperframes/core` barrel into studio browser code broke two
unrelated hook test files with an esbuild TextEncoder invariant violation. The
barrel re-exports the whole core surface (parsers, lint, studio-server), so it
drags a Node-oriented dependency graph into a browser bundle — the test
failure was the symptom, the bundle bloat was the bug.
`@hyperframes/core` now exposes `./canary` and `./canary-registry`, declared in
packages/core/package-subpaths.json (the generated source of truth for exports —
hand-editing package.json is reverted by the sync script) and marked
`environments: [browser, bun, node]`. Both the studio AND cli bindings import
the leaf modules; the CLI gets the same benefit for a different reason, since
this resolves on the startup path — the reason the producer is lazily loaded.
Verified: the two hook files pass again; 269 studio files / 2982 tests, 98
core / 1433, 166 cli / 2194 green, `bun run lint` clean including the subpath
check. Fault-injection confirms both design decisions are pinned — swapping
session for local storage fails the scope test, prefixing the unit id fails the
CLI/Studio cohort-parity test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
df1521a0b6
commit
71ee156dac
@@ -12,8 +12,10 @@ vi.mock("./system.js", () => ({
|
|||||||
|
|
||||||
// The registry is data; pin a known shape so these tests don't move when a
|
// The registry is data; pin a known shape so these tests don't move when a
|
||||||
// real canary is added or ramped.
|
// real canary is added or ramped.
|
||||||
vi.mock("@hyperframes/core", async () => {
|
vi.mock("@hyperframes/core/canary-registry", async () => {
|
||||||
const actual = await vi.importActual<typeof import("@hyperframes/core")>("@hyperframes/core");
|
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
|
||||||
|
"@hyperframes/core/canary-registry",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
CANARIES: [
|
CANARIES: [
|
||||||
|
|||||||
@@ -17,14 +17,11 @@
|
|||||||
* the feature's own code.
|
* the feature's own code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
// Leaf subpath imports, not the "@hyperframes/core" barrel: this resolves on
|
||||||
CANARIES,
|
// the CLI startup path, and the barrel pulls the whole core surface. Same
|
||||||
canaryEnvVar,
|
// reason the producer is lazily loaded.
|
||||||
evaluateCanary,
|
import { evaluateCanary, parseCanaryOverride, type CanaryDecision } from "@hyperframes/core/canary";
|
||||||
findCanary,
|
import { CANARIES, canaryEnvVar, findCanary } from "@hyperframes/core/canary-registry";
|
||||||
parseCanaryOverride,
|
|
||||||
type CanaryDecision,
|
|
||||||
} from "@hyperframes/core";
|
|
||||||
import { readConfig } from "./config.js";
|
import { readConfig } from "./config.js";
|
||||||
import { getSystemMeta } from "./system.js";
|
import { getSystemMeta } from "./system.js";
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,18 @@
|
|||||||
"types": null,
|
"types": null,
|
||||||
"environments": ["browser", "bun", "node"]
|
"environments": ["browser", "bun", "node"]
|
||||||
},
|
},
|
||||||
|
"./canary": {
|
||||||
|
"source": "./src/canary.ts",
|
||||||
|
"runtime": "./dist/canary.js",
|
||||||
|
"types": "./dist/canary.d.ts",
|
||||||
|
"environments": ["browser", "bun", "node"]
|
||||||
|
},
|
||||||
|
"./canary-registry": {
|
||||||
|
"source": "./src/canaryRegistry.ts",
|
||||||
|
"runtime": "./dist/canaryRegistry.js",
|
||||||
|
"types": "./dist/canaryRegistry.d.ts",
|
||||||
|
"environments": ["browser", "bun", "node"]
|
||||||
|
},
|
||||||
"./beats": {
|
"./beats": {
|
||||||
"source": "./src/beats/index.ts",
|
"source": "./src/beats/index.ts",
|
||||||
"runtime": "./dist/beats/index.js",
|
"runtime": "./dist/beats/index.js",
|
||||||
|
|||||||
@@ -28,6 +28,18 @@
|
|||||||
"types": "./src/index.ts"
|
"types": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
|
"./canary": {
|
||||||
|
"bun": "./src/canary.ts",
|
||||||
|
"node": "./dist/canary.js",
|
||||||
|
"import": "./src/canary.ts",
|
||||||
|
"types": "./src/canary.ts"
|
||||||
|
},
|
||||||
|
"./canary-registry": {
|
||||||
|
"bun": "./src/canaryRegistry.ts",
|
||||||
|
"node": "./dist/canaryRegistry.js",
|
||||||
|
"import": "./src/canaryRegistry.ts",
|
||||||
|
"types": "./src/canaryRegistry.ts"
|
||||||
|
},
|
||||||
"./beats": {
|
"./beats": {
|
||||||
"bun": "./src/beats/index.ts",
|
"bun": "./src/beats/index.ts",
|
||||||
"node": "./dist/beats/index.js",
|
"node": "./dist/beats/index.js",
|
||||||
@@ -298,6 +310,14 @@
|
|||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
},
|
},
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
|
"./canary": {
|
||||||
|
"import": "./dist/canary.js",
|
||||||
|
"types": "./dist/canary.d.ts"
|
||||||
|
},
|
||||||
|
"./canary-registry": {
|
||||||
|
"import": "./dist/canaryRegistry.js",
|
||||||
|
"types": "./dist/canaryRegistry.d.ts"
|
||||||
|
},
|
||||||
"./beats": {
|
"./beats": {
|
||||||
"import": "./dist/beats/index.js",
|
"import": "./dist/beats/index.js",
|
||||||
"types": "./dist/beats/index.d.ts"
|
"types": "./dist/beats/index.d.ts"
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { evaluateCanary } from "@hyperframes/core/canary";
|
||||||
|
|
||||||
|
// Pin the registry: real entries move as rollouts ramp, and these tests are
|
||||||
|
// about the BINDING (does the browser supply the right three inputs?), not
|
||||||
|
// about whichever canaries happen to be live today.
|
||||||
|
vi.mock("@hyperframes/core/canary-registry", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
|
||||||
|
"@hyperframes/core/canary-registry",
|
||||||
|
);
|
||||||
|
const defs = [
|
||||||
|
{
|
||||||
|
name: "on-everywhere",
|
||||||
|
percentage: 100,
|
||||||
|
description: "",
|
||||||
|
owner: "t",
|
||||||
|
sunsetAfter: "2099-01-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "off-everywhere",
|
||||||
|
percentage: 0,
|
||||||
|
description: "",
|
||||||
|
owner: "t",
|
||||||
|
sunsetAfter: "2099-01-01",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return { ...actual, CANARIES: defs, findCanary: (n: string) => defs.find((d) => d.name === n) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isCanaryEnabled,
|
||||||
|
resolveCanary,
|
||||||
|
activeCanaryNames,
|
||||||
|
canaryParamName,
|
||||||
|
__resetStudioCanaryCacheForTests,
|
||||||
|
} = await import("./canary");
|
||||||
|
const { resolveStudioDistinctId, __resetStudioDistinctIdForTests } = await import("./distinctId");
|
||||||
|
|
||||||
|
function setSearch(search: string): void {
|
||||||
|
window.history.replaceState({}, "", `/${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
setSearch("");
|
||||||
|
delete window.__HF_CLI_DISTINCT_ID;
|
||||||
|
Object.defineProperty(navigator, "webdriver", { value: false, configurable: true });
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
__resetStudioDistinctIdForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSearch("");
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
__resetStudioDistinctIdForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("studio canary binding", () => {
|
||||||
|
it("reads the percentage from the shared registry", () => {
|
||||||
|
expect(isCanaryEnabled("on-everywhere")).toBe(true);
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unregistered name is off, not a throw — a typo must not break the editor", () => {
|
||||||
|
expect(isCanaryEnabled("nope")).toBe(false);
|
||||||
|
expect(resolveCanary("nope").reason).toBe("out_of_cohort");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the query param from the canary name", () => {
|
||||||
|
expect(canaryParamName("de-parallel-router")).toBe("hf_canary_de_parallel_router");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URL override", () => {
|
||||||
|
it("turns a canary on and off from the query string", () => {
|
||||||
|
setSearch("?hf_canary_off_everywhere=on");
|
||||||
|
expect(resolveCanary("off-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||||
|
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
setSearch("?hf_canary_on_everywhere=off");
|
||||||
|
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "forced_off" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives losing the query string, so in-app navigation keeps the override", () => {
|
||||||
|
setSearch("?hf_canary_off_everywhere=on");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||||
|
|
||||||
|
// Navigate away from the param — a real SPA drops it constantly.
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
setSearch("");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is session-scoped, not persisted to localStorage", () => {
|
||||||
|
// A URL-borne override must not silently pin a browser into a cohort
|
||||||
|
// forever; closing the tab is the reset.
|
||||||
|
setSearch("?hf_canary_off_everywhere=on");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||||
|
expect(JSON.stringify(localStorage).includes("canary")).toBe(false);
|
||||||
|
expect(sessionStorage.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("=reset clears a stored override", () => {
|
||||||
|
setSearch("?hf_canary_off_everywhere=on");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||||
|
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
setSearch("?hf_canary_off_everywhere=reset");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||||
|
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
setSearch("");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("automated browsers", () => {
|
||||||
|
it("are excluded from percentage enrolment", () => {
|
||||||
|
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
||||||
|
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "excluded" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still honour an explicit override, so a canary can be tested under automation", () => {
|
||||||
|
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
||||||
|
setSearch("?hf_canary_on_everywhere=on");
|
||||||
|
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
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",
|
||||||
|
unitId: cliId,
|
||||||
|
percentage: 100,
|
||||||
|
}).bucket;
|
||||||
|
expect(viaBinding).toBe(direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("memoizes so a decision cannot change mid-session", () => {
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||||
|
// A late override must NOT flip a component that already rendered.
|
||||||
|
setSearch("?hf_canary_off_everywhere=on");
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telemetry", () => {
|
||||||
|
it("reports enrolled canaries, undefined when none", () => {
|
||||||
|
expect(activeCanaryNames()).toBe("on-everywhere");
|
||||||
|
|
||||||
|
__resetStudioCanaryCacheForTests();
|
||||||
|
setSearch("?hf_canary_on_everywhere=off");
|
||||||
|
expect(activeCanaryNames()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Studio (browser) binding for the shared canary registry.
|
||||||
|
//
|
||||||
|
// `@hyperframes/core` owns the decision and is deliberately pure — the caller
|
||||||
|
// supplies the unit id, the override and the exclusion. This file supplies
|
||||||
|
// those three from the browser, mirroring `packages/cli/src/telemetry/canary.ts`
|
||||||
|
// for the CLI. The public API is deliberately identical on both surfaces:
|
||||||
|
//
|
||||||
|
// import { isCanaryEnabled } from "../telemetry/canary";
|
||||||
|
// if (isCanaryEnabled("my-feature")) { ... }
|
||||||
|
//
|
||||||
|
// so a call site reads the same whether it runs in Node or the browser, and a
|
||||||
|
// canary can span both.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 2. OVERRIDE — there is no `process.env` in a page, so the override is a URL
|
||||||
|
// query param mirrored into sessionStorage (see `readOverride`).
|
||||||
|
//
|
||||||
|
// 3. EXCLUSION — `navigator.webdriver` stands in for the CLI's `is_ci`.
|
||||||
|
// Automated browsers mint a fresh localStorage id per run, so their ids are
|
||||||
|
// ephemeral and they would hop cohorts between runs — noise in the rollout
|
||||||
|
// signal, and nothing learned about real users.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Deep subpath imports, NOT the "@hyperframes/core" barrel. Studio is a
|
||||||
|
// browser bundle, and the barrel re-exports the whole core surface (parsers,
|
||||||
|
// lint, studio-server); pulling that in here drags a Node-oriented dependency
|
||||||
|
// graph into the bundle. These two modules are pure and leaf.
|
||||||
|
import { evaluateCanary, parseCanaryOverride, type CanaryDecision } from "@hyperframes/core/canary";
|
||||||
|
import { CANARIES, findCanary } from "@hyperframes/core/canary-registry";
|
||||||
|
import { resolveStudioDistinctId } from "./distinctId";
|
||||||
|
import { safeSessionStorage } from "../utils/safeStorage";
|
||||||
|
|
||||||
|
/** `my-feature` → `hf_canary_my_feature`, the query param and storage key. */
|
||||||
|
export function canaryParamName(name: string): string {
|
||||||
|
return `hf_canary_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "hyperframes-studio:canary:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a manual override for one canary.
|
||||||
|
*
|
||||||
|
* `?hf_canary_my_feature=on` (also off/true/false/1/0/yes/no), mirrored into
|
||||||
|
* sessionStorage so it survives in-app navigation and reloads within the tab.
|
||||||
|
*
|
||||||
|
* SESSION scope, not local, is the deliberate part. A URL is the right carrier
|
||||||
|
* — it is shareable, which is what "support: open this link" and "QA: repro
|
||||||
|
* with this on" actually need. But persisting a URL-borne override to
|
||||||
|
* localStorage would mean one click silently pins that browser into a cohort
|
||||||
|
* forever, long after anyone remembers why. Session scope keeps the link
|
||||||
|
* useful and lets closing the tab be the reset.
|
||||||
|
*
|
||||||
|
* `?hf_canary_my_feature=reset` clears it explicitly.
|
||||||
|
*/
|
||||||
|
function readOverride(name: string): boolean | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const key = canaryParamName(name);
|
||||||
|
const storageKey = `${STORAGE_PREFIX}${name}`;
|
||||||
|
const store = safeSessionStorage();
|
||||||
|
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = new URLSearchParams(window.location.search).get(key);
|
||||||
|
} catch {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw !== null) {
|
||||||
|
if (raw.trim().toLowerCase() === "reset") {
|
||||||
|
store?.removeItem(storageKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Persist for the tab session so the override outlives the query string.
|
||||||
|
try {
|
||||||
|
store?.setItem(storageKey, raw);
|
||||||
|
} catch {
|
||||||
|
/* storage full / blocked — the param still applies to this page load */
|
||||||
|
}
|
||||||
|
return parseCanaryOverride(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCanaryOverride(store?.getItem(storageKey) ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated browser? The browser analog of the CLI's CI exclusion.
|
||||||
|
* `navigator.webdriver` is set by Playwright, Puppeteer and Selenium.
|
||||||
|
*/
|
||||||
|
function isAutomatedBrowser(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return navigator.webdriver === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized per page load, for the same reason the CLI memoizes per process: a
|
||||||
|
* canary must not change its mind mid-session. A component that mounted
|
||||||
|
* enrolled has to stay enrolled, and the telemetry has to agree with what the
|
||||||
|
* user actually saw.
|
||||||
|
*/
|
||||||
|
const decisions = new Map<string, CanaryDecision>();
|
||||||
|
|
||||||
|
/** Test-only: drop memoized decisions so cases don't leak into each other. */
|
||||||
|
export function __resetStudioCanaryCacheForTests(): void {
|
||||||
|
decisions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full decision including the reason. An unregistered name resolves to off
|
||||||
|
* rather than throwing — a typo in a rollout control must never break the
|
||||||
|
* editor.
|
||||||
|
*/
|
||||||
|
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: resolveStudioDistinctId(),
|
||||||
|
percentage: definition.percentage,
|
||||||
|
override: readOverride(definition.name),
|
||||||
|
exclude: isAutomatedBrowser(),
|
||||||
|
})
|
||||||
|
: { enabled: false, reason: "out_of_cohort" };
|
||||||
|
|
||||||
|
decisions.set(name, decision);
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this canary on for this Studio 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 — attached to every Studio event so any metric can be split by
|
||||||
|
* cohort, exactly as the CLI does.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
|
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
|
||||||
import { getBrowserSystemMeta } from "./system";
|
import { getBrowserSystemMeta } from "./system";
|
||||||
|
import { activeCanaryNames } from "./canary";
|
||||||
|
|
||||||
// Write-only PostHog project key, safe to embed in client code.
|
// Write-only PostHog project key, safe to embed in client code.
|
||||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||||
@@ -73,7 +74,10 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
|
|||||||
const sys = getBrowserSystemMeta();
|
const sys = getBrowserSystemMeta();
|
||||||
eventQueue.push({
|
eventQueue.push({
|
||||||
event,
|
event,
|
||||||
properties: { ...properties, ...sys },
|
// `canaries` mirrors the CLI: the cohorts this install is enrolled in, on
|
||||||
|
// EVERY event so any metric can be split by cohort. Resolved after the
|
||||||
|
// shouldTrack guard, so opted-out users never pay for it.
|
||||||
|
properties: { ...properties, ...sys, canaries: activeCanaryNames() },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user