Skip to content

Commit 521f2c9

Browse files
feat(media-use): usage telemetry for HeyGen conversion (#2130)
1 parent b1f1c05 commit 521f2c9

9 files changed

Lines changed: 639 additions & 10 deletions

File tree

skills-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"files": 10
4747
},
4848
"media-use": {
49-
"hash": "f6f3af6648b1bd81",
49+
"hash": "49aa5adae0800403",
5050
"files": 122
5151
},
5252
"motion-graphics": {

skills/media-use/audio/scripts/lib/heygen.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export function heygenCredential() {
6969
return null;
7070
}
7171

72+
// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
73+
// makes internally, exposed on its own so callers that only need to *tag* the
74+
// auth path (telemetry) don't have to parse headers back apart. Never throws:
75+
// no credential (or an expired one) is just `null`, same as a fresh resolve
76+
// with nothing to tag.
77+
export function heygenAuthMethod() {
78+
const cred = heygenCredential();
79+
if (!cred?.headers) return null;
80+
return "Authorization" in cred.headers ? "oauth" : "api_key";
81+
}
82+
7283
// → auth headers object, or throw with a fix hint.
7384
export function heygenAuthHeaders() {
7485
const cred = heygenCredential();

skills/media-use/audio/scripts/lib/heygen.test.mjs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
33
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
6-
import { heygenAuthHeaders } from "./heygen.mjs";
6+
import { heygenAuthHeaders, heygenAuthMethod } from "./heygen.mjs";
77

88
function withCleanHeygenEnv(fn) {
99
const previousApiKey = process.env.HEYGEN_API_KEY;
@@ -58,3 +58,43 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic", () => {
5858
}
5959
});
6060
});
61+
62+
test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
63+
withCleanHeygenEnv(() => {
64+
process.env.HEYGEN_API_KEY = "hg_test";
65+
assert.equal(heygenAuthMethod(), "api_key");
66+
});
67+
});
68+
69+
test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
70+
withCleanHeygenEnv(() => {
71+
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
72+
try {
73+
process.env.HEYGEN_CONFIG_DIR = dir;
74+
writeFileSync(
75+
join(dir, "credentials"),
76+
JSON.stringify({
77+
oauth: {
78+
access_token: "at_test",
79+
expires_at: "2099-01-01T00:00:00Z",
80+
},
81+
}),
82+
);
83+
assert.equal(heygenAuthMethod(), "oauth");
84+
} finally {
85+
rmSync(dir, { recursive: true, force: true });
86+
}
87+
});
88+
});
89+
90+
test("heygenAuthMethod returns null with no credential at all", () => {
91+
withCleanHeygenEnv(() => {
92+
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
93+
try {
94+
process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
95+
assert.equal(heygenAuthMethod(), null);
96+
} finally {
97+
rmSync(dir, { recursive: true, force: true });
98+
}
99+
});
100+
});

skills/media-use/scripts/lib/heygen-cli.mjs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { track } from "./telemetry.mjs";
2+
13
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
24
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
35
// needs — so anything below this can't authenticate for free usage at all.
@@ -20,6 +22,14 @@ const ACTIONABLE_MESSAGES = new Set([
2022
]);
2123

