feat(cli): associate signed-in HeyGen account with telemetry (#2020)

* 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(cli): disclose the username identity fallback

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.
This commit is contained in:
Miguel Ángel
2026-07-07 02:23:54 -04:00
committed by GitHub
parent 229e88eac5
commit 3d8372f880
7 changed files with 121 additions and 20 deletions
@@ -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.
+38 -15
View File
@@ -99,7 +99,7 @@ async function runOAuthLogin(): Promise<void> {
// fallow-ignore-next-line complexity
async function reportIdentity(): Promise<void> {
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<void> {
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,42 @@ async function reportIdentity(): Promise<void> {
// `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 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 = {};
@@ -179,7 +197,7 @@ async function clearUserInfoBestEffort(): Promise<void> {
// fallow-ignore-next-line complexity
async function runApiKeyLogin(inlineKey: string): Promise<void> {
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed } =
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("api_key");
@@ -218,13 +236,15 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
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<Credentials> {
@@ -258,8 +278,11 @@ async function rollback(previous: Credentials): Promise<void> {
}
}
// 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<boolean> {
async function verifyAndReport(key: string): Promise<UserInfo | null> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
@@ -268,7 +291,7 @@ async function verifyAndReport(key: string): Promise<boolean> {
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 +299,7 @@ async function verifyAndReport(key: string): Promise<boolean> {
` ${c.dim(err.message)}\n` +
`Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`,
);
return false;
return null;
}
throw err;
}
+4 -1
View File
@@ -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, or username) is linked to your usage.")}`,
);
console.log();
console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`);
console.log();
+24 -3
View File
@@ -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();
});
});
+13
View File
@@ -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;
@@ -387,6 +388,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
+1
View File
@@ -12,5 +12,6 @@ export {
trackAuthLoginStarted,
trackAuthLoginCompleted,
trackAuthLoginFailed,
identifyUser,
} from "./events.js";
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";