From 1c3fcf4d237d031107baa6a5646b5bb1ee82cc2a Mon Sep 17 00:00:00 2001 From: James Russo Date: Thu, 29 Oct 2026 02:57:48 +0000 Subject: [PATCH] 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) * 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) * chore(telemetry): trim comments to the package's comment share Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 1 + docs/packages/cli.mdx | 32 ++++++---- .../cli/src/auth/telemetryIdentity.test.ts | 44 +++++++++++++ packages/cli/src/auth/telemetryIdentity.ts | 38 +++++++++++ packages/cli/src/commands/auth/login.test.ts | 24 ++++--- packages/cli/src/commands/auth/login.ts | 33 +++------- packages/cli/src/commands/events.ts | 2 +- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/commands/telemetry.ts | 11 ++-- packages/cli/src/help.ts | 4 +- packages/cli/src/media-use/lib/telemetry.mjs | 64 ++++++++++++++----- .../cli/src/media-use/lib/telemetry.test.mjs | 24 +++++-- packages/cli/src/media-use/resolve.test.mjs | 2 +- packages/cli/src/telemetry/client.test.ts | 40 ++++++++++++ packages/cli/src/telemetry/client.ts | 12 +++- packages/cli/src/telemetry/events.test.ts | 9 ++- packages/cli/src/telemetry/events.ts | 12 ++-- .../cli/src/telemetry/signedInUser.test.ts | 46 +++++++++++++ packages/cli/src/telemetry/signedInUser.ts | 32 ++++++++++ packages/cli/src/telemetry/transport.ts | 3 +- skills-manifest.json | 4 +- .../references/init-and-scaffold.md | 2 +- .../references/preview-render.md | 4 +- .../references/upgrade-info-misc.md | 4 +- skills/media-use/references/meta.md | 11 ++-- skills/media-use/scripts/lib/telemetry.mjs | 64 ++++++++++++++----- 27 files changed, 415 insertions(+), 111 deletions(-) create mode 100644 packages/cli/src/auth/telemetryIdentity.test.ts create mode 100644 packages/cli/src/auth/telemetryIdentity.ts create mode 100644 packages/cli/src/telemetry/signedInUser.test.ts create mode 100644 packages/cli/src/telemetry/signedInUser.ts 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