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;
}