mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user