From f4b6ff53550e00a8018e689374e71bc978d143a9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 02:00:21 -0400 Subject: [PATCH 1/2] feat(cli): associate signed-in HeyGen account with telemetry Sign-in telemetry currently attributes everything to the anonymous install id, so the sign-in funnel can be counted but a completed sign-in can't be tied to the account it produced. This associates the two. - On a completed sign-in, emit a PostHog `$identify` alias whose `$anon_distinct_id` is the install's anonymousId, so events recorded before sign-in stitch to the same person, and tag `auth_login_completed` with the account identity (the pre-plumbed `distinctId`). - `/v3/users/me` exposes no opaque user_id, so the identity key is the account email, falling back to username (single `identityKey` helper). - Both no-op under the `telemetry disable` opt-out and only fire after the user chooses to sign in. Privacy disclosure updated in lockstep, since this is the first PII the CLI attaches: the first-run telemetry notice and the telemetry section of docs/packages/cli.mdx now state that signing in links your account email to your usage. Tests: identifyUser payload + no-op, completion attribution incl. username fallback and no-identity-on-reject/empty. Verified end-to-end against the built CLI: pre-auth events anonymous, $identify carries $anon_distinct_id, completion carries the account email. --- docs/packages/cli.mdx | 2 +- packages/cli/src/commands/auth/login.test.ts | 40 +++++++++++++++ packages/cli/src/commands/auth/login.ts | 51 ++++++++++++++------ packages/cli/src/telemetry/client.ts | 5 +- packages/cli/src/telemetry/events.test.ts | 27 +++++++++-- packages/cli/src/telemetry/events.ts | 13 +++++ packages/cli/src/telemetry/index.ts | 1 + 7 files changed, 119 insertions(+), 20 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index eeca389245..e996c747f3 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -907,7 +907,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o npx hyperframes telemetry status ``` - Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, environment variable values, or personally identifiable information. Disable with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. + Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, or environment variable values. It collects no personally identifiable information until you sign in: when you authenticate with `hyperframes auth login`, your HeyGen account email is linked to your usage so CLI activity can be associated with your account (and your prior anonymous usage is stitched to it). Nothing else personal is collected, and this only happens after you choose to sign in. Disable all telemetry with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. See [Feedback Collection](/guides/feedback) for how the periodic post-render prompt and Studio feedback bar work, what data they collect, and how to opt out. diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index b1d103ee99..c5af5d48dd 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -31,6 +31,17 @@ vi.mock("../../auth/index.js", async (orig) => { return { ...actual, AuthClient: MockAuthClient }; }); +// Spy on the telemetry the login flow emits, so we can assert the identity is +// attributed on success. login.ts imports these via a dynamic import of +// telemetry/index.js; the mock intercepts it. +const telemetry = vi.hoisted(() => ({ + trackAuthLoginStarted: vi.fn(), + trackAuthLoginCompleted: vi.fn(), + trackAuthLoginFailed: vi.fn(), + identifyUser: vi.fn(), +})); +vi.mock("../../telemetry/index.js", () => telemetry); + const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const; describe("auth login --api-key rollback", () => { @@ -46,6 +57,7 @@ describe("auth login --api-key rollback", () => { process.env["HEYGEN_CONFIG_DIR"] = dir; verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; + for (const fn of Object.values(telemetry)) fn.mockClear(); // process.exit throws so we can assert the post-rollback state. vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { throw new Error(`process.exit:${code ?? 0}`); @@ -158,6 +170,34 @@ describe("auth login --api-key rollback", () => { 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" }; + await runLogin("hg_goodkey456"); + expect(telemetry.identifyUser).toHaveBeenCalledWith("alice@example.com"); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice@example.com"); + }); + + it("falls back to username when the account has no email", async () => { + verifyState.user = { username: "alice" }; + await runLogin("hg_goodkey456"); + expect(telemetry.identifyUser).toHaveBeenCalledWith("alice"); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice"); + }); + + it("does not identify when the identity probe returns nothing", async () => { + verifyState.user = {}; // verified key, but no identity fields + await runLogin("hg_goodkey456"); + expect(telemetry.identifyUser).not.toHaveBeenCalled(); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", undefined); + }); + + it("records a rejected key as failed and never identifies", async () => { + verifyState.reject = true; + await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/); + expect(telemetry.identifyUser).not.toHaveBeenCalled(); + expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected"); + }); + it("preserves an unknown/foreign top-level key across a successful re-login", async () => { // Cross-CLI invariant end-to-end: a key heygen-cli (or a future // version) wrote must survive a hyperframes-cli login round-trip. diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 5c68be654b..8f01a9de08 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -99,7 +99,7 @@ async function runOAuthLogin(): Promise { // fallow-ignore-next-line complexity async function reportIdentity(): Promise { - const { trackAuthLoginCompleted, trackAuthLoginFailed } = + const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } = await import("../../telemetry/index.js"); const credential = await tryResolveCredential(); if (!credential) { @@ -107,10 +107,6 @@ async function reportIdentity(): Promise { console.error(c.warn("Sign-in completed but no credential was persisted.")); process.exit(1); } - // A resolvable credential IS the success signal: the tokens are on disk and - // usable. The `/v3/users/me` probe below only fetches a display name, so its - // outcome is cosmetic and does not gate completion. - trackAuthLoginCompleted("oauth"); // Wire the refresh hook here too — a freshly-minted token shouldn't // need it, but a fast IdP-side rotation (or a misconfigured short // TTL) shouldn't punish the user with a hard failure when the @@ -124,20 +120,40 @@ async function reportIdentity(): Promise { // `auth status` can show "Logged in as ..." without re-hitting // /v3/users/me. Best-effort — a persist failure never fails the login. await persistUserInfo(user); + // 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 identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)"; console.log(c.success(`✓ Signed in as ${identity}.`)); } catch (err) { // Don't roll back — the OAuth tokens are valid on disk; this is a - // transient verify-side issue. The identity probe failed, so any - // stale user block from a prior login (possibly a DIFFERENT account) - // is cleared so `auth status` can't surface the wrong identity. + // transient verify-side issue. The credential is persisted and usable, so + // the sign-in still COMPLETED; we just have no resolved identity to + // attribute it to. The stale user block from a prior login (possibly a + // DIFFERENT account) is cleared so `auth status` can't surface it. await clearUserInfoBestEffort(); + trackAuthLoginCompleted("oauth"); console.error( c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`), ); } } +/** + * 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. Flip the order here to prefer the less + * identifying username; the privacy notice (showTelemetryNotice) and + * docs/packages/cli.mdx must match 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 = {}; @@ -179,7 +195,7 @@ async function clearUserInfoBestEffort(): Promise { // fallow-ignore-next-line complexity async function runApiKeyLogin(inlineKey: string): Promise { - const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed } = + const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } = await import("../../telemetry/index.js"); trackAuthLoginStarted("api_key"); @@ -218,13 +234,15 @@ async function runApiKeyLogin(inlineKey: string): Promise { const next: Credentials = { ...previous, api_key: key }; await writeStore(next); - const verifyOk = await verifyAndReport(key); - if (!verifyOk) { + const user = await verifyAndReport(key); + if (!user) { trackAuthLoginFailed("api_key", "rejected"); await rollback(previous); process.exit(1); } - trackAuthLoginCompleted("api_key"); + const id = identityKey(user); + if (id) identifyUser(id); + trackAuthLoginCompleted("api_key", id); } async function snapshotStore(): Promise { @@ -258,8 +276,11 @@ async function rollback(previous: Credentials): Promise { } } +// Returns the verified user on success (so the caller can attribute the +// completed sign-in to that identity), or null when the backend rejects the +// key. Other errors propagate. // fallow-ignore-next-line complexity -async function verifyAndReport(key: string): Promise { +async function verifyAndReport(key: string): Promise { const client = new AuthClient(); try { const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" }); @@ -268,7 +289,7 @@ async function verifyAndReport(key: string): Promise { await persistUserInfo(user); const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)"; console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`)); - return true; + return user; } catch (err) { if (isAuthError(err) && err.code === "UNAUTHENTICATED") { console.error( @@ -276,7 +297,7 @@ async function verifyAndReport(key: string): Promise { ` ${c.dim(err.message)}\n` + `Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`, ); - return false; + return null; } throw err; } diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index e8630a3c6d..7471d4dbab 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -191,7 +191,10 @@ export function showTelemetryNotice(): boolean { console.log(); console.log(` ${c.dim("Hyperframes collects anonymous usage data to improve the tool.")}`); - console.log(` ${c.dim("No personal info, file paths, or content is collected.")}`); + console.log(` ${c.dim("File paths and composition content are never collected.")}`); + console.log( + ` ${c.dim("If you sign in to HeyGen, your account email is linked to your usage.")}`, + ); console.log(); console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`); console.log(); diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 4a474ad196..5801d8f9be 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -5,6 +5,12 @@ vi.mock("./client.js", () => ({ trackEvent: (...args: unknown[]) => trackEvent(...args), })); +// identifyUser reads the install anonymousId; pin it so the $identify alias is +// deterministic and the test never touches disk. +vi.mock("./config.js", () => ({ + readConfig: () => ({ anonymousId: "anon-test-123", telemetryEnabled: true }), +})); + const { trackRenderComplete, trackRenderError, @@ -17,6 +23,7 @@ const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, + identifyUser, } = await import("./events.js"); describe("render telemetry events", () => { @@ -285,12 +292,26 @@ describe("auth login telemetry events", () => { ); }); - it("forwards an explicit distinctId to trackEvent for future user-level attribution", () => { - trackAuthLoginCompleted("oauth", "heygen-user-123"); + it("forwards an explicit distinctId to trackEvent for user-level attribution", () => { + trackAuthLoginCompleted("oauth", "alice@example.com"); expect(trackEvent).toHaveBeenCalledWith( "auth_login_completed", { method: "oauth" }, - "heygen-user-123", + "alice@example.com", + ); + }); + + it("identifyUser emits a $identify alias linking the anon install to the identity", () => { + identifyUser("alice@example.com"); + expect(trackEvent).toHaveBeenCalledWith( + "$identify", + { $anon_distinct_id: "anon-test-123" }, + "alice@example.com", ); }); + + it("identifyUser is a no-op when there is no identity to attach", () => { + identifyUser(""); + expect(trackEvent).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index fcf03cbb2a..d1917b3a4c 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -1,5 +1,6 @@ import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core"; import { trackEvent } from "./client.js"; +import { readConfig } from "./config.js"; export interface RenderObservabilityTelemetryPayload { observabilityRenderJobId?: string; @@ -381,6 +382,18 @@ export function trackAuthLoginFailed( trackEvent("auth_login_failed", { method, reason }, distinctId); } +// Associate this install with the signed-in HeyGen account after a completed +// sign-in. Emits a PostHog `$identify` alias whose `$anon_distinct_id` is the +// 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 { + if (!distinctId) return; + trackEvent("$identify", { $anon_distinct_id: readConfig().anonymousId }, distinctId); +} + // A render was rejected by the output-resolution/alpha/HDR pre-flight (P1-3) // before any browser/ffmpeg work. Counts the "caught early" saves on dashboard // 1783183, distinct from deep render failures. `kind` is the low-cardinality diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 854ee03ede..6d1a76e017 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -12,5 +12,6 @@ export { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, + identifyUser, } from "./events.js"; export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js"; From cd02297d531665f6def526a7b833239dffc52cbd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 02:13:09 -0400 Subject: [PATCH 2/2] docs(cli): disclose the username identity fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review gating item: identityKey is `email ?? username`, but the first-run notice and cli.mdx said only "email", so an emailless account's username would reach PostHog undisclosed. `/v3/users/me` treats email as optional (pickString), so the fallback is live code, not dead — disclose it rather than assert an unverifiable email guarantee. Both surfaces now say "email, or username if the account has no email". Also soften the identityKey comment: it implied username is "less identifying", but HeyGen usernames are often email-shaped, so the note now states username is a fallback, not a privacy win. --- docs/packages/cli.mdx | 2 +- packages/cli/src/commands/auth/login.ts | 8 +++++--- packages/cli/src/telemetry/client.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index e996c747f3..bd48396259 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -907,7 +907,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o npx hyperframes telemetry status ``` - Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, or environment variable values. It collects no personally identifiable information until you sign in: when you authenticate with `hyperframes auth login`, your HeyGen account email is linked to your usage so CLI activity can be associated with your account (and your prior anonymous usage is stitched to it). Nothing else personal is collected, and this only happens after you choose to sign in. Disable all telemetry with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. + Telemetry collects command names, render performance, render checkpoint/error names, aggregate browser diagnostic counts, browser initialization duration and tween count, aggregate video extraction workload counts (such as extracted frame count and VFR preflight count), example choices, and system info — including a coarse environment fingerprint (OS, kernel string, CPU/memory shape, sandbox runtime such as gVisor or Docker, and the *name* of a coding agent driving the CLI when one is detected, e.g. `claude_code` / `codex` / `cursor`). The agent name is derived from the existence of well-known environment variables; their values are never read. Telemetry redacts local paths and URL query strings from render error/checkpoint messages and does **not** collect project names, video content, or environment variable values. It collects no personally identifiable information until you sign in: when you authenticate with `hyperframes auth login`, your HeyGen account email (or your username, if your account has no email) is linked to your usage so CLI activity can be associated with your account (and your prior anonymous usage is stitched to it). Nothing else personal is collected, and this only happens after you choose to sign in. Disable all telemetry with `HYPERFRAMES_NO_TELEMETRY=1` or the command above. See [Feedback Collection](/guides/feedback) for how the periodic post-render prompt and Studio feedback bar work, what data they collect, and how to opt out. diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8f01a9de08..907f126baa 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -146,9 +146,11 @@ async function reportIdentity(): Promise { * 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. Flip the order here to prefer the less - * identifying username; the privacy notice (showTelemetryNotice) and - * docs/packages/cli.mdx must match whatever this returns. + * 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; diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 7471d4dbab..3ca96a4376 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -193,7 +193,7 @@ export function showTelemetryNotice(): boolean { console.log(` ${c.dim("Hyperframes collects anonymous usage data to improve the tool.")}`); console.log(` ${c.dim("File paths and composition content are never collected.")}`); console.log( - ` ${c.dim("If you sign in to HeyGen, your account email is linked to your usage.")}`, + ` ${c.dim("If you sign in to HeyGen, your account (email, or username) is linked to your usage.")}`, ); console.log(); console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`);