mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
* 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.
204 lines
6.5 KiB
TypeScript
204 lines
6.5 KiB
TypeScript
import { readConfig, writeConfig } from "./config.js";
|
|
import { VERSION } from "../version.js";
|
|
import { c } from "../ui/colors.js";
|
|
import { isDevMode } from "../utils/env.js";
|
|
import { getSystemMeta } from "./system.js";
|
|
|
|
// This is a public project API key — safe to embed in client-side code.
|
|
// It only allows writing events, not reading data.
|
|
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
|
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
const FLUSH_TIMEOUT_MS = 5_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lightweight PostHog client — uses the HTTP batch API directly to avoid
|
|
// pulling in the full posthog-node SDK and its dependencies.
|
|
// All calls are fire-and-forget with a hard timeout.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface EventProperties {
|
|
[key: string]: string | number | boolean | null | undefined;
|
|
}
|
|
|
|
let eventQueue: Array<{
|
|
event: string;
|
|
properties: EventProperties;
|
|
timestamp: string;
|
|
// Override for the batch distinct_id. Defaults to the install's anonymousId.
|
|
// Used to attribute server-side studio renders to the browser user who
|
|
// triggered them, so the render funnel is joinable across processes.
|
|
distinctId?: string;
|
|
}> = [];
|
|
|
|
let telemetryEnabled: boolean | null = null;
|
|
|
|
/**
|
|
* Check if telemetry should be active.
|
|
* Disabled when: dev mode, user opted out, CI environment, or HYPERFRAMES_NO_TELEMETRY set.
|
|
*/
|
|
export function shouldTrack(): boolean {
|
|
if (telemetryEnabled !== null) return telemetryEnabled;
|
|
|
|
if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") {
|
|
telemetryEnabled = false;
|
|
return false;
|
|
}
|
|
|
|
if (isDevMode()) {
|
|
telemetryEnabled = false;
|
|
return false;
|
|
}
|
|
|
|
// Safety check: ensure the API key has been configured (phc_ prefix = valid PostHog key)
|
|
if (!POSTHOG_API_KEY.startsWith("phc_")) {
|
|
telemetryEnabled = false;
|
|
return false;
|
|
}
|
|
|
|
const config = readConfig();
|
|
telemetryEnabled = config.telemetryEnabled;
|
|
return telemetryEnabled;
|
|
}
|
|
|
|
/**
|
|
* Queue a telemetry event. Non-blocking, fail-silent.
|
|
*/
|
|
export function trackEvent(
|
|
event: string,
|
|
properties: EventProperties = {},
|
|
distinctId?: string,
|
|
): void {
|
|
if (!shouldTrack()) return;
|
|
|
|
const sys = getSystemMeta();
|
|
eventQueue.push({
|
|
event,
|
|
distinctId,
|
|
properties: {
|
|
...properties,
|
|
cli_version: VERSION,
|
|
os: process.platform,
|
|
arch: process.arch,
|
|
node_version: process.version,
|
|
os_release: sys.os_release,
|
|
cpu_count: sys.cpu_count,
|
|
cpu_model: sys.cpu_model ?? undefined,
|
|
cpu_speed: sys.cpu_speed ?? undefined,
|
|
memory_total_mb: sys.memory_total_mb,
|
|
is_docker: sys.is_docker,
|
|
is_ci: sys.is_ci,
|
|
ci_name: sys.ci_name ?? undefined,
|
|
is_wsl: sys.is_wsl,
|
|
is_tty: sys.is_tty,
|
|
sandbox_runtime: sys.sandbox_runtime ?? undefined,
|
|
agent_runtime: sys.agent_runtime ?? undefined,
|
|
// New-agent discovery signals — populated only when agent_runtime is null.
|
|
agent_hint: sys.agent_hint ?? undefined,
|
|
term_program: sys.term_program ?? undefined,
|
|
agent_env_hints: sys.agent_env_hints ?? undefined,
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drain the in-memory queue into a PostHog `/batch/` payload string.
|
|
* Returns null when there's nothing to send. Resets the queue as a side effect
|
|
* so callers can fire-and-forget the resulting payload.
|
|
*
|
|
* $ip:null tells PostHog not to record the request IP for any of these events.
|
|
* Server-side "Discard client IP data" is also enabled in project settings.
|
|
*/
|
|
function drainQueueToPayload(): string | null {
|
|
if (eventQueue.length === 0) return null;
|
|
const config = readConfig();
|
|
const batch = eventQueue.map((e) => ({
|
|
event: e.event,
|
|
properties: { ...e.properties, $ip: null },
|
|
distinct_id: e.distinctId ?? config.anonymousId,
|
|
timestamp: e.timestamp,
|
|
}));
|
|
eventQueue = [];
|
|
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
|
}
|
|
|
|
/**
|
|
* Flush all queued events to PostHog via async HTTP POST.
|
|
* Called before normal process exit via `beforeExit`.
|
|
*/
|
|
export async function flush(): Promise<void> {
|
|
const payload = drainQueueToPayload();
|
|
if (payload == null) return;
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
|
|
try {
|
|
await fetch(`${POSTHOG_HOST}/batch/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Connection: "close" },
|
|
body: payload,
|
|
signal: controller.signal,
|
|
});
|
|
} catch {
|
|
// Silently ignore — telemetry must never break the CLI
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget flush for use in the `exit` event handler.
|
|
* Spawns a detached child process that sends the HTTP request independently,
|
|
* so the parent process exits immediately without waiting.
|
|
*/
|
|
export function flushSync(): void {
|
|
const payload = drainQueueToPayload();
|
|
if (payload == null) return;
|
|
|
|
try {
|
|
const { spawn } = require("node:child_process") as typeof import("node:child_process");
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
`fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`,
|
|
],
|
|
{ detached: true, stdio: "ignore" },
|
|
);
|
|
// Let the parent exit without waiting for the child
|
|
child.unref();
|
|
} catch {
|
|
// Silently ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show the first-run telemetry notice if it hasn't been shown yet.
|
|
* Must be called BEFORE any tracking calls so the user sees the disclosure
|
|
* before any data is sent.
|
|
*/
|
|
export function showTelemetryNotice(): boolean {
|
|
if (!shouldTrack()) return false;
|
|
|
|
const config = readConfig();
|
|
if (config.telemetryNoticeShown) return false;
|
|
|
|
// Persist the notice flag first, before any tracking occurs,
|
|
// so the user is never tracked without having seen the disclosure.
|
|
config.telemetryNoticeShown = true;
|
|
writeConfig(config);
|
|
|
|
console.log();
|
|
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, or username) is linked to your usage.")}`,
|
|
);
|
|
console.log();
|
|
console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`);
|
|
console.log();
|
|
|
|
return true;
|
|
}
|