feat(telemetry): identify signed-in CLI users by their HyperFrames user id, and say signed-in usage is not anonymous (#6703)

* docs(telemetry): say signed-in usage is linked to your account, not anonymous

After hyperframes auth login the CLI sends a PostHog $identify with the
account email (username as fallback) and merges the install's earlier
anonymous usage; media-use does the same when it finds a signed-in
account. Several places still called all telemetry anonymous. Say it is
anonymous until you sign in, in the CLI reference (media-use, --skill,
feedback, events, telemetry), the hyperframes-cli skill references, and
the CLI help and notice strings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(telemetry): identify signed-in CLI users by their HyperFrames user id

The CLI and media-use identified a signed-in install by account email, so
CLI usage landed on a different PostHog person than the same account in
the HyperFrames web app, which keys on toUUID(username). Identify by that
same sha256-derived id instead, and $set email plus heygen_username on the
person. heygen_username is the cross-project join column for the Snowflake
event table (HeyGen's own project stores it as `name`). An account with no
username still falls back to the lowercased email.

Notices and docs now say usage is keyed on an id derived from the username
with email and username stored on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(telemetry): trim comments to the package's comment share

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(telemetry): put heygen_username on every event while signed in

The Snowflake event sync may not carry PostHog person properties, so a
$set alone does not let an event row join across projects. The CLI now
adds the signed-in HeyGen username (raw, from ~/.heygen/credentials) to
every event and drops it once the credentials hold no user; media-use
does the same on each event it sends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: count auth/paths.ts as an edit-accuracy bench input

Telemetry now imports it to read the signed-in username.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(telemetry): stat and read the credentials through one file handle

CodeQL flagged the stat-then-read as a file system race.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(telemetry): key signed-in events on the account id; fix stale anonymity copy

Genesis review on #6703:
- telemetry --help listed 'Any personally identifiable information' as
  never collected, and media-use's meta.md still said anonymous.
- Installs already identified by email under an older CLI never merge
  into the new person, so send signed-in events with distinct_id = the
  account id (back to the install id after logout), in the CLI and in
  media-use, and stop claiming earlier usage always merges.
- A user block with no credential no longer counts as signed in.
- A UUID-shaped username passes through unhashed, as on the web.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-29 02:57:48 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4fa5f54519
commit 1c3fcf4d23
27 changed files with 415 additions and 111 deletions
+1
View File
@@ -238,6 +238,7 @@ jobs:
- "packages/cli/src/{background-removal,registry,telemetry}/**"
- "packages/cli/src/capture/{captureCompositionFrame,captureTimeout,navigateForCapture,readBoundedResponse}.ts"
- "packages/cli/src/media-use/lib/config-lock.mjs"
- "packages/cli/src/auth/{paths,telemetryIdentity}.ts"
- "packages/cli/scripts/**"
- "packages/core/stubs/**"
- "packages/engine/src/**"
+21 -11
View File
@@ -692,9 +692,11 @@ ChatGPT subscription. Openverse, theSVG, GitHub, favicons, color grades, and the
local generators cost nothing. `doctor` exits 1 only when ffmpeg, ffprobe, or the
bundled sounds are missing; HeyGen problems are listed with a fix but do not fail it.
`media-use` sends anonymous usage events — the media type, which source won, and
`media-use` sends usage events — the media type, which source won, and
whether it reused a file, never your intent text or paths — and honors the same
opt-outs as [`telemetry`](#telemetry).
opt-outs as [`telemetry`](#telemetry). They are anonymous until you sign in to
HeyGen; after that they are linked to your account, as described under
[`telemetry`](#telemetry).
See [Voice, sound, and captions](/guides/voice-and-audio) and
[Color grading](/guides/media-effects#fix-and-grade-the-color) for the workflows around these assets, and
@@ -1672,7 +1674,7 @@ How it uses the machine, and when it gives up:
| `--batch-concurrency` / `--batch-fail-fast` | `1` / off | Batch rows at once, and whether the first failure stops the rest |
| `--debug` | off | Keep intermediates and write diagnostics under the Producer `.debug` directory |
| `--quiet` | off | Less output |
| `--skill` | — | Record which authoring workflow started this render, in anonymous telemetry |
| `--skill` | — | Record which authoring workflow started this render, in usage telemetry |
Low-memory detection reads **host** RAM, not cgroup or container limits, so
containerised callers — including `--docker` — should set
@@ -1963,8 +1965,9 @@ Topics: `data-attributes`, `examples`, `rendering`, `gsap`, `troubleshooting`,
### `feedback`
Send feedback about how it went. It is anonymous unless the app that launched
the CLI attaches your email (below).
Send feedback about how it went. It rides the same usage telemetry, so it is
anonymous until you sign in to HeyGen and linked to your account after that. An
app that launches the CLI can also attach your email (below).
```bash
npx hyperframes feedback --rating 10
@@ -2018,10 +2021,16 @@ values are never collected, except short self-identifying names: `AGENT`,
the `<app>/<version>/<channel>` tag an app that launches the CLI sets, sent as
`client` so reports from that app can be told apart.
Nothing personally identifying is collected until you sign in. When you run
`hyperframes auth login`, your HeyGen account email — or username, if the
account has no email — is linked to your usage, and your prior anonymous usage
is stitched to it. Nothing else personal, and only once you choose to sign in.
Nothing personally identifying is collected until you sign in. **Signed-in usage
is not anonymous.** When you run `hyperframes auth login`, your usage is linked
to your HeyGen account from then on: it is keyed on an id derived from your
HeyGen username (the same id the HyperFrames web app and Desktop use), and your
account email and username are stored with it. While you are signed in, every
event carries that id and your username. Anonymous usage from before the first
sign-in is merged in, unless an older CLI version had already linked the install
to your email; that earlier history stays on its own profile. `media-use` links the same way when it finds a
signed-in HeyGen account. Nothing else personal is added, and the opt-outs below
still turn all of it off.
Turn it all off with `HYPERFRAMES_NO_TELEMETRY=1` or the command above.
[Feedback collection](/help#share-feedback) explains the post-render prompt and the
@@ -2147,8 +2156,9 @@ subcommands: `asset` (export an image or SVG), `tokens` (pull design tokens),
and `component` (import an editable component). Run it bare for usage. See
[Figma integration](/guides/figma) for setup and examples.
`npx hyperframes events` lets an installed workflow skill emit an anonymous
usage event (`--skill`, `--event`, `--outcome`). Nothing calls it by hand.
`npx hyperframes events` lets an installed workflow skill emit a usage event
(`--skill`, `--event`, `--outcome`). Like the rest of [`telemetry`](#telemetry),
it is anonymous until you sign in. Nothing calls it by hand.
## hyperframes auth
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { heygenUserId, telemetryIdentity } from "./telemetryIdentity.js";
describe("heygenUserId", () => {
// Vectors from the HyperFrames web app's toUUID (demo-next derive-user-id.ts),
// which keys its users table and PostHog persons. A mismatch splits one person
// across the CLI and the web app.
it("matches the web app's derived user id", () => {
expect(heygenUserId("alice")).toBe("2bd806c9-7f0e-50af-9a1f-c3328fa763a9");
expect(heygenUserId("21e28514b7994f46b907b74914a3ca6e")).toBe(
"9f3df3a5-89ae-5609-b80b-ce3b7d5f8c37",
);
});
it("passes a UUID through unhashed, like the web app", () => {
const id = "6f1c0d4e-2b3a-4c5d-8e9f-0a1b2c3d4e5f";
expect(heygenUserId(id)).toBe(id);
});
// media-use/lib/telemetry.test.mjs pins the same "alice" vector for its copy.
});
describe("telemetryIdentity", () => {
it("keys on the username and sets email and heygen_username", () => {
expect(telemetryIdentity({ username: "alice", email: " Alice@Example.com " })).toEqual({
distinctId: "2bd806c9-7f0e-50af-9a1f-c3328fa763a9",
traits: { email: "alice@example.com", heygen_username: "alice" },
});
});
it("keeps the username exactly as HeyGen returns it", () => {
expect(telemetryIdentity({ username: "Alice" })?.traits).toEqual({ heygen_username: "Alice" });
});
it("falls back to the lowercased email without a username", () => {
expect(telemetryIdentity({ email: "Alice@Example.com" })).toEqual({
distinctId: "alice@example.com",
traits: { email: "alice@example.com" },
});
});
it("returns nothing without an email or username", () => {
expect(telemetryIdentity({ username: " ", email: "" })).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import { createHash } from "node:crypto";
import type { UserInfo } from "./client.js";
export interface TelemetryIdentity {
distinctId: string;
/** PostHog person properties. `heygen_username` joins one person across HeyGen's projects. */
traits: Record<string, string>;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** The id the HyperFrames web app and Desktop key their users on: a UUID as is, else its sha256-derived UUID. */
export function heygenUserId(username: string): string {
if (UUID_PATTERN.test(username)) return username;
const hash = createHash("sha256").update(username).digest();
hash[6] = (hash[6]! & 0x0f) | 0x50;
hash[8] = (hash[8]! & 0x3f) | 0x80;
const hex = hash.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
/** Keyed on the username's user id; an account without one falls back to its lowercased email. */
export function telemetryIdentity(user: UserInfo): TelemetryIdentity | undefined {
const email = user.email?.trim().toLowerCase() || undefined;
const username = user.username?.trim() ? user.username : undefined;
const traits: Record<string, string> = {};
if (email) traits.email = email;
if (username) traits.heygen_username = username;
if (username) return { distinctId: heygenUserId(username), traits };
if (email) return { distinctId: email, traits };
return undefined;
}
+16 -8
View File
@@ -5,6 +5,9 @@ import { readStore, writeStore } from "../../auth/store.js";
import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js";
import { CliRuntimeError } from "../../utils/commandResult.js";
// heygenUserId("alice"): the sha256-derived UUID the web app and Desktop also use.
const ALICE_USER_ID = "2bd806c9-7f0e-50af-9a1f-c3328fa763a9";
// Mock only AuthClient — keep the real store/resolver so the test
// exercises the actual on-disk rollback / persistence behavior.
// `verifyState` controls what `getCurrentUser` returns per test:
@@ -245,18 +248,23 @@ describe("auth login", () => {
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("attributes a successful login to the account email", async () => {
verifyState.user = { email: "alice@example.com", username: "alice" };
it("attributes a successful login to the username's HyperFrames user id", async () => {
verifyState.user = { email: "Alice@Example.com", username: "alice" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice@example.com");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice@example.com");
expect(telemetry.identifyUser).toHaveBeenCalledWith(ALICE_USER_ID, {
email: "alice@example.com",
heygen_username: "alice",
});
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", ALICE_USER_ID);
});
it("falls back to username when the account has no email", async () => {
verifyState.user = { username: "alice" };
it("falls back to the email when the account has no username", async () => {
verifyState.user = { email: "alice@example.com" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice@example.com", {
email: "alice@example.com",
});
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice@example.com");
});
it("does not identify when the identity probe returns nothing", async () => {
+10 -23
View File
@@ -47,6 +47,7 @@ import {
type StoredUserInfo,
type UserInfo,
} from "../../auth/index.js";
import { telemetryIdentity } from "../../auth/telemetryIdentity.js";
import { c } from "../../ui/colors.js";
const STDIN_TIMEOUT_MS = 30_000;
@@ -178,9 +179,9 @@ async function runDeviceLogin(): Promise<void> {
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("device", id);
const who = telemetryIdentity(user);
if (who) identifyUser(who.distinctId, who.traits);
trackAuthLoginCompleted("device", who?.distinctId);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ Signed in as ${identity}.`));
}
@@ -250,9 +251,9 @@ async function reportIdentity(credential: ReturnType<typeof issuedCredential>):
// Attribute this install to the signed-in account (and stitch its prior
// anonymous usage) before recording completion, so the completed event
// carries the identity. Both no-op under the telemetry opt-out.
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("oauth", id);
const who = telemetryIdentity(user);
if (who) identifyUser(who.distinctId, who.traits);
trackAuthLoginCompleted("oauth", who?.distinctId);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ Signed in as ${identity}.`));
} catch (err) {
@@ -269,20 +270,6 @@ async function reportIdentity(credential: ReturnType<typeof issuedCredential>):
}
}
/**
* The stable key we associate this install with in telemetry after sign-in.
* `/v3/users/me` exposes no opaque user_id, so we key on the HeyGen account
* EMAIL — the canonical account identifier and the reliable join key back to
* billing — falling back to username only when the account exposes no email.
* (Username is NOT a privacy win — HeyGen usernames are frequently email-shaped
* — it is purely a fallback so an emailless account is still attributable.)
* The privacy notice (showTelemetryNotice) and docs/packages/cli.mdx disclose
* both, so keep them in sync with whatever this returns.
*/
function identityKey(user: UserInfo): string | undefined {
return user.email ?? user.username;
}
/** Project the API `/v3/users/me` view onto the on-disk identity block. */
function toStoredUserInfo(user: UserInfo): StoredUserInfo {
const out: StoredUserInfo = {};
@@ -369,9 +356,9 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
await rollback(previous);
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("api_key", id);
const who = telemetryIdentity(user);
if (who) identifyUser(who.distinctId, who.traits);
trackAuthLoginCompleted("api_key", who?.distinctId);
}
async function snapshotStore(): Promise<Credentials> {
+1 -1
View File
@@ -23,7 +23,7 @@ export default defineCommand({
meta: {
name: "events",
description:
"Emit an anonymous skill-usage telemetry event (skills report their own invocation/outcome). Honors DO_NOT_TRACK / telemetry opt-out.",
"Emit a skill-usage telemetry event (skills report their own invocation/outcome). Honors DO_NOT_TRACK / telemetry opt-out.",
},
args: {
skill: {
+1 -1
View File
@@ -790,7 +790,7 @@ export default defineCommand({
description:
"Owning authoring skill slug (e.g. hyperframes). Stamped into " +
"hyperframes.json so every render of this project is attributed to it on " +
"anonymous telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
"usage telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
},
},
async run({ args }) {
+1 -1
View File
@@ -199,7 +199,7 @@ export default defineCommand({
type: "string",
description:
"Authoring skill that initiated this render (e.g. hyperframes). " +
"Recorded on anonymous render telemetry for per-skill usage breakdowns; ignored unless it is a slug.",
"Recorded on render telemetry for per-skill usage breakdowns; ignored unless it is a slug.",
},
format: {
type: "string",
+6 -5
View File
@@ -74,7 +74,7 @@ function runStatus(): void {
}
export default defineCommand({
meta: { name: "telemetry", description: "Manage anonymous usage telemetry" },
meta: { name: "telemetry", description: "Manage usage telemetry" },
args: {
subcommand: {
type: "positional",
@@ -89,23 +89,24 @@ export default defineCommand({
console.log(`
${c.bold("hyperframes telemetry")} ${c.dim("<subcommand>")}
Manage anonymous usage data collection.
Manage usage data collection (anonymous until you sign in to HeyGen).
${c.bold("SUBCOMMANDS:")}
${c.accent("status")} ${c.dim("Show current telemetry status")}
${c.accent("enable")} ${c.dim("Enable anonymous telemetry")}
${c.accent("disable")} ${c.dim("Disable anonymous telemetry")}
${c.accent("enable")} ${c.dim("Enable usage telemetry")}
${c.accent("disable")} ${c.dim("Disable usage telemetry")}
${c.bold("WHAT WE COLLECT:")}
${c.dim("\u2022")} Command names (init, render, preview, etc.)
${c.dim("\u2022")} Render performance (duration, fps, quality)
${c.dim("\u2022")} Template choices
${c.dim("\u2022")} OS, architecture, Node.js version, CLI version
${c.dim("\u2022")} Once you sign in to HeyGen: your account email and username
${c.bold("WHAT WE DON'T COLLECT:")}
${c.dim("\u2022")} File paths, project names, or video content
${c.dim("\u2022")} IP addresses (discarded by our analytics provider)
${c.dim("\u2022")} Any personally identifiable information
${c.dim("\u2022")} Anything that identifies you before you sign in
${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")} ${c.dim("to disable.")}
`);
+2 -2
View File
@@ -107,8 +107,8 @@ const GROUPS: Group[] = [
{
title: "Settings",
commands: [
["feedback", "Submit anonymous feedback about your experience"],
["telemetry", "Manage anonymous usage telemetry"],
["feedback", "Submit feedback about your experience"],
["telemetry", "Manage usage telemetry"],
],
},
];
+49 -15
View File
@@ -1,7 +1,7 @@
// Usage tracking shares the CLI and Studio identity. Properties stay coarse and
// never carry intent text, file names, or paths.
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
@@ -119,7 +119,30 @@ function anonymousId() {
}
}
function heygenAccountDistinctId() {
// The HyperFrames user id for a HeyGen username: the same sha256-derived UUID
// the CLI (auth/telemetryIdentity.ts), web app and Desktop key PostHog on.
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function heygenUserId(username) {
if (UUID_PATTERN.test(username)) return username;
const hash = createHash("sha256").update(username).digest();
hash[6] = (hash[6] & 0x0f) | 0x50;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = hash.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
// The signed-in HeyGen account, keyed like the CLI's own identify call: the
// username's user id, else the lowercased email (lowercased so different stored
// casing never splits one person). `heygen_username` is the cross-project join
// column for the Snowflake event table.
function heygenAccount() {
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
try {
if (!existsSync(file)) return null;
@@ -127,13 +150,19 @@ function heygenAccountDistinctId() {
if (!raw.startsWith("{")) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (!parsed.api_key && !parsed.oauth) return null;
const user = parsed.user;
if (!user || typeof user !== "object" || Array.isArray(user)) return null;
const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username;
// Lowercased so this joins with the CLI's own identify call regardless of
// the account's stored email casing — two different-case distinct ids
// would otherwise split one person across two PostHog profiles.
return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null;
const email =
typeof user.email === "string" && user.email.trim() ? user.email.trim().toLowerCase() : null;
const username =
typeof user.username === "string" && user.username.trim() ? user.username : null;
const traits = {};
if (email) traits.email = email;
if (username) traits.heygen_username = username;
if (username) return { distinctId: heygenUserId(username), traits };
if (email) return { distinctId: email, traits };
return null;
} catch {
return null;
}
@@ -149,7 +178,7 @@ function showTelemetryNotice() {
console.error(
[
"media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.",
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
"If you sign in to HeyGen, usage links to your account (email and username). Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
].join("\n"),
);
updateSharedConfig({ telemetryNoticeShown: true });
@@ -182,12 +211,14 @@ async function postEvent(event, properties, distinctId) {
]);
}
async function identifyAccount(anonId) {
if (optedOut() || identifiedAccount) return;
const distinctId = heygenAccountDistinctId();
if (!distinctId) return;
async function identifyAccount(anonId, account) {
if (optedOut() || identifiedAccount || !account) return;
identifiedAccount = true;
await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId);
await postEvent(
"$identify",
{ $anon_distinct_id: anonId, $set: account.traits },
account.distinctId,
);
}
/**
@@ -199,8 +230,11 @@ export async function track(event, properties = {}) {
if (optedOut()) return;
showTelemetryNotice();
const anonId = anonymousId();
await identifyAccount(anonId);
await postEvent(event, properties, anonId);
const account = heygenAccount();
await identifyAccount(anonId, account);
const username = account?.traits.heygen_username;
if (!username) return postEvent(event, properties, anonId);
await postEvent(event, { ...properties, heygen_username: username }, heygenUserId(username));
}
export function __anonymousIdForTest() {
@@ -184,7 +184,10 @@ test("track identifies a signed-in HeyGen account once and still sends events",
mkdirSync(join(home, ".heygen"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
JSON.stringify({ user: { email: "alice@example.com", username: "alice" } }),
JSON.stringify({
api_key: "hg_test",
user: { email: "alice@example.com", username: "alice" },
}),
);
await track("media_use_resolve", { type: "bgm", source: "search" });
@@ -193,9 +196,19 @@ test("track identifies a signed-in HeyGen account once and still sends events",
const batch = parseFetchBodies(calls);
const identify = batch.filter((item) => item.event === "$identify");
assert.equal(identify.length, 1);
assert.equal(identify[0].distinct_id, "alice@example.com");
// heygenUserId("alice"): the same id the CLI's own login identify uses.
assert.equal(identify[0].distinct_id, "2bd806c9-7f0e-50af-9a1f-c3328fa763a9");
assert.equal(identify[0].properties.$anon_distinct_id, "anon-1");
assert.equal(batch.filter((item) => item.event === "media_use_resolve").length, 2);
assert.deepEqual(identify[0].properties.$set, {
email: "alice@example.com",
heygen_username: "alice",
});
const resolves = batch.filter((item) => item.event === "media_use_resolve");
assert.equal(resolves.length, 2);
assert.ok(resolves.every((item) => item.properties.heygen_username === "alice"));
assert.ok(
resolves.every((item) => item.distinct_id === "2bd806c9-7f0e-50af-9a1f-c3328fa763a9"),
);
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
@@ -204,7 +217,7 @@ test("track identifies a signed-in HeyGen account once and still sends events",
}
});
test("track identifies with a lowercased email regardless of stored casing", async () => {
test("track falls back to a lowercased email when the account has no username", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
@@ -223,7 +236,7 @@ test("track identifies with a lowercased email regardless of stored casing", asy
mkdirSync(join(home, ".heygen"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
JSON.stringify({ user: { email: "Alice@Example.com", username: "alice" } }),
JSON.stringify({ api_key: "hg_test", user: { email: "Alice@Example.com" } }),
);
await track("media_use_resolve", { type: "bgm", source: "search" });
@@ -269,6 +282,7 @@ test("track does not identify when signed out", async () => {
);
assert.equal(batch[0].event, "media_use_resolve");
assert.equal(batch[0].distinct_id, "anon-2");
assert.equal(batch[0].properties.heygen_username, undefined);
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
+1 -1
View File
@@ -1422,7 +1422,7 @@ async function captureResolveEvent({ provider, type = "bgm", intent }) {
// never touch the real developer machine, and point the host at the local
// server. HEYGEN_CONFIG_DIR is sandboxed too -- runResolve's env is
// {...process.env, ...env}, so a developer with that var set to a real
// credentials dir would otherwise have heygenAccountDistinctId() read
// credentials dir would otherwise have heygenAccount() read
// their real email into this test's local-server payload despite HOME
// being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials
// path). Every other test in this file keeps its untouched default env.
+40
View File
@@ -45,6 +45,9 @@ vi.mock("./canary.js", () => ({
canaryEventProperties: () => canaryProps(),
}));
const signedIn = vi.hoisted(() => ({ username: undefined as string | undefined }));
vi.mock("./signedInUser.js", () => ({ signedInHeygenUsername: () => signedIn.username }));
// Intercept the exit-time child process so flushSync delivery is assertable.
const spawnMock = vi.fn(() => ({ unref: vi.fn() }));
vi.mock("node:child_process", () => ({
@@ -335,3 +338,40 @@ describe("canary cohort on every event", () => {
);
});
});
describe("signed-in HeyGen account on every event", () => {
const firstEvent = (fetchMock: ReturnType<typeof vi.fn>) => {
const init = fetchMock.mock.calls[0]?.[1] as { body: string };
return (
JSON.parse(init.body) as {
batch: Array<{ distinct_id: string; properties: Record<string, unknown> }>;
}
).batch[0]!;
};
it("keys signed-in events on the account id and carries heygen_username", async () => {
signedIn.username = "alice";
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }) as Response);
vi.stubGlobal("fetch", fetchMock);
trackEvent("cli_command", { command: "render" });
await flush();
const event = firstEvent(fetchMock);
expect(event.distinct_id).toBe("2bd806c9-7f0e-50af-9a1f-c3328fa763a9");
expect(event.properties.heygen_username).toBe("alice");
});
it("goes back to the install id once signed out", async () => {
signedIn.username = undefined;
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }) as Response);
vi.stubGlobal("fetch", fetchMock);
trackEvent("cli_command", { command: "render" });
await flush();
const event = firstEvent(fetchMock);
expect(event.distinct_id).not.toBe("2bd806c9-7f0e-50af-9a1f-c3328fa763a9");
expect(event.properties.heygen_username).toBeUndefined();
});
});
+9 -3
View File
@@ -10,6 +10,8 @@ import { c } from "../ui/colors.js";
import { diag } from "../ui/diagnostics.js";
import { getSystemMeta } from "./system.js";
import { canaryEventProperties } from "./canary.js";
import { signedInHeygenUsername } from "./signedInUser.js";
import { heygenUserId } from "../auth/telemetryIdentity.js";
import { enqueue, type EventProperties } from "./transport.js";
import { telemetryRuntimeOverride } from "./policy.js";
@@ -74,6 +76,7 @@ export function trackEvent(
if (!shouldTrack()) return;
const sys = getSystemMeta();
const heygenUsername = signedInHeygenUsername();
enqueue(
event,
{
@@ -130,8 +133,9 @@ export function trackEvent(
...canaryEventProperties(),
agent_env_hints: sys.agent_env_hints ?? undefined,
client: sys.client ?? undefined,
heygen_username: heygenUsername,
},
distinctId,
distinctId ?? (heygenUsername ? heygenUserId(heygenUsername) : undefined),
);
}
@@ -155,10 +159,12 @@ export function showTelemetryNotice(): boolean {
// --json (the guard in cli.ts filters by command only), so a stdout banner
// would corrupt the JSON envelope of the very first `check --json` etc.
diag.notice();
diag.notice(` ${c.dim("Hyperframes collects anonymous usage data to improve the tool.")}`);
diag.notice(
` ${c.dim("Hyperframes collects usage data to improve the tool, anonymous until you sign in.")}`,
);
diag.notice(` ${c.dim("File paths and composition content are never collected.")}`);
diag.notice(
` ${c.dim("If you sign in to HeyGen, your account (email, or username) is linked to your usage.")}`,
` ${c.dim("If you sign in to HeyGen, your account (email and username) is linked to your usage.")}`,
);
diag.notice();
diag.notice(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`);
+6 -3
View File
@@ -1395,11 +1395,14 @@ describe("auth login telemetry events", () => {
});
it("identifyUser emits a $identify alias linking the anon install to the identity", () => {
identifyUser("alice@example.com");
identifyUser("user-id-1", { email: "alice@example.com", heygen_username: "alice" });
expect(trackEvent).toHaveBeenCalledWith(
"$identify",
{ $anon_distinct_id: "anon-test-123" },
"alice@example.com",
{
$anon_distinct_id: "anon-test-123",
$set: { email: "alice@example.com", heygen_username: "alice" },
},
"user-id-1",
);
});
+8 -4
View File
@@ -872,11 +872,15 @@ export function trackAuthLoginFailed(
// install's anonymousId, so events recorded before sign-in stitch to the same
// person instead of stranding as a separate anonymous profile. Routed through
// trackEvent so it shares the opt-out gate and flush path — a no-op when
// telemetry is disabled. `distinctId` is the account email (else username);
// see the privacy notice in showTelemetryNotice and docs/packages/cli.mdx.
export function identifyUser(distinctId: string): void {
// telemetry is disabled. Identity comes from auth/telemetryIdentity.ts; see
// showTelemetryNotice and docs/packages/cli.mdx.
export function identifyUser(distinctId: string, traits: Record<string, string> = {}): void {
if (!distinctId) return;
trackEvent("$identify", { $anon_distinct_id: readConfig().anonymousId }, distinctId);
trackEvent(
"$identify",
{ $anon_distinct_id: readConfig().anonymousId, $set: traits },
distinctId,
);
}
// A render was rejected by the output-resolution/HDR pre-flight (P1-3)
@@ -0,0 +1,46 @@
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { signedInHeygenUsername } from "./signedInUser.js";
describe("signedInHeygenUsername", () => {
let dir: string;
const saved = process.env["HEYGEN_CONFIG_DIR"];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-signed-in-"));
process.env["HEYGEN_CONFIG_DIR"] = dir;
});
afterEach(() => {
if (saved === undefined) delete process.env["HEYGEN_CONFIG_DIR"];
else process.env["HEYGEN_CONFIG_DIR"] = saved;
rmSync(dir, { recursive: true, force: true });
});
const write = (contents: unknown, mtime: number) => {
const file = join(dir, "credentials");
writeFileSync(file, typeof contents === "string" ? contents : JSON.stringify(contents));
utimesSync(file, mtime, mtime);
};
it("is undefined when nobody is signed in", () => {
expect(signedInHeygenUsername()).toBeUndefined();
write({ api_key: "hg_x" }, 1000);
expect(signedInHeygenUsername()).toBeUndefined();
write("hg_legacy_plain_key", 1001);
expect(signedInHeygenUsername()).toBeUndefined();
});
it("returns the raw username after sign-in and drops it after sign-out", () => {
write({ user: { username: "Alice" } }, 1999);
expect(signedInHeygenUsername()).toBeUndefined();
write({ api_key: "hg_x", user: { username: "Alice", email: "alice@example.com" } }, 2000);
expect(signedInHeygenUsername()).toBe("Alice");
write({ api_key: "hg_x" }, 2001);
expect(signedInHeygenUsername()).toBeUndefined();
rmSync(join(dir, "credentials"));
expect(signedInHeygenUsername()).toBeUndefined();
});
});
@@ -0,0 +1,32 @@
import { closeSync, fstatSync, openSync, readFileSync } from "node:fs";
import { credentialPath } from "../auth/paths.js";
let cached: { path: string; mtimeMs: number; username: string | undefined } | undefined;
function usernameOf(parsed: unknown): string | undefined {
if (!parsed || typeof parsed !== "object") return undefined;
const { user, api_key, oauth } = parsed as { user?: unknown; api_key?: unknown; oauth?: unknown };
if (!api_key && !oauth) return undefined;
if (!user || typeof user !== "object") return undefined;
const name = (user as { username?: unknown }).username;
return typeof name === "string" && name.trim() ? name : undefined;
}
/** The signed-in HeyGen username, put on every event so each row joins across projects by itself. */
export function signedInHeygenUsername(): string | undefined {
const path = credentialPath();
let fd: number | undefined;
try {
fd = openSync(path, "r");
const { mtimeMs } = fstatSync(fd);
if (cached?.path === path && cached.mtimeMs === mtimeMs) return cached.username;
const username = usernameOf(JSON.parse(readFileSync(fd, "utf8")));
cached = { path, mtimeMs, username };
return username;
} catch {
cached = undefined;
return undefined;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ export type EventPropertyValue =
| null
| undefined
| readonly string[]
| Readonly<Record<string, number>>;
| Readonly<Record<string, number>>
| Readonly<Record<string, string>>;
export interface EventProperties {
[key: string]: EventPropertyValue;
+2 -2
View File
@@ -10,7 +10,7 @@
"files": 77
},
"hyperframes-cli": {
"hash": "19e8e8f081dabdfd",
"hash": "64c80027b1239a9f",
"files": 13
},
"hyperframes-registry": {
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "938c00af915a46b5",
"hash": "3755ab80e5df5341",
"files": 116
},
"remotion-to-hyperframes": {
@@ -23,7 +23,7 @@ Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decisi
Other useful flags:
- `--resolution` — preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k`, `portrait-4k`, `square` (1080×1080), `square-4k`. Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `4k-square`.
- `--skill=<slug>` — record the owning skill (e.g. `hyperframes`) in `hyperframes.json`, so every later render of this project — re-renders, `npm run render`, `--batch` — is attributed to it on anonymous telemetry without re-passing the flag. You rarely pass it by hand.
- `--skill=<slug>` — record the owning skill (e.g. `hyperframes`) in `hyperframes.json`, so every later render of this project — re-renders, `npm run render`, `--batch` — is attributed to it on usage telemetry without re-passing the flag. You rarely pass it by hand.
- `--skip-skills` — leave AI coding skills alone: `init` does not install the core set. `HYPERFRAMES_SKIP_SKILLS=1` does the same.
- `--skip-transcribe` — don't auto-transcribe `--audio` / `--video` with Whisper.
- `--model`, `--language` — Whisper model / language for the auto-transcription.
@@ -160,7 +160,7 @@ npx hyperframes feedback --rating 10 # clean run, n
npx hyperframes feedback --rating 6 --comment "bg <video> renders grey in multi-scene; worked around with --format png-sequence"
```
`--rating` is an integer from 0-10; `--comment` is free text. A `--comment` with no `--rating` is a ratingless report (`MISSING FEATURE:` and `HOST APP:` in `SKILL.md`) that never counts in the rating metric. Feedback is anonymous and attaches a `doctorSummary` (OS/Node/CPU/mem/ffmpeg) automatically, so don't repeat those fields. A clean run needs only a short result. Before sending any bug, workaround, or confusing behavior, collect this compact reproduction packet:
`--rating` is an integer from 0-10; `--comment` is free text. A `--comment` with no `--rating` is a ratingless report (`MISSING FEATURE:` and `HOST APP:` in `SKILL.md`) that never counts in the rating metric. Feedback is anonymous until the user signs in to HeyGen (then it is linked to their account) and attaches a `doctorSummary` (OS/Node/CPU/mem/ffmpeg) automatically, so don't repeat those fields. A clean run needs only a short result. Before sending any bug, workaround, or confusing behavior, collect this compact reproduction packet:
```text
REPRO COMMAND: <HF_*/PRODUCER_* env> npx hyperframes <exact command> # run from the project directory; do NOT paste absolute paths
@@ -199,4 +199,4 @@ Uploads the project's source (HTML + assets) and returns a stable hosted URL tha
Use cloud rendering when the user wants hosted rendering without local Chrome, FFmpeg, or AWS. Use Lambda only when AWS ownership is a requirement. Use Cloud Run only when GCP ownership is a requirement. Read the matching reference before running any cloud path.
Skill attribution is automatic — the examples above need no `--skill`. A project scaffolded with `hyperframes init --skill=<slug>` records its owning skill in `hyperframes.json`, and every later render inherits it on anonymous telemetry: re-renders, `npm run render`, and `--batch` alike. Pass `--skill=<slug>` explicitly only to stamp a project that was not scaffolded with one (its first render then persists it).
Skill attribution is automatic — the examples above need no `--skill`. A project scaffolded with `hyperframes init --skill=<slug>` records its owning skill in `hyperframes.json`, and every later render inherits it on usage telemetry: re-renders, `npm run render`, and `--batch` alike. Pass `--skill=<slug>` explicitly only to stamp a project that was not scaffolded with one (its first render then persists it).
@@ -101,11 +101,11 @@ Renders the project with 5 preset configurations — `30fps draft 2w`, `30fps st
```bash
npx hyperframes telemetry status # show telemetry state
npx hyperframes telemetry disable # disable anonymous usage telemetry
npx hyperframes telemetry disable # disable usage telemetry
npx hyperframes telemetry enable # re-enable telemetry
```
Telemetry is anonymous usage counters only. Disable globally with `HYPERFRAMES_NO_TELEMETRY=1` if env-var control is preferred over the subcommand.
Telemetry is usage counters only. It is anonymous until the user signs in to HeyGen; after `auth login` it is linked to their HeyGen account (email and username), so don't tell a signed-in user it is anonymous. Disable globally with `HYPERFRAMES_NO_TELEMETRY=1` if env-var control is preferred over the subcommand.
Events include two fingerprint properties used to distinguish managed-sandbox runs from real laptops — no PII, no env-var **values**, only existence checks:
+6 -5
View File
@@ -35,9 +35,10 @@ npx hyperframes media-use resolve --stats --project . --days 7
## Telemetry
`resolve` and the edit tools (transcribe / transcript-cut / audio-duck) send an
anonymous usage event to PostHog (`scripts/lib/telemetry.mjs`), so we can see
which capabilities are actually used. It records only the media TYPE, the
`resolve` and the edit tools (transcribe / transcript-cut / audio-duck) send a
usage event to PostHog (`scripts/lib/telemetry.mjs`), so we can see which
capabilities are actually used. It is anonymous until you sign in to HeyGen
(see Privacy below). It records only the media TYPE, the
resolution SOURCE, and the winning PROVIDER: never the intent text, file names,
or paths, and `$ip:null` so no IP is stored. Best-effort and non-blocking (a
resolve never waits on or fails from telemetry).
@@ -55,7 +56,7 @@ so a future call site can't silently ship untagged. Read-only discovery calls
media-use uses the same shared install id as the `hyperframes` CLI/studio
(`~/.hyperframes/config.json`). When you are signed in to HeyGen, usage is
linked to your account email, or username when email is unavailable, matching
the CLI behavior. The events stay coarse: media type, source, provider, and
linked to your HeyGen account (keyed on an id derived from your username, with
your email and username stored on it), matching the CLI behavior. The events stay coarse: media type, source, provider, and
small counts only; intent text and paths stay local. Disable telemetry with
`HYPERFRAMES_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`.
+49 -15
View File
@@ -1,7 +1,7 @@
// Usage tracking shares the CLI and Studio identity. Properties stay coarse and
// never carry intent text, file names, or paths.
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
@@ -119,7 +119,30 @@ function anonymousId() {
}
}
function heygenAccountDistinctId() {
// The HyperFrames user id for a HeyGen username: the same sha256-derived UUID
// the CLI (auth/telemetryIdentity.ts), web app and Desktop key PostHog on.
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function heygenUserId(username) {
if (UUID_PATTERN.test(username)) return username;
const hash = createHash("sha256").update(username).digest();
hash[6] = (hash[6] & 0x0f) | 0x50;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = hash.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
// The signed-in HeyGen account, keyed like the CLI's own identify call: the
// username's user id, else the lowercased email (lowercased so different stored
// casing never splits one person). `heygen_username` is the cross-project join
// column for the Snowflake event table.
function heygenAccount() {
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
try {
if (!existsSync(file)) return null;
@@ -127,13 +150,19 @@ function heygenAccountDistinctId() {
if (!raw.startsWith("{")) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (!parsed.api_key && !parsed.oauth) return null;
const user = parsed.user;
if (!user || typeof user !== "object" || Array.isArray(user)) return null;
const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username;
// Lowercased so this joins with the CLI's own identify call regardless of
// the account's stored email casing — two different-case distinct ids
// would otherwise split one person across two PostHog profiles.
return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null;
const email =
typeof user.email === "string" && user.email.trim() ? user.email.trim().toLowerCase() : null;
const username =
typeof user.username === "string" && user.username.trim() ? user.username : null;
const traits = {};
if (email) traits.email = email;
if (username) traits.heygen_username = username;
if (username) return { distinctId: heygenUserId(username), traits };
if (email) return { distinctId: email, traits };
return null;
} catch {
return null;
}
@@ -149,7 +178,7 @@ function showTelemetryNotice() {
console.error(
[
"media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.",
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
"If you sign in to HeyGen, usage links to your account (email and username). Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
].join("\n"),
);
updateSharedConfig({ telemetryNoticeShown: true });
@@ -182,12 +211,14 @@ async function postEvent(event, properties, distinctId) {
]);
}
async function identifyAccount(anonId) {
if (optedOut() || identifiedAccount) return;
const distinctId = heygenAccountDistinctId();
if (!distinctId) return;
async function identifyAccount(anonId, account) {
if (optedOut() || identifiedAccount || !account) return;
identifiedAccount = true;
await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId);
await postEvent(
"$identify",
{ $anon_distinct_id: anonId, $set: account.traits },
account.distinctId,
);
}
/**
@@ -199,8 +230,11 @@ export async function track(event, properties = {}) {
if (optedOut()) return;
showTelemetryNotice();
const anonId = anonymousId();
await identifyAccount(anonId);
await postEvent(event, properties, anonId);
const account = heygenAccount();
await identifyAccount(anonId, account);
const username = account?.traits.heygen_username;
if (!username) return postEvent(event, properties, anonId);
await postEvent(event, { ...properties, heygen_username: username }, heygenUserId(username));
}
export function __anonymousIdForTest() {