feat(cli): add system metrics to telemetry and expand doctor command (#110)

* feat(cli): add system metrics to telemetry and expand doctor command

Enrich render telemetry with device/environment metadata (CPU, memory,
OS, Docker/CI/WSL detection) following patterns from Next.js and
Turborepo. Add speed_ratio (render time / composition duration),
per-frame capture timing, and resource usage to render events.

Expand the doctor command with CPU, memory, disk, /dev/shm, and
environment checks to help debug rendering issues on user machines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): invert speed_ratio to match experiment-framework convention

composition_duration / render_time — higher is better, >1 means faster
than realtime. Matches magic_edit.render.speed_ratio in experiment-framework.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): wire errorMessage into render error telemetry

Address review feedback — the errorMessage field was declared in the
trackRenderError interface but never populated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add render telemetry to embedded studio server

Track render_complete and render_error from the studio's render API
endpoint (hyperframes dev). Uses dynamic imports so telemetry is
resolved at call time within the CLI package — no telemetry coupling
added to @hyperframes/studio or @hyperframes/producer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-03-27 20:53:06 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ce63160907
commit 865843ba9a
7 changed files with 395 additions and 32 deletions
+70 -29
View File
@@ -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 {