mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): add opt-out anonymous telemetry via PostHog
Add anonymous usage telemetry to help improve the CLI. Uses PostHog's HTTP batch API directly (zero new dependencies) with a 5-second timeout and fail-silent behavior — telemetry never breaks the CLI. What's collected: command names, render performance (duration, fps, quality), template choices, OS/arch/Node version/CLI version. What's NOT collected: file paths, project names, video content, or any personally identifiable information. Telemetry is: - Disabled in dev mode (running via tsx) - Disabled in CI (CI=true) or via HYPERFRAMES_NO_TELEMETRY=1 - Disabled when API key is placeholder (safe to merge before key is set) - Controllable via `hyperframes telemetry [enable|disable|status]` - Disclosed on first run with clear opt-out instructions Config stored at ~/.hyperframes/config.json (0600 permissions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,35 @@
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { VERSION } from "./version.js";
|
||||
import { showTelemetryNotice, flush, trackCommand, incrementCommandCount } from "./telemetry/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry — detect command from argv, track it, flush on exit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_COMMANDS = new Set([
|
||||
"init", "dev", "render", "lint", "info", "compositions",
|
||||
"benchmark", "browser", "docs", "doctor", "upgrade", "telemetry",
|
||||
]);
|
||||
|
||||
const commandArg = process.argv[2];
|
||||
const command = commandArg && KNOWN_COMMANDS.has(commandArg) ? commandArg : "unknown";
|
||||
|
||||
// Show first-run notice (no-ops if already shown or telemetry disabled)
|
||||
if (command !== "telemetry") {
|
||||
showTelemetryNotice();
|
||||
trackCommand(command);
|
||||
incrementCommandCount();
|
||||
}
|
||||
|
||||
// Flush telemetry events before exit — non-blocking, 5s timeout
|
||||
process.on("beforeExit", () => {
|
||||
flush().catch(() => {});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
@@ -21,6 +50,7 @@ const main = defineCommand({
|
||||
docs: () => import("./commands/docs.js").then((m) => m.default),
|
||||
doctor: () => import("./commands/doctor.js").then((m) => m.default),
|
||||
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
|
||||
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CHROME_VERSION,
|
||||
CACHE_DIR,
|
||||
} from "../browser/manager.js";
|
||||
import { trackBrowserInstall } from "../telemetry/events.js";
|
||||
|
||||
async function runEnsure(): Promise<void> {
|
||||
clack.intro(c.bold("hyperframes browser ensure"));
|
||||
@@ -47,6 +48,7 @@ async function runEnsure(): Promise<void> {
|
||||
});
|
||||
|
||||
downloadSpinner.stop(c.success("Download complete"));
|
||||
trackBrowserInstall(true);
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { execSync, execFileSync, spawn } from "node:child_process";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { TEMPLATES, type TemplateId } from "../templates/generators.js";
|
||||
import { trackInitTemplate } from "../telemetry/events.js";
|
||||
|
||||
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
|
||||
|
||||
@@ -380,6 +381,7 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
scaffoldProject(destDir, basename(destDir), templateId, localVideoName);
|
||||
trackInitTemplate(templateId);
|
||||
|
||||
console.log(c.success(`\nCreated ${c.accent(name + "/")}`));
|
||||
for (const f of readdirSync(destDir)) {
|
||||
@@ -498,6 +500,7 @@ export default defineCommand({
|
||||
const templateId: TemplateId = templateResult;
|
||||
|
||||
// 4. Copy template and patch
|
||||
trackInitTemplate(templateId);
|
||||
scaffoldProject(destDir, name, templateId, localVideoName);
|
||||
|
||||
const files = readdirSync(destDir);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadProducer } from "../utils/producer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
|
||||
import { renderProgress } from "../ui/progress.js";
|
||||
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";
|
||||
|
||||
const VALID_FPS = new Set([24, 30, 60]);
|
||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||
@@ -158,12 +159,21 @@ async function renderDocker(
|
||||
});
|
||||
await producer.executeRenderJob(job, projectDir, outputPath);
|
||||
} catch (error: unknown) {
|
||||
trackRenderError({ fps: options.fps, quality: options.quality, docker: true });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorBox("Render failed", message, "Try --docker for containerized rendering");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
trackRenderComplete({
|
||||
durationMs: elapsed,
|
||||
fps: options.fps,
|
||||
quality: options.quality,
|
||||
workers: options.workers ?? 4,
|
||||
docker: true,
|
||||
gpu: options.gpu,
|
||||
});
|
||||
printRenderComplete(outputPath, elapsed, options.quiet);
|
||||
}
|
||||
|
||||
@@ -191,12 +201,21 @@ async function renderLocal(
|
||||
try {
|
||||
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
} catch (error: unknown) {
|
||||
trackRenderError({ fps: options.fps, quality: options.quality, docker: false });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorBox("Render failed", message, "Try --docker for containerized rendering");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
trackRenderComplete({
|
||||
durationMs: elapsed,
|
||||
fps: options.fps,
|
||||
quality: options.quality,
|
||||
workers: options.workers ?? 4,
|
||||
docker: false,
|
||||
gpu: options.gpu,
|
||||
});
|
||||
printRenderComplete(outputPath, elapsed, options.quiet);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { readConfig, writeConfig, CONFIG_PATH } from "../telemetry/config.js";
|
||||
|
||||
function runEnable(): void {
|
||||
const config = readConfig();
|
||||
config.telemetryEnabled = true;
|
||||
writeConfig(config);
|
||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.success("enabled")}\n`);
|
||||
}
|
||||
|
||||
function runDisable(): void {
|
||||
const config = readConfig();
|
||||
config.telemetryEnabled = false;
|
||||
writeConfig(config);
|
||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.bold("disabled")}\n`);
|
||||
}
|
||||
|
||||
function runStatus(): void {
|
||||
const config = readConfig();
|
||||
const status = config.telemetryEnabled ? c.success("enabled") : c.dim("disabled");
|
||||
console.log();
|
||||
console.log(` ${c.dim("Status:")} ${status}`);
|
||||
console.log(` ${c.dim("Config:")} ${c.accent(CONFIG_PATH)}`);
|
||||
console.log(` ${c.dim("Commands:")} ${c.bold(String(config.commandCount))}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Disable:")} ${c.accent("hyperframes telemetry disable")}`);
|
||||
console.log(` ${c.dim("Env var:")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "telemetry", description: "Manage anonymous usage telemetry" },
|
||||
args: {
|
||||
subcommand: {
|
||||
type: "positional",
|
||||
description: "Subcommand: enable, disable, status",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const subcommand = args.subcommand;
|
||||
|
||||
if (!subcommand || subcommand === "") {
|
||||
console.log(`
|
||||
${c.bold("hyperframes telemetry")} ${c.dim("<subcommand>")}
|
||||
|
||||
Manage anonymous usage data collection.
|
||||
|
||||
${c.bold("SUBCOMMANDS:")}
|
||||
${c.accent("status")} ${c.dim("Show current telemetry status")}
|
||||
${c.accent("enable")} ${c.dim("Enable anonymous telemetry")}
|
||||
${c.accent("disable")} ${c.dim("Disable anonymous telemetry")}
|
||||
|
||||
${c.bold("WHAT WE COLLECT:")}
|
||||
${c.dim("\u2022")} Command names (init, render, dev, etc.)
|
||||
${c.dim("\u2022")} Render performance (duration, fps, quality)
|
||||
${c.dim("\u2022")} Template choices
|
||||
${c.dim("\u2022")} OS, architecture, Node.js version, CLI version
|
||||
|
||||
${c.bold("WHAT WE DON'T COLLECT:")}
|
||||
${c.dim("\u2022")} File paths, project names, or video content
|
||||
${c.dim("\u2022")} IP addresses (discarded by our analytics provider)
|
||||
${c.dim("\u2022")} Any personally identifiable information
|
||||
|
||||
${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("to disable.")}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subcommand) {
|
||||
case "enable":
|
||||
return runEnable();
|
||||
case "disable":
|
||||
return runDisable();
|
||||
case "status":
|
||||
return runStatus();
|
||||
default:
|
||||
console.error(
|
||||
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes telemetry --help")} for usage.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { readConfig, writeConfig } from "./config.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostHog configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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 = "__POSTHOG_API_KEY__";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const FLUSH_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev mode detection — telemetry is disabled when running from source (tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isDevMode(): boolean {
|
||||
// In dev: files are .ts (running via tsx). In production: bundled .js
|
||||
try {
|
||||
const url = new URL(import.meta.url);
|
||||
return url.pathname.endsWith(".ts");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight PostHog client — we use the HTTP 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 | undefined;
|
||||
}
|
||||
|
||||
let eventQueue: Array<{
|
||||
event: string;
|
||||
properties: EventProperties;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
let isEnabled: boolean | null = null;
|
||||
let anonymousId: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if telemetry should be active.
|
||||
* Disabled when: dev mode, user opted out, CI environment, or HYPERFRAMES_NO_TELEMETRY set.
|
||||
*/
|
||||
function shouldTrack(): boolean {
|
||||
if (isEnabled !== null) return isEnabled;
|
||||
|
||||
// Environment overrides
|
||||
if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") {
|
||||
isEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// CI detection
|
||||
if (process.env["CI"] === "true" || process.env["CI"] === "1") {
|
||||
isEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dev mode — never phone home during development
|
||||
if (isDevMode()) {
|
||||
isEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Placeholder API key means it hasn't been configured yet
|
||||
if (POSTHOG_API_KEY === "__POSTHOG_API_KEY__") {
|
||||
isEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
isEnabled = config.telemetryEnabled;
|
||||
anonymousId = config.anonymousId;
|
||||
return isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a telemetry event. Non-blocking, fail-silent.
|
||||
*/
|
||||
export function trackEvent(event: string, properties: EventProperties = {}): void {
|
||||
if (!shouldTrack()) return;
|
||||
|
||||
if (!anonymousId) {
|
||||
const config = readConfig();
|
||||
anonymousId = config.anonymousId;
|
||||
}
|
||||
|
||||
eventQueue.push({
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
cli_version: VERSION,
|
||||
os: process.platform,
|
||||
arch: process.arch,
|
||||
node_version: process.version,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all queued events to PostHog. Called on process exit.
|
||||
* Uses the /batch endpoint for efficiency.
|
||||
*/
|
||||
export async function flush(): Promise<void> {
|
||||
if (eventQueue.length === 0 || !shouldTrack()) {
|
||||
eventQueue = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = eventQueue.map((e) => ({
|
||||
event: e.event,
|
||||
properties: e.properties,
|
||||
distinct_id: anonymousId,
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
eventQueue = [];
|
||||
|
||||
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" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — telemetry must never break the CLI
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the first-run telemetry notice if it hasn't been shown yet.
|
||||
* Returns true if the notice was shown (so callers can add spacing).
|
||||
*/
|
||||
export function showTelemetryNotice(): boolean {
|
||||
if (!shouldTrack()) return false;
|
||||
|
||||
const config = readConfig();
|
||||
if (config.telemetryNoticeShown) return false;
|
||||
|
||||
// Dynamic import to avoid pulling colors into the check path
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
||||
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
||||
|
||||
console.log();
|
||||
console.log(` ${dim("Hyperframes collects anonymous usage data to improve the tool.")}`);
|
||||
console.log(` ${dim("No personal info, file paths, or content is collected.")}`);
|
||||
console.log();
|
||||
console.log(` ${dim("Disable anytime:")} ${cyan("hyperframes telemetry disable")}`);
|
||||
console.log();
|
||||
|
||||
config.telemetryNoticeShown = true;
|
||||
writeConfig(config);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config directory: ~/.hyperframes/
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
|
||||
export interface HyperframesConfig {
|
||||
/** Whether anonymous telemetry is enabled (default: true in production) */
|
||||
telemetryEnabled: boolean;
|
||||
/** Stable anonymous identifier — no PII, just a random UUID */
|
||||
anonymousId: string;
|
||||
/** Whether the first-run telemetry notice has been shown */
|
||||
telemetryNoticeShown: boolean;
|
||||
/** Total CLI command invocations (for engagement prompts) */
|
||||
commandCount: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: HyperframesConfig = {
|
||||
telemetryEnabled: true,
|
||||
anonymousId: "",
|
||||
telemetryNoticeShown: false,
|
||||
commandCount: 0,
|
||||
};
|
||||
|
||||
let cachedConfig: HyperframesConfig | null = null;
|
||||
|
||||
/**
|
||||
* Read the config file, creating it with defaults if it doesn't exist.
|
||||
* Returns a mutable copy — call `writeConfig()` to persist changes.
|
||||
*/
|
||||
export function readConfig(): HyperframesConfig {
|
||||
if (cachedConfig) return { ...cachedConfig };
|
||||
|
||||
if (!existsSync(CONFIG_FILE)) {
|
||||
const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() };
|
||||
writeConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<HyperframesConfig>;
|
||||
|
||||
const config: HyperframesConfig = {
|
||||
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
|
||||
anonymousId: parsed.anonymousId || randomUUID(),
|
||||
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
|
||||
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
|
||||
};
|
||||
|
||||
cachedConfig = config;
|
||||
return { ...config };
|
||||
} catch {
|
||||
// Corrupted config — reset
|
||||
const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() };
|
||||
writeConfig(config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist config to disk. Updates the in-memory cache.
|
||||
*/
|
||||
export function writeConfig(config: HyperframesConfig): void {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
||||
cachedConfig = { ...config };
|
||||
} catch {
|
||||
// Non-fatal — telemetry should never break the CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the command counter and persist.
|
||||
*/
|
||||
export function incrementCommandCount(): number {
|
||||
const config = readConfig();
|
||||
config.commandCount++;
|
||||
writeConfig(config);
|
||||
return config.commandCount;
|
||||
}
|
||||
|
||||
/** Expose the config directory path for the telemetry command output */
|
||||
export const CONFIG_PATH = CONFIG_FILE;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { trackEvent } from "./client.js";
|
||||
|
||||
/**
|
||||
* Track a CLI command invocation.
|
||||
* This is the primary event — fired for every command.
|
||||
*/
|
||||
export function trackCommand(command: string): void {
|
||||
trackEvent("cli_command", { command });
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a successful render completion with performance metrics.
|
||||
*/
|
||||
export function trackRenderComplete(props: {
|
||||
durationMs: number;
|
||||
fps: number;
|
||||
quality: string;
|
||||
workers: number;
|
||||
docker: boolean;
|
||||
gpu: boolean;
|
||||
}): void {
|
||||
trackEvent("render_complete", {
|
||||
duration_ms: props.durationMs,
|
||||
fps: props.fps,
|
||||
quality: props.quality,
|
||||
workers: props.workers,
|
||||
docker: props.docker,
|
||||
gpu: props.gpu,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a render failure (error type only, no message/stack).
|
||||
*/
|
||||
export function trackRenderError(props: { fps: number; quality: string; docker: boolean }): void {
|
||||
trackEvent("render_error", {
|
||||
fps: props.fps,
|
||||
quality: props.quality,
|
||||
docker: props.docker,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track which template was chosen during init.
|
||||
*/
|
||||
export function trackInitTemplate(templateId: string): void {
|
||||
trackEvent("init_template", { template: templateId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Track browser download/ensure events.
|
||||
*/
|
||||
export function trackBrowserInstall(success: boolean): void {
|
||||
trackEvent("browser_install", { success });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { readConfig, writeConfig, incrementCommandCount, CONFIG_PATH } from "./config.js";
|
||||
export { trackEvent, flush, showTelemetryNotice } from "./client.js";
|
||||
export {
|
||||
trackCommand,
|
||||
trackRenderComplete,
|
||||
trackRenderError,
|
||||
trackInitTemplate,
|
||||
trackBrowserInstall,
|
||||
} from "./events.js";
|
||||
Reference in New Issue
Block a user