Files
hyperframes/packages/cli/src/server/studioRenderTelemetry.test.ts
T
James da38de1b12 test+fix(telemetry): address PR review — dev-mode gate, session-storage dedupe, payload tests
Addresses review comments on #982:

- studio shouldTrack(): adds VITE_HYPERFRAMES_NO_TELEMETRY (mirrors CLI's
  HYPERFRAMES_NO_TELEMETRY) and import.meta.env.DEV gates so dev / CI
  studio builds don't pollute production telemetry. shouldTrack() is now
  exported for testability.
- App.tsx session dedupe: moves the once-per-session check from a useRef
  (which resets on HMR / remount) to sessionStorage via new
  hasFiredSessionStart / markSessionStartFired helpers in config.ts.
- studioRenderTelemetry.ts: documents why `workers` is intentionally
  omitted from emitStudioRenderError (studio renders don't accept a
  user-supplied worker count, so early failures genuinely don't know one).
- client.ts flush(): documents fire-and-forget no-retry design so future
  hands don't accidentally add retry logic that double-counts.

Tests:
- studioRenderTelemetry.test.ts (8 tests): perfPayload mapping for every
  RenderPerfSummary field, undefined-perf path, missing-extract path,
  zero-elapsed edge case, error event shape.
- studio/telemetry/events.test.ts (4 tests): pin event names
  (studio_session_start, studio_render_start) and payload shape.
- studio/telemetry/client.test.ts (9 tests): shouldTrack() returns false
  for non-phc_ key, opt-out, doNotTrack, build-time env, vite dev mode;
  memoization.
2026-05-20 14:53:38 -04:00

176 lines
6.3 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import type { RenderPerfSummary } from "@hyperframes/producer";
// Mock `../telemetry/events.js` so we can capture trackRenderComplete /
// trackRenderError calls and verify the payload mapping without firing
// network requests.
const trackRenderComplete = vi.fn();
const trackRenderError = vi.fn();
vi.mock("../telemetry/events.js", () => ({
trackRenderComplete: (...args: unknown[]) => trackRenderComplete(...args),
trackRenderError: (...args: unknown[]) => trackRenderError(...args),
}));
// Imported after the mock is registered so the module picks up the mocked
// trackRenderComplete / trackRenderError.
const { emitStudioRenderComplete, emitStudioRenderError } =
await import("./studioRenderTelemetry.js");
const opts = {
fps: { num: 30, den: 1 } as const,
quality: "standard",
};
const fullPerf: RenderPerfSummary = {
renderId: "r-1",
totalElapsedMs: 5000,
fps: 30,
quality: "standard",
workers: 4,
chunkedEncode: false,
chunkSizeFrames: null,
compositionDurationSeconds: 10,
totalFrames: 300,
resolution: { width: 1920, height: 1080 },
videoCount: 1,
audioCount: 0,
stages: {
compileMs: 100,
videoExtractMs: 200,
audioProcessMs: 50,
captureMs: 4000,
encodeMs: 500,
assembleMs: 150,
},
videoExtractBreakdown: {
resolveMs: 10,
hdrProbeMs: 20,
hdrPreflightMs: 30,
hdrPreflightCount: 1,
vfrProbeMs: 40,
vfrPreflightMs: 50,
vfrPreflightCount: 2,
extractMs: 60,
cacheHits: 3,
cacheMisses: 4,
},
tmpPeakBytes: 1024,
captureAvgMs: 13,
capturePeakMs: 25,
};
describe("studioRenderTelemetry", () => {
beforeEach(() => {
trackRenderComplete.mockClear();
trackRenderError.mockClear();
});
describe("emitStudioRenderComplete", () => {
it("tags the event with source: 'studio' and fps as a number", () => {
emitStudioRenderComplete(opts, 5000, fullPerf);
expect(trackRenderComplete).toHaveBeenCalledOnce();
const payload = trackRenderComplete.mock.calls[0]![0];
expect(payload.source).toBe("studio");
expect(payload.fps).toBe(30);
expect(payload.quality).toBe("standard");
expect(payload.docker).toBe(false);
expect(payload.gpu).toBe(false);
});
it("maps every RenderPerfSummary field to the expected payload key", () => {
emitStudioRenderComplete(opts, 5000, fullPerf);
const p = trackRenderComplete.mock.calls[0]![0];
expect(p.durationMs).toBe(5000);
expect(p.workers).toBe(4);
expect(p.compositionDurationMs).toBe(10_000);
expect(p.compositionWidth).toBe(1920);
expect(p.compositionHeight).toBe(1080);
expect(p.totalFrames).toBe(300);
// speedRatio = compositionDurationMs / elapsedMs = 10000 / 5000 = 2
expect(p.speedRatio).toBe(2);
expect(p.captureAvgMs).toBe(13);
expect(p.capturePeakMs).toBe(25);
expect(p.tmpPeakBytes).toBe(1024);
// stages
expect(p.stageCompileMs).toBe(100);
expect(p.stageVideoExtractMs).toBe(200);
expect(p.stageAudioProcessMs).toBe(50);
expect(p.stageCaptureMs).toBe(4000);
expect(p.stageEncodeMs).toBe(500);
expect(p.stageAssembleMs).toBe(150);
// video-extract breakdown
expect(p.extractResolveMs).toBe(10);
expect(p.extractHdrProbeMs).toBe(20);
expect(p.extractHdrPreflightMs).toBe(30);
expect(p.extractHdrPreflightCount).toBe(1);
expect(p.extractVfrProbeMs).toBe(40);
expect(p.extractVfrPreflightMs).toBe(50);
expect(p.extractVfrPreflightCount).toBe(2);
// `extractMs` on RenderPerfSummary maps to `extractPhase3Ms` on the event
// (named for legacy reasons — see packages/cli/src/commands/render.ts).
expect(p.extractPhase3Ms).toBe(60);
expect(p.extractCacheHits).toBe(3);
expect(p.extractCacheMisses).toBe(4);
});
it("omits all perf-derived fields when perfSummary is undefined", () => {
emitStudioRenderComplete(opts, 5000, undefined);
const p = trackRenderComplete.mock.calls[0]![0];
// Identity fields still present
expect(p.source).toBe("studio");
expect(p.fps).toBe(30);
expect(p.durationMs).toBe(5000);
// Perf-derived fields all undefined
expect(p.workers).toBeUndefined();
expect(p.compositionDurationMs).toBeUndefined();
expect(p.totalFrames).toBeUndefined();
expect(p.speedRatio).toBeUndefined();
expect(p.stageCompileMs).toBeUndefined();
expect(p.extractResolveMs).toBeUndefined();
});
it("omits videoExtractBreakdown fields when only the breakdown is absent", () => {
const perfNoExtract: RenderPerfSummary = { ...fullPerf, videoExtractBreakdown: undefined };
emitStudioRenderComplete(opts, 5000, perfNoExtract);
const p = trackRenderComplete.mock.calls[0]![0];
expect(p.workers).toBe(4);
expect(p.extractResolveMs).toBeUndefined();
expect(p.extractCacheHits).toBeUndefined();
});
it("leaves speedRatio undefined when elapsedMs is zero", () => {
emitStudioRenderComplete(opts, 0, fullPerf);
const p = trackRenderComplete.mock.calls[0]![0];
expect(p.speedRatio).toBeUndefined();
});
});
describe("emitStudioRenderError", () => {
it("tags with source: 'studio' and forwards failedStage + elapsedMs", () => {
emitStudioRenderError(opts, 1200, "encode", new Error("boom"));
expect(trackRenderError).toHaveBeenCalledOnce();
const p = trackRenderError.mock.calls[0]![0];
expect(p.source).toBe("studio");
expect(p.fps).toBe(30);
expect(p.quality).toBe("standard");
expect(p.docker).toBe(false);
expect(p.failedStage).toBe("encode");
expect(p.elapsedMs).toBe(1200);
expect(p.errorMessage).toBe("boom");
});
it("stringifies non-Error throwables", () => {
emitStudioRenderError(opts, 100, undefined, "string error");
expect(trackRenderError.mock.calls[0]![0].errorMessage).toBe("string error");
});
it("does not include a workers field on the error event payload", () => {
// Documented behavior: studio renders don't request a worker count,
// and the early-failure path doesn't have perfSummary to read it from.
emitStudioRenderError(opts, 100, undefined, new Error("x"));
const p = trackRenderError.mock.calls[0]![0];
expect(p.workers).toBeUndefined();
});
});
});