diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 46bf6944f..b9fb313a1 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,10 +1,12 @@ import { defineCommand } from "citty"; import { execSync } from "node:child_process"; +import { freemem, platform } from "node:os"; import { c } from "../ui/colors.js"; import { findBrowser } from "../browser/manager.js"; import { findFFmpeg } from "../browser/ffmpeg.js"; import { VERSION } from "../version.js"; import { getUpdateMeta } from "../utils/updateCheck.js"; +import { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "../telemetry/system.js"; interface Check { name: string; @@ -102,6 +104,77 @@ function checkNode(): CheckResult { return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` }; } +// ── Hardware & Environment Checks ────────────────────────────────────────── + +function checkCPU(): CheckResult { + const sys = getSystemMeta(); + const model = sys.cpu_model ?? "Unknown"; + const speedStr = sys.cpu_speed ? ` @ ${sys.cpu_speed}MHz` : ""; + return { ok: true, detail: `${sys.cpu_count} cores \u00B7 ${model}${speedStr}` }; +} + +function checkMemory(): CheckResult { + const sys = getSystemMeta(); + const freeMb = bytesToMb(freemem()); // fresh reading, not cached + const totalGb = (sys.memory_total_mb / 1024).toFixed(1); + const freeGb = (freeMb / 1024).toFixed(1); + + if (freeMb < 2048) { + return { + ok: false, + detail: `${totalGb} GB total \u00B7 ${freeGb} GB free`, + hint: "Low memory — renders may fail. Close other apps or increase RAM.", + }; + } + return { ok: true, detail: `${totalGb} GB total \u00B7 ${freeGb} GB free` }; +} + +function checkShm(): CheckResult { + const shmMb = getShmSizeMb(); + if (shmMb === null) { + return { ok: true, detail: "N/A (non-Linux)" }; + } + // Docker default is 64MB which causes Chrome crashes + if (shmMb < 256) { + return { + ok: false, + detail: `${shmMb} MB`, + hint: "Chrome needs \u2265256 MB. Use: docker run --shm-size=512m", + }; + } + return { ok: true, detail: `${shmMb} MB` }; +} + +function checkDisk(): CheckResult { + const freeMb = getFreeDiskMb("."); + if (freeMb === null) { + return { ok: true, detail: "Unable to check" }; + } + const freeGb = (freeMb / 1024).toFixed(1); + if (freeMb < 1024) { + return { + ok: false, + detail: `${freeGb} GB free`, + hint: "Low disk space — renders produce large temp files.", + }; + } + return { ok: true, detail: `${freeGb} GB free` }; +} + +function checkEnvironment(): CheckResult { + const sys = getSystemMeta(); + const parts: string[] = []; + if (sys.is_docker) parts.push("Docker"); + if (sys.is_wsl) parts.push("WSL"); + if (sys.is_ci) parts.push(`CI (${sys.ci_name ?? "detected"})`); + if (!sys.is_tty) parts.push("non-TTY"); + + if (parts.length === 0) { + return { ok: true, detail: "Native terminal" }; + } + return { ok: true, detail: parts.join(" \u00B7 ") }; +} + export default defineCommand({ meta: { name: "doctor", description: "Check system dependencies and environment" }, args: {}, @@ -113,12 +186,24 @@ export default defineCommand({ const checks: Check[] = [ { name: "Version", run: checkVersion }, { name: "Node.js", run: checkNode }, + { name: "CPU", run: checkCPU }, + { name: "Memory", run: checkMemory }, + { name: "Disk", run: checkDisk }, + ]; + + // /dev/shm is only relevant on Linux (especially Docker) + if (platform() === "linux") { + checks.push({ name: "/dev/shm", run: checkShm }); + } + + checks.push( + { name: "Environment", run: checkEnvironment }, { name: "FFmpeg", run: checkFFmpeg }, { name: "FFprobe", run: checkFFprobe }, { name: "Chrome", run: checkChrome }, { name: "Docker", run: checkDocker }, { name: "Docker running", run: checkDockerRunning }, - ]; + ); let allOk = true; @@ -139,7 +224,7 @@ export default defineCommand({ if (allOk) { console.log(` ${c.success("\u25C7")} ${c.success("All checks passed")}`); } else { - console.log(` ${c.warn("\u25C7")} ${c.warn("Some checks failed — see hints above")}`); + console.log(` ${c.warn("\u25C7")} ${c.warn("Some checks failed \u2014 see hints above")}`); } console.log(); }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index a7aea8f8e..d31847598 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, mkdirSync, statSync } from "node:fs"; -import { cpus } from "node:os"; +import { cpus, freemem } from "node:os"; import { resolve, dirname, join } from "node:path"; import { resolveProject } from "../utils/project.js"; import { loadProducer } from "../utils/producer.js"; @@ -8,6 +8,8 @@ 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"; +import { bytesToMb } from "../telemetry/system.js"; +import type { RenderJob } from "@hyperframes/producer"; const VALID_FPS = new Set([24, 30, 60]); const VALID_QUALITY = new Set(["draft", "standard", "high"]); @@ -230,8 +232,9 @@ async function renderDocker( const producer = await loadProducer(); const startTime = Date.now(); + let job: RenderJob; try { - const job = producer.createRenderJob({ + job = producer.createRenderJob({ fps: options.fps, quality: options.quality, format: options.format, @@ -240,25 +243,11 @@ 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, "Check Docker is running: docker info"); - process.exit(1); + handleRenderError(error, options, startTime, true, "Check Docker is running: docker info"); } const elapsed = Date.now() - startTime; - trackRenderComplete({ - durationMs: elapsed, - fps: options.fps, - quality: options.quality, - workers: options.workers, - docker: true, - gpu: options.gpu, - }); + trackRenderMetrics(job, elapsed, options, true); printRenderComplete(outputPath, elapsed, options.quiet); } @@ -295,26 +284,78 @@ 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); + handleRenderError(error, options, startTime, false, "Try --docker for containerized rendering"); } const elapsed = Date.now() - startTime; + trackRenderMetrics(job, elapsed, options, false); + printRenderComplete(outputPath, elapsed, options.quiet); +} + +function getMemorySnapshot() { + return { + peakMemoryMb: bytesToMb(process.memoryUsage.rss()), + memoryFreeMb: bytesToMb(freemem()), + }; +} + +function handleRenderError( + error: unknown, + options: RenderOptions, + startTime: number, + docker: boolean, + hint: string, +): never { + const message = error instanceof Error ? error.message : String(error); + trackRenderError({ + fps: options.fps, + quality: options.quality, + docker, + workers: options.workers, + gpu: options.gpu, + elapsedMs: Date.now() - startTime, + errorMessage: message, + ...getMemorySnapshot(), + }); + errorBox("Render failed", message, hint); + process.exit(1); +} + +/** + * Extract rich metrics from the completed render job and send to telemetry. + * speed_ratio = composition_duration / render_time — higher is better, >1 means faster than realtime. + */ +function trackRenderMetrics( + job: RenderJob, + elapsedMs: number, + options: RenderOptions, + docker: boolean, +): void { + const perf = job.perfSummary; + const compositionDurationMs = perf + ? Math.round(perf.compositionDurationSeconds * 1000) + : undefined; + const speedRatio = + compositionDurationMs && compositionDurationMs > 0 && elapsedMs > 0 + ? Math.round((compositionDurationMs / elapsedMs) * 100) / 100 + : undefined; + trackRenderComplete({ - durationMs: elapsed, + durationMs: elapsedMs, fps: options.fps, quality: options.quality, workers: options.workers, - docker: false, + docker, gpu: options.gpu, + compositionDurationMs, + compositionWidth: perf?.resolution.width, + compositionHeight: perf?.resolution.height, + totalFrames: perf?.totalFrames, + speedRatio, + captureAvgMs: perf?.captureAvgMs, + capturePeakMs: perf?.capturePeakMs, + ...getMemorySnapshot(), }); - printRenderComplete(outputPath, elapsed, options.quiet); } function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void { diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 33025edbf..c784e4787 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -356,7 +356,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // Continue without — acquireBrowser will try its own resolution } + const { trackRenderComplete } = await import("../telemetry/events.js"); + const { bytesToMb } = await import("../telemetry/system.js"); + const { freemem } = await import("node:os"); + const job = createRenderJob({ fps: 30, quality: "standard" }); + const startTime = Date.now(); const onProgress = (j: { progress: number }) => { const entry = renderJobs.get(jobId); if (entry) entry.progress = j.progress; @@ -367,7 +372,48 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { entry.status = "complete"; entry.progress = 100; } + + const elapsed = Date.now() - startTime; + const perf = job.perfSummary; + const compositionDurationMs = perf + ? Math.round(perf.compositionDurationSeconds * 1000) + : undefined; + trackRenderComplete({ + durationMs: elapsed, + fps: 30, + quality: "standard", + workers: perf?.workers ?? 1, + docker: false, + gpu: false, + compositionDurationMs, + compositionWidth: perf?.resolution.width, + compositionHeight: perf?.resolution.height, + totalFrames: perf?.totalFrames, + speedRatio: + compositionDurationMs && compositionDurationMs > 0 && elapsed > 0 + ? Math.round((compositionDurationMs / elapsed) * 100) / 100 + : undefined, + captureAvgMs: perf?.captureAvgMs, + capturePeakMs: perf?.capturePeakMs, + peakMemoryMb: bytesToMb(process.memoryUsage.rss()), + memoryFreeMb: bytesToMb(freemem()), + }); } catch (err) { + try { + const { trackRenderError } = await import("../telemetry/events.js"); + const { bytesToMb } = await import("../telemetry/system.js"); + const { freemem } = await import("node:os"); + trackRenderError({ + fps: 30, + quality: "standard", + docker: false, + errorMessage: err instanceof Error ? err.message : String(err), + peakMemoryMb: bytesToMb(process.memoryUsage.rss()), + memoryFreeMb: bytesToMb(freemem()), + }); + } catch { + // Telemetry must never break the studio + } const entry = renderJobs.get(jobId); if (entry) { entry.status = "failed"; diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 015966f58..3518544c9 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -2,6 +2,7 @@ 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. @@ -66,6 +67,7 @@ export function shouldTrack(): boolean { export function trackEvent(event: string, properties: EventProperties = {}): void { if (!shouldTrack()) return; + const sys = getSystemMeta(); eventQueue.push({ event, properties: { @@ -74,6 +76,16 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi 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, }, timestamp: new Date().toISOString(), }); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 2bf498c8e..f7db477e9 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -11,6 +11,18 @@ export function trackRenderComplete(props: { workers: number; docker: boolean; gpu: boolean; + // Composition metadata + compositionDurationMs?: number; + compositionWidth?: number; + compositionHeight?: number; + totalFrames?: number; + // Processing efficiency + speedRatio?: number; + captureAvgMs?: number; + capturePeakMs?: number; + // Resource usage + peakMemoryMb?: number; + memoryFreeMb?: number; }): void { trackEvent("render_complete", { duration_ms: props.durationMs, @@ -19,14 +31,41 @@ export function trackRenderComplete(props: { workers: props.workers, docker: props.docker, gpu: props.gpu, + composition_duration_ms: props.compositionDurationMs, + composition_width: props.compositionWidth, + composition_height: props.compositionHeight, + total_frames: props.totalFrames, + speed_ratio: props.speedRatio, + capture_avg_ms: props.captureAvgMs, + capture_peak_ms: props.capturePeakMs, + peak_memory_mb: props.peakMemoryMb, + memory_free_mb: props.memoryFreeMb, }); } -export function trackRenderError(props: { fps: number; quality: string; docker: boolean }): void { +export function trackRenderError(props: { + fps: number; + quality: string; + docker: boolean; + workers?: number; + gpu?: boolean; + failedStage?: string; + errorMessage?: string; + elapsedMs?: number; + peakMemoryMb?: number; + memoryFreeMb?: number; +}): void { trackEvent("render_error", { fps: props.fps, quality: props.quality, docker: props.docker, + workers: props.workers, + gpu: props.gpu, + failed_stage: props.failedStage, + error_message: props.errorMessage, + elapsed_ms: props.elapsedMs, + peak_memory_mb: props.peakMemoryMb, + memory_free_mb: props.memoryFreeMb, }); } diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index f1c00b71b..f83e2efbf 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -7,3 +7,4 @@ export { trackInitTemplate, trackBrowserInstall, } from "./events.js"; +export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js"; diff --git a/packages/cli/src/telemetry/system.ts b/packages/cli/src/telemetry/system.ts new file mode 100644 index 000000000..3719533e1 --- /dev/null +++ b/packages/cli/src/telemetry/system.ts @@ -0,0 +1,139 @@ +import { cpus, totalmem, platform, release } from "node:os"; +import { existsSync, readFileSync, statfsSync } from "node:fs"; + +// --------------------------------------------------------------------------- +// System metadata collected once per CLI session and attached to all events. +// Follows the same patterns as Next.js, Turborepo, and Gatsby telemetry. +// No PII — only hardware/environment characteristics useful for debugging. +// --------------------------------------------------------------------------- + +/** Convert bytes to whole megabytes. */ +export function bytesToMb(bytes: number): number { + return Math.trunc(bytes / (1024 * 1024)); +} + +export interface SystemMeta { + os_release: string; + cpu_count: number; + cpu_model: string | null; + cpu_speed: number | null; + memory_total_mb: number; + is_docker: boolean; + is_ci: boolean; + ci_name: string | null; + is_wsl: boolean; + is_tty: boolean; +} + +let cached: SystemMeta | null = null; + +/** + * Collect system metadata. Cached after first call. + * Only includes static values — use `freemem()` directly for volatile readings. + */ +export function getSystemMeta(): SystemMeta { + if (cached) return cached; + + const cpuInfo = cpus(); + const firstCpu = cpuInfo[0] ?? null; + + cached = { + os_release: release(), + cpu_count: cpuInfo.length, + cpu_model: firstCpu?.model?.trim() ?? null, + cpu_speed: firstCpu?.speed ?? null, + memory_total_mb: bytesToMb(totalmem()), + is_docker: detectDocker(), + is_ci: detectCI(), + ci_name: getCIName(), + is_wsl: detectWSL(), + is_tty: Boolean(process.stdout?.isTTY), + }; + return cached; +} + +// --------------------------------------------------------------------------- +// Environment detectors +// --------------------------------------------------------------------------- + +function detectDocker(): boolean { + // Standard detection: /.dockerenv file or "docker" in /proc/1/cgroup + try { + if (existsSync("/.dockerenv")) return true; + if (platform() === "linux") { + const cgroup = readFileSync("/proc/1/cgroup", "utf-8"); + if (cgroup.includes("docker") || cgroup.includes("containerd")) return true; + } + } catch { + // Ignore — not in Docker + } + return false; +} + +function detectCI(): boolean { + return ( + process.env["CI"] === "true" || + process.env["CI"] === "1" || + process.env["CONTINUOUS_INTEGRATION"] === "true" || + process.env["GITHUB_ACTIONS"] === "true" || + process.env["GITLAB_CI"] === "true" || + process.env["CIRCLECI"] === "true" || + process.env["JENKINS_URL"] != null || + process.env["BUILDKITE"] === "true" || + process.env["TRAVIS"] === "true" || + false + ); +} + +function getCIName(): string | null { + if (process.env["GITHUB_ACTIONS"] === "true") return "github_actions"; + if (process.env["GITLAB_CI"] === "true") return "gitlab_ci"; + if (process.env["CIRCLECI"] === "true") return "circleci"; + if (process.env["JENKINS_URL"] != null) return "jenkins"; + if (process.env["BUILDKITE"] === "true") return "buildkite"; + if (process.env["TRAVIS"] === "true") return "travis"; + if (detectCI()) return "unknown"; + return null; +} + +function detectWSL(): boolean { + if (platform() !== "linux") return false; + try { + const osRelease = release().toLowerCase(); + if (osRelease.includes("microsoft") || osRelease.includes("wsl")) return true; + const procVersion = readFileSync("/proc/version", "utf-8").toLowerCase(); + return procVersion.includes("microsoft") || procVersion.includes("wsl"); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Extended hardware checks (for doctor command and detailed render events) +// --------------------------------------------------------------------------- + +/** + * Get /dev/shm size in MB (Linux only). Chrome uses shared memory heavily; + * Docker's default 64MB limit causes crashes. + */ +export function getShmSizeMb(): number | null { + if (platform() !== "linux") return null; + try { + const stats = statfsSync("/dev/shm"); + return bytesToMb(stats.bsize * stats.blocks); + } catch { + return null; + } +} + +/** + * Get available disk space in MB at a given path. + */ +export function getFreeDiskMb(path: string = "."): number | null { + try { + const stats = statfsSync(path); + return bytesToMb(stats.bsize * stats.bavail); + } catch { + return null; + } +}