Files
hyperframes/packages/cli/src/server/studioRenderTelemetry.ts
T

168 lines
6.0 KiB
TypeScript

// ---------------------------------------------------------------------------
// Maps studio-triggered renders into the existing `render_complete` /
// `render_error` telemetry events with `source: "studio"`, so they land
// alongside CLI renders in one unified taxonomy.
//
// Kept in its own file so `studioServer.ts` only needs two function calls.
// ---------------------------------------------------------------------------
import { freemem } from "node:os";
import type { CanvasResolution, Fps } from "@hyperframes/core";
import { fpsToNumber } from "@hyperframes/core";
import type { RenderJob, RenderPerfSummary } from "@hyperframes/producer";
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";
import {
renderJobObservabilityTelemetryPayload,
renderObservabilityTelemetryPayload,
} from "../telemetry/renderObservability.js";
import { bytesToMb } from "../telemetry/system.js";
import type { RenderCpuTelemetryPayload } from "../telemetry/renderCpu.js";
export interface StudioRenderOpts {
fps: Fps;
quality: string;
format?: "mp4" | "webm" | "mov" | "gif";
/** Named canvas preset the Studio request asked for; undefined renders at the composition's native dimensions. */
outputResolution?: CanvasResolution;
useGpu?: boolean;
// Telemetry id of the browser user who triggered the render, so the render
// outcome joins their studio_session_start / studio_render_start events.
// Undefined for older studio clients → falls back to the install anonymousId.
distinctId?: string;
/**
* The browser profile that triggered this render has telemetry disabled.
*
* The CLI's own policy cannot see a localStorage opt-out or `DoNotTrack` in
* someone else's browser, so without this the server happily emitted
* render_complete / render_error for a user who had opted out — the events
* merely landed on the install id instead of theirs, which is worse, not
* better. Explicit `true` only: an old client sends nothing here, and that
* is not consent withdrawn.
*/
telemetryOptOut?: boolean;
}
type RenderCompleteProps = Parameters<typeof trackRenderComplete>[0];
function memSnapshot(): { peakMemoryMb: number; memoryFreeMb: number } {
return {
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
memoryFreeMb: bytesToMb(freemem()),
};
}
function stagesPayload(stages: Record<string, number>): Partial<RenderCompleteProps> {
return {
stageCompileMs: stages.compileMs,
stageVideoExtractMs: stages.videoExtractMs,
stageAudioProcessMs: stages.audioProcessMs,
stageCaptureMs: stages.captureMs,
stageCaptureSetupMs: stages.captureSetupMs,
stageCaptureFrameMs: stages.captureFrameMs,
stageEncodeMs: stages.encodeMs,
stageAssembleMs: stages.assembleMs,
};
}
function extractPayload(
extract: RenderPerfSummary["videoExtractBreakdown"],
): Partial<RenderCompleteProps> {
if (!extract) return {};
return {
extractResolveMs: extract.resolveMs,
extractHdrProbeMs: extract.hdrProbeMs,
extractHdrPreflightMs: extract.hdrPreflightMs,
extractHdrPreflightCount: extract.hdrPreflightCount,
extractVfrProbeMs: extract.vfrProbeMs,
extractVfrPreflightMs: extract.vfrPreflightMs,
extractVfrPreflightCount: extract.vfrPreflightCount,
extractPhase3Ms: extract.extractMs,
extractCacheHits: extract.cacheHits,
extractCacheMisses: extract.cacheMisses,
};
}
function perfPayload(
perf: RenderPerfSummary | undefined,
elapsedMs: number,
): Partial<RenderCompleteProps> {
if (!perf) return {};
const compositionDurationMs = Math.round(perf.compositionDurationSeconds * 1000);
const speedRatio =
compositionDurationMs > 0 && elapsedMs > 0
? Math.round((compositionDurationMs / elapsedMs) * 100) / 100
: undefined;
return {
workers: perf.workers,
compositionDurationMs,
compositionWidth: perf.resolution.width,
compositionHeight: perf.resolution.height,
totalFrames: perf.totalFrames,
speedRatio,
captureAvgMs: perf.captureAvgMs,
captureSeekAvgMs: perf.captureSeekAvgMs,
captureBeforeCaptureAvgMs: perf.captureBeforeCaptureAvgMs,
captureScreenshotAvgMs: perf.captureScreenshotAvgMs,
encoderWaitAvgMs: perf.encoderWaitAvgMs,
capturePeakMs: perf.capturePeakMs,
tmpPeakBytes: perf.tmpPeakBytes,
...stagesPayload(perf.stages),
...extractPayload(perf.videoExtractBreakdown),
...renderObservabilityTelemetryPayload(perf.observability),
};
}
export function emitStudioRenderError(
opts: StudioRenderOpts,
elapsedMs: number,
failedStage: string | undefined,
err: unknown,
job: RenderJob | undefined,
cpuUsage: RenderCpuTelemetryPayload = {},
): void {
// `workers` is intentionally omitted: studio renders don't accept a
// user-supplied worker count (the producer picks its default), so on early
// failures we genuinely don't know one. The CLI side has the value from
// `options.workers` even before `job.perfSummary` exists; studio doesn't.
if (opts.telemetryOptOut === true) return;
trackRenderError({
fps: fpsToNumber(opts.fps),
quality: opts.quality,
docker: false,
gpu: opts.useGpu === true,
source: "studio",
failedStage,
errorMessage: err instanceof Error ? err.message : String(err),
elapsedMs,
outputFormat: opts.format,
outputResolutionPreset: opts.outputResolution,
distinctId: opts.distinctId,
...renderJobObservabilityTelemetryPayload(job),
...memSnapshot(),
...cpuUsage,
});
}
export function emitStudioRenderComplete(
opts: StudioRenderOpts,
elapsedMs: number,
perf: RenderPerfSummary | undefined,
cpuUsage: RenderCpuTelemetryPayload = {},
): void {
if (opts.telemetryOptOut === true) return;
trackRenderComplete({
durationMs: elapsedMs,
fps: fpsToNumber(opts.fps),
quality: opts.quality,
docker: false,
gpu: opts.useGpu === true,
source: "studio",
outputFormat: opts.format,
outputResolutionPreset: opts.outputResolution,
distinctId: opts.distinctId,
...perfPayload(perf, elapsedMs),
...memSnapshot(),
...cpuUsage,
});
}