// Opt-out usage tracking for media-use, sharing the hyperframes CLI/studio // identity (packages/cli/src/telemetry): the same install id from // ~/.hyperframes/config.json, plus a $identify to the HeyGen account on sign-in, // so a person is one PostHog profile across surfaces — not a fresh id per tool. // Not fully anonymous by design (it must dedupe): pseudonymous before sign-in, // account-linked after. Event PROPERTIES stay coarse — media TYPE, resolution // SOURCE, winning PROVIDER — never the intent text, file names, or paths. // // Same public PostHog project key as the CLI (a write-only ingestion key, safe // to ship), same opt-outs (DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI / dev), // and $ip:null so no IP is recorded. Fire-and-forget: telemetry never blocks a // resolve and never throws into it. import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; const POSTHOG_HOST = "https://us.i.posthog.com"; const TIMEOUT_MS = 1500; let identifiedAccount = false; let warnedNonDefaultHost = false; // Same CI/test signals the test suite itself sets (resolve.test.mjs's U7 test // sets NODE_ENV=test and clears CI to prove the interception seam works) — // reused here, not a new heuristic, so that deliberate test usage never // triggers the warning below. function isTestOrCiContext() { return ( process.env.CI === "true" || process.env.CI === "1" || process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" ); } // Test-only interception seam: a real HTTP destination a test can point at, // so a spawned-child test (resolve.test.mjs) can prove track() never reaches // production rather than trusting DO_NOT_TRACK alone (a future call site or // test could forget to set that env var). Falls back to the real production // host whenever unset — production behavior is unchanged. // // Safety net: if this ever leaks into a real user's shell, track() would // silently redirect to a likely-dead host and postBatch()'s catch{} would // swallow the failure with zero signal. Surface one stderr warning outside // test/CI contexts so a real user gets some indication instead of silence. function posthogHost() { const override = process.env.MEDIA_USE_TELEMETRY_HOST; if (override && !warnedNonDefaultHost && !isTestOrCiContext()) { warnedNonDefaultHost = true; console.error( `media-use: telemetry is redirected to a non-default host via MEDIA_USE_TELEMETRY_HOST (${override}) — unset it unless this is intentional.`, ); } return override || POSTHOG_HOST; } /** True when telemetry must NOT be sent (opt-out envs, CI, dev). */ export function optedOut() { return ( process.env.HYPERFRAMES_NO_TELEMETRY === "1" || process.env.DO_NOT_TRACK === "1" || process.env.CI === "true" || process.env.CI === "1" || process.env.NODE_ENV === "development" ); } // CLI + studio share one install identity in ~/.hyperframes/config.json // (packages/cli/src/telemetry/config.ts — same path, same `anonymousId` / // `telemetryNoticeShown` fields). Read and write that same file so media-use is // the same PostHog person and shows the notice once per person, not per tool. // Computed per call (not a module const) so it honors HOME at runtime — tests // sandbox HOME, and os.homedir() re-reads it each call. function sharedConfigPath() { return join(homedir(), ".hyperframes", "config.json"); } function readSharedConfig() { try { const file = sharedConfigPath(); if (existsSync(file)) { const parsed = JSON.parse(readFileSync(file, "utf8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; } } catch { // unreadable config → treat as empty; never throw } return {}; } function writeSharedConfig(config) { const dir = join(homedir(), ".hyperframes"); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n"); } // Adopt a pre-existing media-use-only id (~/.media/anon-id from before this // change) so upgraders keep their PostHog persona instead of resetting to a new // one — otherwise cross-surface continuity would start over on upgrade. function legacyMediaAnonId() { try { const file = join(homedir(), ".media", "anon-id"); if (existsSync(file)) { const id = readFileSync(file, "utf8").trim(); if (id) return id; } } catch { // ignore } return null; } // Stable per-machine id from the shared config; seeds it (adopting a legacy // media-use id when present) if absent. function anonymousId() { try { const config = readSharedConfig(); if (typeof config.anonymousId === "string" && config.anonymousId.trim()) { return config.anonymousId.trim(); } const id = legacyMediaAnonId() || randomUUID(); writeSharedConfig({ ...config, anonymousId: id }); return id; } catch { return "anon"; // best-effort; a shared bucket is fine if the fs is read-only } } function heygenAccountDistinctId() { const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials"); try { if (!existsSync(file)) return null; const raw = readFileSync(file, "utf8").trim(); if (!raw.startsWith("{")) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const user = parsed.user; if (!user || typeof user !== "object" || Array.isArray(user)) return null; const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username; // Lowercased so this joins with the CLI's own identify call regardless of // the account's stored email casing — two different-case distinct ids // would otherwise split one person across two PostHog profiles. return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null; } catch { return null; } } function showTelemetryNotice() { if (optedOut()) return; try { const config = readSharedConfig(); // Shared with the CLI (config.telemetryNoticeShown): shown once per person // across surfaces, not once per tool. if (config.telemetryNoticeShown === true) return; console.error( [ "media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.", "If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.", ].join("\n"), ); writeSharedConfig({ ...config, telemetryNoticeShown: true }); } catch { // notice is best-effort; never surface into the command } } async function postBatch(batch) { try { await fetch(`${posthogHost()}/batch/`, { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), signal: AbortSignal.timeout(TIMEOUT_MS), }); } catch { // telemetry is best-effort; never surface into the command } } async function postEvent(event, properties, distinctId) { await postBatch([ { event, properties: { ...properties, surface: "media-use", $ip: null }, distinct_id: distinctId, timestamp: new Date().toISOString(), }, ]); } async function identifyAccount(anonId) { if (optedOut() || identifiedAccount) return; const distinctId = heygenAccountDistinctId(); if (!distinctId) return; identifiedAccount = true; await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId); } /** * Fire-and-forget a single event to PostHog. Best-effort: awaited with a short * timeout so a short-lived script flushes before exit, but any failure (offline, * opted out) is swallowed. `properties` must be non-PII (no intent/paths). */ export async function track(event, properties = {}) { if (optedOut()) return; showTelemetryNotice(); const anonId = anonymousId(); await identifyAccount(anonId); await postEvent(event, properties, anonId); } export function __anonymousIdForTest() { return anonymousId(); } export function __resetTelemetryForTest() { identifiedAccount = false; warnedNonDefaultHost = false; }