feat(cli): report which catalog items a render actually used (#3470)

`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.

`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.

The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.

Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
This commit is contained in:
Miguel Ángel
2026-08-24 19:56:06 -04:00
committed by GitHub
parent b2fc18b2df
commit 045b3a4fd7
18 changed files with 1129 additions and 40 deletions
+122
View File
@@ -190,6 +190,128 @@ describe("render telemetry events", () => {
flush.mockClear();
});
// The catalog join. Counts must be present at zero: the no-catalog cohort is
// what the with-catalog cohort is compared against, and an absent property is
// indistinguishable from an older CLI that never sent one.
it("reports zero catalog counts for a project with no registry items", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBe(0);
expect(props.registry_blocks_used_count).toBe(0);
expect(props.registry_items).toBeUndefined();
});
it("names the installed items and the subset the render reached", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: {
installed: ["bar-chart-race", "data-chart"],
usedBlocks: ["data-chart"],
manifestUnreadable: false,
},
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_items).toBe("bar-chart-race,data-chart");
expect(props.registry_item_count).toBe(2);
expect(props.registry_blocks_used).toBe("data-chart");
expect(props.registry_blocks_used_count).toBe(1);
});
// A count is one integer with no cardinality risk. Capping it would lose the
// real number with no way downstream to tell 40 installs from 400.
it("caps the item names but reports the true counts past the cap", () => {
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBe(45);
expect(props.registry_blocks_used_count).toBe(5);
expect(String(props.registry_items).split(",")).toHaveLength(40);
// The names are a window, and a query joining on them would otherwise read
// this project as 45 abandoned items: every used block sits past the cap,
// so `registry_blocks_used` is absent against a count of 5.
expect(props.registry_items_truncated).toBe(true);
expect(props.registry_blocks_used).toBeUndefined();
});
it("does not claim truncation when every name fits", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: {
installed: ["bar-chart-race", "data-chart"],
usedBlocks: ["data-chart"],
manifestUnreadable: false,
},
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_items_truncated).toBeUndefined();
});
// Sliced independently the two lists come out disjoint, which breaks the one
// relationship any drop-off query relies on.
it("keeps the used names a subset of the reported installed names", () => {
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
const reported = new Set(String(props.registry_items).split(","));
const used =
props.registry_blocks_used === undefined ? [] : String(props.registry_blocks_used).split(",");
expect(used.every((name) => reported.has(name))).toBe(true);
});
// The control cohort is the one that must not silently absorb failures: a
// project whose manifest cannot be read is not a project without a catalog.
it("flags an unreadable manifest instead of reporting it as zero catalog items", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: true },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_manifest_unreadable).toBe(true);
expect(props.registry_item_count).toBeUndefined();
});
// A caller that built render options by hand makes no catalog claim, rather
// than claiming zero items.
it("omits the catalog props entirely when usage was never resolved", () => {
trackRenderComplete({ durationMs: 1, fps: 30, quality: "draft", docker: false, gpu: false });
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBeUndefined();
expect(props.registry_blocks_used_count).toBeUndefined();
});
it("flushes immediately after render_complete and render_error (exit races the lazy flush)", () => {
trackRenderComplete({ durationMs: 1000, fps: 30, quality: "draft", docker: false, gpu: false });
expect(flush).toHaveBeenCalledTimes(1);
+63
View File
@@ -1,6 +1,7 @@
import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
import { FEEDBACK_RATING_SCALE } from "../utils/feedbackRating.js";
import type { CatalogUsage } from "../utils/catalogUsage.js";
import { flush, shouldTrack, trackEvent } from "./client.js";
import { readConfig } from "./config.js";
import { getPowerState } from "./system.js";
@@ -173,6 +174,60 @@ export function trackCommand(command: string, runId?: string): void {
});
}
/**
* Cap on item names in one event. Registry names are low-cardinality slugs, but
* a project with a hundred blocks should not push a hundred-name string into
* every render. The cap belongs at the boundary that builds the string, and
* deliberately NOT on the counts: a count is one integer with no cardinality
* risk, and a saturated one loses the real number with no way downstream to
* tell 40 installs from 400.
*/
const MAX_REPORTED_ITEM_NAMES = 40;
/**
* Catalog half of `render_complete`.
*
* Counts are emitted even when zero: the no-catalog cohort is exactly what the
* with-catalog cohort gets compared against, and a property that is simply
* absent is indistinguishable from an older CLI that never sent one. Names ride
* as a comma-joined string because event property values are scalars only (same
* shape as `recent_render_ids` on `cli_render_feedback`).
*
* The used names are narrowed to the reported installed names, so
* `registry_blocks_used` stays a subset of `registry_items` even when the cap
* bites. Sliced independently, the two lists can come out disjoint, breaking
* the one relationship a drop-off query relies on. When the cap does bite,
* `registry_items_truncated` says so: the counts still carry the truth, but the
* names are a window, and a query that joins on names must not read the
* difference as abandonment.
*
* An unreadable manifest reports itself and omits the counts rather than
* sending zeros, so a failed read cannot pose as a project that never used the
* catalog. Undefined usage means the caller built render options by hand rather
* than through the render plan, so it makes no catalog claim at all.
*/
function catalogEventProperties(
usage: CatalogUsage | undefined,
): Record<string, string | number | boolean> {
if (!usage) return {};
if (usage.manifestUnreadable) return { registry_manifest_unreadable: true };
const names = usage.installed.slice(0, MAX_REPORTED_ITEM_NAMES);
const reportedNames = new Set(names);
const used = usage.usedBlocks.filter((name) => reportedNames.has(name));
const truncated = names.length < usage.installed.length;
return {
registry_item_count: usage.installed.length,
registry_blocks_used_count: usage.usedBlocks.length,
// Say when the name lists are a window rather than the whole set. Without
// it a name-joining drop-off query silently reads a truncated project as
// all-abandoned: the used blocks can all sit past the cap, leaving an empty
// `registry_blocks_used` against a non-zero count.
...(truncated ? { registry_items_truncated: true } : {}),
...(names.length > 0 ? { registry_items: names.join(",") } : {}),
...(used.length > 0 ? { registry_blocks_used: used.join(",") } : {}),
};
}
export function trackRenderComplete(
props: {
durationMs: number;
@@ -180,6 +235,13 @@ export function trackRenderComplete(
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string;
/**
* Catalog items installed in this project, and those the rendered
* composition reaches. The pair is what joins `registry_item_added` to a
* finished video: an installed item missing from the used set was tried
* and dropped, which no add-time event can express.
*/
catalogUsage?: CatalogUsage;
workers?: number;
// Worker auto-sizing provenance (RenderPerfSummary.workerSizing). Answers
// "why N workers?" fleet-wide, and validates the advisory per-worker heap
@@ -292,6 +354,7 @@ export function trackRenderComplete(
fps: props.fps,
quality: props.quality,
authoring_skill: props.authoringSkill,
...catalogEventProperties(props.catalogUsage),
workers: props.workers,
workers_bound_by: props.workersBoundBy,
workers_cpu_based: props.workersCpuBased,