2224
export function classifyHeygenError(err) {
25+
return classifyHeygenErrorResult(err).message;
26+
}
27+
28+
export function classifyHeygenErrorCode(err) {
29+
return classifyHeygenErrorResult(err).code;
30+
}
31+
32+
function classifyHeygenErrorResult(err) {
2333
const detail = heygenErrorDetail(err);
2434
const text = [err?.stderr, err?.stdout, err?.message, detail]
2535
.map((value) => textOf(value))
@@ -33,7 +43,7 @@ export function classifyHeygenError(err) {
3343
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
3444
// just ran successfully. Keep this narrow.
3545
if (err?.code === "ENOENT" || lower.includes("command not found")) {
36-
return HEYGEN_NOT_FOUND_MESSAGE;
46+
return { code: "not_found", message: HEYGEN_NOT_FOUND_MESSAGE };
3747
}
3848

3949
if (
@@ -50,24 +60,63 @@ export function classifyHeygenError(err) {
5060
lower.includes("auth required") ||
5161
lower.includes("authentication required")
5262
) {
53-
return HEYGEN_NOT_AUTHENTICATED_MESSAGE;
63+
return { code: "not_authenticated", message: HEYGEN_NOT_AUTHENTICATED_MESSAGE };
5464
}
5565

5666
const version = firstSemver(text);
5767
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
58-
return HEYGEN_OUTDATED_MESSAGE;
68+
return { code: "outdated", message: HEYGEN_OUTDATED_MESSAGE };
5969
}
6070

61-
return detail;
71+
if (
72+
lower.includes("rate limit") ||
73+
lower.includes("quota") ||
74+
lower.includes("insufficient credit") ||
75+
lower.includes("too many requests") ||
76+
lower.includes("throttled") ||
77+
/\b429\b/.test(lower)
78+
) {
79+
return { code: "rate_limited", message: detail };
80+
}
81+
82+
return { code: "other", message: detail };
6283
}
6384

64-
export function reportHeygenFailure(err, context) {
65-
const message = classifyHeygenError(err);
85+
// reportHeygenFailure's callers (voice-provider.mjs, heygen-search.mjs) are
86+
// synchronous and several layers below the CLI's process.exit() calls, so
87+
// they can't await this tracking call themselves. Stash each attempt's
88+
// promise here so a caller closer to exit (resolve.mjs) can join it first —
89+
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
90+
// track() already documents, just reachable from a sync call site.
91+
const pendingFailureTracking = new Set();
92+
93+
export function reportHeygenFailure(err, context, trackEvent = track) {
94+
const { code, message } = classifyHeygenErrorResult(err);
6695
if (ACTIONABLE_MESSAGES.has(message)) {
6796
console.error(message);
6897
} else {
6998
console.error(`media-use: \`${context}\` failed: ${message}`);
7099
}
100+
try {
101+
const tracked = Promise.resolve(
102+
trackEvent("media_use_provider_error", { provider: "heygen", reason: code }),
103+
).catch(() => {});
104+
pendingFailureTracking.add(tracked);
105+
void tracked.finally(() => pendingFailureTracking.delete(tracked));
106+
return tracked;
107+
} catch {
108+
// Telemetry must never affect the provider failure path.
109+
return Promise.resolve();
110+
}
111+
}
112+
113+
// Awaits every provider-error track fired since the last flush, so a caller
114+
// about to process.exit() doesn't orphan one mid-request (both are separate,
115+
// non-keepalive HTTP connections with no ordering guarantee otherwise).
116+
// Never rejects: each tracked promise already swallows its own failure.
117+
export async function flushHeygenFailureTracking() {
118+
if (pendingFailureTracking.size === 0) return;
119+
await Promise.all(pendingFailureTracking);
71120
}
72121

73122
export function firstSemver(text) {

skills/media-use/scripts/lib/heygen-cli.test.mjs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
import { strict as assert } from "node:assert";
2+
import { spawnSync } from "node:child_process";
23
import { test } from "node:test";
34
import {
45
classifyHeygenError,
6+
classifyHeygenErrorCode,
7+
flushHeygenFailureTracking,
58
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
69
HEYGEN_NOT_FOUND_MESSAGE,
710
HEYGEN_OUTDATED_MESSAGE,
11+
reportHeygenFailure,
812
} from "./heygen-cli.mjs";
913

14+
function captureFailureReport(err, context, trackEvent) {
15+
const originalError = console.error;
16+
const stderrCalls = [];
17+
console.error = (...args) => stderrCalls.push(args);
18+
try {
19+
if (trackEvent) {
20+
reportHeygenFailure(err, context, trackEvent);
21+
} else {
22+
reportHeygenFailure(err, context);
23+
}
24+
} finally {
25+
console.error = originalError;
26+
}
27+
return stderrCalls;
28+
}
29+
1030
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
1131
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
1232

@@ -64,3 +84,184 @@ test("passes through unrelated errors", () => {
6484

6585
assert.equal(message, "rate limit exceeded");
6686
});
87+
88+
test("classifies existing HeyGen failures with stable reason codes", () => {
89+
assert.equal(classifyHeygenErrorCode({ code: "ENOENT" }), "not_found");
90+
assert.equal(
91+
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
92+
"not_authenticated",
93+
);
94+
assert.equal(
95+
classifyHeygenErrorCode({ stderr: Buffer.from("heygen v0.1.5 is unsupported") }),
96+
"outdated",
97+
);
98+
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from("provider unavailable") }), "other");
99+
});
100+
101+
test("classifies rate-limit text case-insensitively", () => {
102+
assert.equal(
103+
classifyHeygenErrorCode({ stderr: Buffer.from("RATE LIMIT exceeded") }),
104+
"rate_limited",
105+
);
106+
});
107+
108+
test("classifies quota and insufficient-credit errors as rate limited", () => {
109+
for (const detail of ["Quota exhausted", "INSUFFICIENT CREDIT remaining"]) {
110+
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
111+
}
112+
});
113+
114+
test("classifies the literal 429 reason phrase and throttling language as rate limited", () => {
115+
for (const detail of ["Too Many Requests", "Error: throttled by upstream, retry later"]) {
116+
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
117+
}
118+
});
119+
120+
test("does not misclassify unrelated errors that share a word with the new phrasing", () => {
121+
// Shares "too many" with "too many requests" but is a distinct failure (fd
122+
// exhaustion, not a rate limit) — the match must require the full phrase.
123+
assert.equal(
124+
classifyHeygenErrorCode({ stderr: Buffer.from("Too many open file descriptors") }),
125+
"other",
126+
);
127+
});
128+
129+
test("classifies a bare 429 as rate limited without matching request IDs", () => {
130+
assert.equal(
131+
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 429 Too Many Requests") }),
132+
"rate_limited",
133+
);
134+
assert.equal(
135+
classifyHeygenErrorCode({ stderr: Buffer.from("request req-429abc failed") }),
136+
"other",
137+
);
138+
});
139+
140+
test("tracks not-found failures without changing actionable output", () => {
141+
const trackingCalls = [];
142+
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search", (...args) =>
143+
trackingCalls.push(args),
144+
);
145+
146+
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
147+
assert.deepEqual(trackingCalls, [
148+
["media_use_provider_error", { provider: "heygen", reason: "not_found" }],
149+
]);
150+
});
151+
152+
test("tracks generic failures without including raw detail", () => {
153+
const trackingCalls = [];
154+
const stderrCalls = captureFailureReport(
155+
{ stderr: Buffer.from("private provider detail") },
156+
"heygen asset search",
157+
(...args) => trackingCalls.push(args),
158+
);
159+
160+
assert.deepEqual(stderrCalls, [
161+
["media-use: `heygen asset search` failed: private provider detail"],
162+
]);
163+
assert.deepEqual(trackingCalls, [
164+
["media_use_provider_error", { provider: "heygen", reason: "other" }],
165+
]);
166+
});
167+
168+
test("keeps failure output observable when telemetry is opted out", () => {
169+
const previousOptOut = process.env.HYPERFRAMES_NO_TELEMETRY;
170+
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
171+
try {
172+
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search");
173+
174+
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
175+
} finally {
176+
if (previousOptOut === undefined) {
177+
delete process.env.HYPERFRAMES_NO_TELEMETRY;
178+
} else {
179+
process.env.HYPERFRAMES_NO_TELEMETRY = previousOptOut;
180+
}
181+
}
182+
});
183+
184+
test("keeps failure output observable when tracking throws synchronously", () => {
185+
const originalError = console.error;
186+
const stderrCalls = [];
187+
let thrown;
188+
console.error = (...args) => stderrCalls.push(args);
189+
try {
190+
try {
191+
reportHeygenFailure({ code: "ENOENT" }, "heygen asset search", () => {
192+
throw new Error("tracking failed");
193+
});
194+
} catch (err) {
195+
thrown = err;
196+
}
197+
} finally {
198+
console.error = originalError;
199+
}
200+
201+
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
202+
assert.equal(thrown, undefined);
203+
});
204+
205+
test("does not leave rejected tracking promises unhandled", () => {
206+
const moduleUrl = new URL("./heygen-cli.mjs", import.meta.url).href;
207+
const script = `
208+
import { reportHeygenFailure } from ${JSON.stringify(moduleUrl)};
209+
reportHeygenFailure(
210+
{ stderr: "provider unavailable" },
211+
"heygen asset search",
212+
() => Promise.reject(new Error("tracking failed")),
213+
);
214+
await new Promise((resolve) => setImmediate(resolve));
215+
`;
216+
const child = spawnSync(
217+
process.execPath,
218+
["--unhandled-rejections=strict", "--input-type=module", "--eval", script],
219+
{ encoding: "utf8", timeout: 5000 },
220+
);
221+
222+
assert.equal(child.error, undefined);
223+
assert.equal(child.signal, null);
224+
assert.equal(child.status, 0, child.stderr);
225+
assert.equal(child.stderr, "media-use: `heygen asset search` failed: provider unavailable\n");
226+
});
227+
228+
test("flushHeygenFailureTracking waits for a pending report before resolving", async () => {
229+
const events = [];
230+
let releaseTrack;
231+
const gate = new Promise((resolve) => {
232+
releaseTrack = resolve;
233+
});
234+
235+
// Mirrors the real call sites (voice-provider.mjs, heygen-search.mjs):
236+
// fire-and-forget, the return value is never awaited by the caller.
237+
reportHeygenFailure({ code: "ENOENT" }, "heygen voice speech", () =>
238+
gate.then(() => {
239+
events.push("track-settled");
240+
}),
241+
);
242+
243+
const flushed = flushHeygenFailureTracking().then(() => {
244+
events.push("flush-resolved");
245+
});
246+
247+
// Let several pending microtasks drain before releasing the gate, so this
248+
// proves flush is genuinely still waiting on the tracked promise -- not
249+
// merely that it hasn't had a tick yet.
250+
await Promise.resolve();
251+
await Promise.resolve();
252+
await Promise.resolve();
253+
assert.deepEqual(events, [], "flush must not resolve while the tracked promise is still pending");
254+
255+
releaseTrack();
256+
await flushed;
257+
258+
assert.deepEqual(
259+
events,
260+
["track-settled", "flush-resolved"],
261+
"flush must resolve only after the pending track settles, in that order",
262+
);
263+
});
264+
265+
test("flushHeygenFailureTracking resolves immediately when nothing is pending", async () => {
266+
await flushHeygenFailureTracking();
267+
});

0 commit comments

Comments
 (0)