diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cb4fadc6e..7a6bcdf820 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**" diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index f21de16913..d09d55193a 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 `//` 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 diff --git a/packages/cli/src/auth/telemetryIdentity.test.ts b/packages/cli/src/auth/telemetryIdentity.test.ts new file mode 100644 index 0000000000..066cc0b0c9 --- /dev/null +++ b/packages/cli/src/auth/telemetryIdentity.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/auth/telemetryIdentity.ts b/packages/cli/src/auth/telemetryIdentity.ts new file mode 100644 index 0000000000..e196d4f4a8 --- /dev/null +++ b/packages/cli/src/auth/telemetryIdentity.ts @@ -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; +} + +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 = {}; + 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; +} diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index 2944040a76..443928407a 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -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 () => { diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 89bed70997..6082c5ddc6 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -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 { 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): // 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): } } -/** - * 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 { 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 { diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 37d68790f9..a381f7f043 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -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: { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 243d6466da..0b0ff7797b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 }) { diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 68d1a9b18d..0bab9786ea 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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", diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts index 0a67d52044..0d90e0dc20 100644 --- a/packages/cli/src/commands/telemetry.ts +++ b/packages/cli/src/commands/telemetry.ts @@ -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("")} -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.")} `); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8949e249e9..136ab3ec3e 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -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"], ], }, ]; diff --git a/packages/cli/src/media-use/lib/telemetry.mjs b/packages/cli/src/media-use/lib/telemetry.mjs index 182e3505b5..21a427f9c9 100644 --- a/packages/cli/src/media-use/lib/telemetry.mjs +++ b/packages/cli/src/media-use/lib/telemetry.mjs @@ -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() { diff --git a/packages/cli/src/media-use/lib/telemetry.test.mjs b/packages/cli/src/media-use/lib/telemetry.test.mjs index 19db85a4cb..df9cf206b5 100644 --- a/packages/cli/src/media-use/lib/telemetry.test.mjs +++ b/packages/cli/src/media-use/lib/telemetry.test.mjs @@ -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); diff --git a/packages/cli/src/media-use/resolve.test.mjs b/packages/cli/src/media-use/resolve.test.mjs index dc8a2ce01b..c1f366db35 100644 --- a/packages/cli/src/media-use/resolve.test.mjs +++ b/packages/cli/src/media-use/resolve.test.mjs @@ -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. diff --git a/packages/cli/src/telemetry/client.test.ts b/packages/cli/src/telemetry/client.test.ts index e393ecaffd..927278bdb8 100644 --- a/packages/cli/src/telemetry/client.test.ts +++ b/packages/cli/src/telemetry/client.test.ts @@ -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) => { + const init = fetchMock.mock.calls[0]?.[1] as { body: string }; + return ( + JSON.parse(init.body) as { + batch: Array<{ distinct_id: string; properties: Record }>; + } + ).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(); + }); +}); diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index edfed5cb27..bda5c27fc6 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -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")}`); diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index c45b28a6fb..13d275619a 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -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", ); }); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 95df698be3..62d524f754 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -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 = {}): 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) diff --git a/packages/cli/src/telemetry/signedInUser.test.ts b/packages/cli/src/telemetry/signedInUser.test.ts new file mode 100644 index 0000000000..640c6e06ac --- /dev/null +++ b/packages/cli/src/telemetry/signedInUser.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/telemetry/signedInUser.ts b/packages/cli/src/telemetry/signedInUser.ts new file mode 100644 index 0000000000..9e90c0f446 --- /dev/null +++ b/packages/cli/src/telemetry/signedInUser.ts @@ -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); + } +} diff --git a/packages/cli/src/telemetry/transport.ts b/packages/cli/src/telemetry/transport.ts index a7f9c335ee..a85fa702a1 100644 --- a/packages/cli/src/telemetry/transport.ts +++ b/packages/cli/src/telemetry/transport.ts @@ -35,7 +35,8 @@ export type EventPropertyValue = | null | undefined | readonly string[] - | Readonly>; + | Readonly> + | Readonly>; export interface EventProperties { [key: string]: EventPropertyValue; diff --git a/skills-manifest.json b/skills-manifest.json index 7f624f70fc..07f5269ca4 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -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": { diff --git a/skills/hyperframes-cli/references/init-and-scaffold.md b/skills/hyperframes-cli/references/init-and-scaffold.md index ddf6fcd241..37aede0823 100644 --- a/skills/hyperframes-cli/references/init-and-scaffold.md +++ b/skills/hyperframes-cli/references/init-and-scaffold.md @@ -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=` — 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=` — 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. diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index ddb32564c1..ebbb6d2d9a 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -160,7 +160,7 @@ npx hyperframes feedback --rating 10 # clean run, n npx hyperframes feedback --rating 6 --comment "bg