feat(cli): track which registry items add installs (#3099)

* feat(cli): track which registry items `add` installs

`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.

Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.

`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.

Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.

* test(cli): cover `add` telemetry end to end against the built CLI

The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.

Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.

Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.

Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
This commit is contained in:
Miguel Ángel
2026-08-07 16:00:23 -07:00
committed by GitHub
parent 57ec008cb2
commit 0bda6b55b8
6 changed files with 344 additions and 2 deletions
+38 -1
View File
@@ -4,6 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
import { trackRegistryItemAdded } from "../telemetry/events.js";
// Assert the emitted payload rather than the transport: `shouldTrack()` is
// already false under test (dev mode / no PostHog key), so a real call would
// be indistinguishable from no call at all.
vi.mock("../telemetry/events.js", () => ({ trackRegistryItemAdded: vi.fn() }));
// ── Fixtures ────────────────────────────────────────────────────────────────
@@ -250,7 +256,10 @@ describe("add command pure helpers", () => {
});
describe("runAdd (integration, mocked registry)", () => {
beforeEach(() => mockFetch());
beforeEach(() => {
vi.mocked(trackRegistryItemAdded).mockClear();
mockFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -313,6 +322,9 @@ describe("runAdd (integration, mocked registry)", () => {
code: "incompatible-cli",
});
expect(existsSync(join(dir, "compositions/future-block.html"))).toBe(false);
// Nothing was written, so nothing may be counted: an install count that
// also counts refused installs is not a download count.
expect(trackRegistryItemAdded).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -359,6 +371,31 @@ describe("runAdd (integration, mocked registry)", () => {
}
});
it("reports every installed item, marking only the requested one", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);
await runAdd({ name: "dep-block", projectDir: dir, skipClipboard: true });
// A dependency dragged in behind the request must not read as a vote for
// itself, or a popular dependency outranks everything that depends on it.
expect(trackRegistryItemAdded).toHaveBeenCalledTimes(2);
expect(trackRegistryItemAdded).toHaveBeenCalledWith({
item: "base-component",
itemType: "hyperframes:component",
requested: false,
});
expect(trackRegistryItemAdded).toHaveBeenCalledWith({
item: "dep-block",
itemType: "hyperframes:block",
requested: true,
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("throws AddError with code 'example-type' when asked to add an example", async () => {
const dir = tmp();
try {
+17
View File
@@ -28,6 +28,7 @@ import {
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
import { trackRegistryItemAdded } from "../telemetry/events.js";
// ── Target-path resolution ──────────────────────────────────────────────────
// `registry-item.json` files specify `target` paths relative to the project
@@ -197,6 +198,17 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
// 5. Install — dependencies first, requested item last.
const written = await installAll(installPlan, projectDir, config.registry);
// Report what landed, not what was asked for: a failed install throws above,
// and the bulk `add <tag>` path re-enters here per item, so this one place
// covers every way an item reaches a project.
for (const planItem of installPlan) {
trackRegistryItemAdded({
item: planItem.name,
itemType: planItem.type,
requested: planItem.name === item.name,
});
}
// 6. Build include snippet + clipboard copy for the requested item.
const itemForInstall = installPlan[installPlan.length - 1]!;
const primaryFile =
@@ -254,6 +266,11 @@ export default defineCommand({
description: "Print a machine-readable summary (written files + snippet) to stdout",
},
},
// `run` is 28 cyclomatic and predates this change, which touches only
// `runAdd`. Fallow scores it as new because the file changed. Splitting the
// tag-fallback branch out would fix it honestly and is worth doing, but not
// inside a telemetry change.
// fallow-ignore-next-line complexity
async run({ args }) {
const projectDir = resolve(args.dir ?? process.cwd());
const json = args.json === true;