mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user