feat(telemetry): attribute renders to the authoring workflow skill (#1695)

* feat(telemetry): attribute renders to the authoring workflow skill

Add an optional `--skill` flag to `hyperframes render` and tag the
`render_complete` / `render_error` events with `authoring_skill`, so render
usage can be broken down per authoring workflow. The value is slug-gated (a
malformed value is ignored) and the existing anonymous / opt-out telemetry
pipeline is otherwise unchanged.

Each end-user workflow that renders now passes `--skill=<name>` on its render
command: embedded-captions, faceless-explainer, graphic-overlays,
motion-graphics, music-to-video, pr-to-video, product-launch-video,
remotion-to-hyperframes, website-to-video.

Not instrumented, by design: general-video renders freeform with no canonical
render command to attach to, and slideshow produces an interactive deck rather
than a rendered video. Both can follow up if per-skill numbers are wanted.

* fix(telemetry): address review — shared slug util, equals-form flag, invalid-value warning

- Extract the SKILL_SLUG regex + a normalizeSkillSlug() helper into
  telemetry/skill.ts, shared by the `events` and `render` commands (the regex
  was duplicated). `render` adopts normalizeSkillSlug (so it now trims the value,
  matching `events`); `events` references the shared SKILL_SLUG. + unit test.
- `render` warns on a non-empty but invalid --skill value (e.g. a camelCase
  typo) so attribution isn't silently lost — stderr only, never fails the render.
- embedded-captions render script: `--skill embedded-captions` -> `--skill=embedded-captions`.
  On an older CLI that does not declare --skill, the space form leaks the value
  as a positional and clobbers the project dir (resolveProject fails); the equals
  form is parsed as a self-delimiting flag and safely ignored. Verified via Node
  parseArgs(strict:false).

Addresses review feedback on the PR (shared util + .trim drift, version-skew
safety, invalid-value visibility).

---------

Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
This commit is contained in:
kiritowoo
2026-06-24 06:16:36 -04:00
committed by GitHub
co-authored by kiritowoo
parent 649c216394
commit 5242dde2dc
15 changed files with 115 additions and 17 deletions
+1 -4
View File
@@ -1,5 +1,6 @@
import { defineCommand } from "citty";
import { trackEvent, flush } from "../telemetry/client.js";
import { SKILL_SLUG } from "../telemetry/skill.js";
// Skill-usage telemetry endpoint. A skill reports its own invocation/outcome —
// ideally from its own bundled script, so it fires deterministically rather
@@ -17,10 +18,6 @@ import { trackEvent, flush } from "../telemetry/client.js";
const ALLOWED_EVENTS = ["skill_invoked", "skill_completed"];
const ALLOWED_OUTCOMES = ["success", "error", "abort"];
// Skill names are lowercase slugs (e.g. "product-launch-video"). Anything that
// doesn't match is dropped, so a caller can't push high-cardinality or PII
// strings (paths, shell output, free text) into the anonymous event stream.
const SKILL_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
export default defineCommand({
meta: {
+31
View File
@@ -64,6 +64,7 @@ import {
} from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
import { normalizeSkillSlug } from "../telemetry/skill.js";
import { bytesToMb } from "../telemetry/system.js";
import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
@@ -175,6 +176,12 @@ export default defineCommand({
description: "Quality: draft, standard, high",
default: "standard",
},
skill: {
type: "string",
description:
"Authoring workflow skill that initiated this render (e.g. product-launch-video). " +
"Recorded on anonymous render telemetry for per-skill usage breakdowns; ignored unless it is a slug.",
},
format: {
type: "string",
description:
@@ -379,6 +386,22 @@ export default defineCommand({
}
const quality = qualityRaw as "draft" | "standard" | "high";
// ── Authoring skill (telemetry attribution) ────────────────────────────
// Optional slug naming the workflow skill that drove this render (e.g.
// "product-launch-video"), tagged onto render telemetry for per-skill usage
// breakdowns. Slug-gated (shared with the `events` command) so a caller
// can't push high-cardinality or PII strings into the anonymous event
// stream; a missing/invalid value is omitted.
const authoringSkill = normalizeSkillSlug(args.skill);
if (typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill) {
// Surface a typo (e.g. camelCase) instead of silently losing attribution.
// Warning only — never fails the render.
process.stderr.write(
`hyperframes: ignoring --skill="${args.skill}" — not a valid slug ` +
"(lowercase letters/digits/hyphens, max 64); this render will be unattributed.\n",
);
}
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
const format = parseRenderFormat(formatRaw);
@@ -755,6 +778,7 @@ export default defineCommand({
const renderOptionsBase: RenderOptions = {
fps,
quality,
authoringSkill,
format,
workers,
gpu: useGpu,
@@ -812,6 +836,7 @@ export default defineCommand({
await renderDocker(project.dir, outputPath, {
fps,
quality,
authoringSkill,
format,
gifLoop,
workers,
@@ -837,6 +862,7 @@ export default defineCommand({
await renderLocal(project.dir, outputPath, {
fps,
quality,
authoringSkill,
format,
gifLoop,
workers,
@@ -870,6 +896,8 @@ export interface SingleRenderResult {
interface RenderOptions {
fps: Fps;
quality: "draft" | "standard" | "high";
/** Authoring workflow skill that drove this render (telemetry attribution). */
authoringSkill?: string;
format: RenderFormat;
gifLoop?: number;
workers?: number;
@@ -1171,6 +1199,7 @@ async function renderDocker(
workers: options.workers,
docker: true,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
...getMemorySnapshot(),
});
@@ -1410,6 +1439,7 @@ function handleRenderError(
docker,
workers: options.workers,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
elapsedMs: Date.now() - startTime,
errorMessage: message,
failedStage,
@@ -1455,6 +1485,7 @@ function trackRenderMetrics(
workers: options.workers ?? perf?.workers,
docker,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
staticDedupEnabled: perf?.staticDedup?.enabled,
staticDedupArmed: perf?.staticDedup?.armed,
staticDedupSkipReason: perf?.staticDedup?.skipReason,
+6
View File
@@ -98,6 +98,8 @@ export function trackRenderComplete(
durationMs: number;
fps: number;
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string;
workers?: number;
docker: boolean;
gpu: boolean;
@@ -155,6 +157,7 @@ export function trackRenderComplete(
duration_ms: props.durationMs,
fps: props.fps,
quality: props.quality,
authoring_skill: props.authoringSkill,
workers: props.workers,
docker: props.docker,
gpu: props.gpu,
@@ -202,6 +205,8 @@ export function trackRenderError(
props: {
fps: number;
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string;
docker: boolean;
workers?: number;
gpu?: boolean;
@@ -221,6 +226,7 @@ export function trackRenderError(
{
fps: props.fps,
quality: props.quality,
authoring_skill: props.authoringSkill,
docker: props.docker,
workers: props.workers,
gpu: props.gpu,
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { normalizeSkillSlug } from "./skill.js";
describe("normalizeSkillSlug", () => {
it("accepts valid slugs unchanged", () => {
for (const s of [
"product-launch-video",
"pr-to-video",
"embedded-captions",
"a",
"a1",
"x".repeat(64),
]) {
expect(normalizeSkillSlug(s)).toBe(s);
}
});
it("trims surrounding whitespace", () => {
expect(normalizeSkillSlug(" pr-to-video ")).toBe("pr-to-video");
});
it("drops invalid values (returns undefined)", () => {
for (const s of [
"",
" ",
"MotionGraphics",
"has space",
"under_score",
"-leading",
"x".repeat(65),
"café",
]) {
expect(normalizeSkillSlug(s)).toBeUndefined();
}
});
it("drops non-string input", () => {
expect(normalizeSkillSlug(undefined)).toBeUndefined();
expect(normalizeSkillSlug(123)).toBeUndefined();
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Authoring-skill slug helpers, shared by the `events` and `render` commands.
*
* A skill slug names the authoring workflow that drove a telemetry event
* (e.g. "product-launch-video"). Values are slug-gated so a caller can't push
* high-cardinality or PII strings (paths, shell output, free text) into the
* anonymous event stream.
*/
/** Lowercase slug: starts alphanumeric, then alphanumerics/hyphens, max 64 chars. */
export const SKILL_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
/**
* Trim and validate a raw `--skill` value. Returns the slug, or `undefined`
* when the value is missing or not a valid slug (so the telemetry property is
* simply omitted rather than carrying garbage).
*/
export function normalizeSkillSlug(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
const slug = raw.trim();
return SKILL_SLUG.test(slug) ? slug : undefined;
}