mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,6 @@ export {
|
||||
trackAuthLoginStarted,
|
||||
trackAuthLoginCompleted,
|
||||
trackAuthLoginFailed,
|
||||
identifyUser,
|
||||
} from "./events.js";
|
||||
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
|
||||
|
||||
Reference in New Issue
Block a user