mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
Merge remote-tracking branch 'origin/main' into feat/pipeline-quality-v2
This commit is contained in:
@@ -53,6 +53,14 @@
|
||||
"file": "packages/producer/src/services/fileServer.ts",
|
||||
"exports": ["isPathInside"],
|
||||
},
|
||||
// Studio telemetry: `trackStudioRenderStart` is consumed by
|
||||
// useRenderQueue.ts (deep relative import) but fallow's static analyzer
|
||||
// doesn't trace it. `trackStudioSessionStart` from the same file resolves
|
||||
// fine via App.tsx, so this is a path-resolution quirk, not dead code.
|
||||
{
|
||||
"file": "packages/studio/src/telemetry/events.ts",
|
||||
"exports": ["trackStudioRenderStart"],
|
||||
},
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 { Fps } from "@hyperframes/core";
|
||||
import { fpsToNumber } from "@hyperframes/core";
|
||||
import type { RenderPerfSummary } from "@hyperframes/producer";
|
||||
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";
|
||||
import { bytesToMb } from "../telemetry/system.js";
|
||||
|
||||
export interface StudioRenderOpts {
|
||||
fps: Fps;
|
||||
quality: string;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
capturePeakMs: perf.capturePeakMs,
|
||||
tmpPeakBytes: perf.tmpPeakBytes,
|
||||
...stagesPayload(perf.stages),
|
||||
...extractPayload(perf.videoExtractBreakdown),
|
||||
};
|
||||
}
|
||||
|
||||
export function emitStudioRenderError(
|
||||
opts: StudioRenderOpts,
|
||||
elapsedMs: number,
|
||||
failedStage: string | undefined,
|
||||
err: unknown,
|
||||
): 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.
|
||||
trackRenderError({
|
||||
fps: fpsToNumber(opts.fps),
|
||||
quality: opts.quality,
|
||||
docker: false,
|
||||
source: "studio",
|
||||
failedStage,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs,
|
||||
...memSnapshot(),
|
||||
});
|
||||
}
|
||||
|
||||
export function emitStudioRenderComplete(
|
||||
opts: StudioRenderOpts,
|
||||
elapsedMs: number,
|
||||
perf: RenderPerfSummary | undefined,
|
||||
): void {
|
||||
trackRenderComplete({
|
||||
durationMs: elapsedMs,
|
||||
fps: fpsToNumber(opts.fps),
|
||||
quality: opts.quality,
|
||||
docker: false,
|
||||
gpu: false,
|
||||
source: "studio",
|
||||
...perfPayload(perf, elapsedMs),
|
||||
...memSnapshot(),
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { resolve, join, basename } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||
import {
|
||||
createStudioManualEditsRenderBodyScript,
|
||||
createStudioApi,
|
||||
@@ -253,6 +254,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
};
|
||||
|
||||
// Run render asynchronously, mutating the state object
|
||||
const startTime = Date.now();
|
||||
(async () => {
|
||||
try {
|
||||
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
|
||||
@@ -279,7 +281,6 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
|
||||
...(opts.composition ? { entryFile: opts.composition } : {}),
|
||||
});
|
||||
const startTime = Date.now();
|
||||
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
||||
state.progress = j.progress;
|
||||
if (j.currentStage) state.stage = j.currentStage;
|
||||
@@ -292,9 +293,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary);
|
||||
} catch (err) {
|
||||
state.status = "failed";
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
emitStudioRenderError(opts, Date.now() - startTime, state.stage, err);
|
||||
try {
|
||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
|
||||
@@ -11,6 +11,9 @@ export function trackRenderComplete(props: {
|
||||
workers?: number;
|
||||
docker: boolean;
|
||||
gpu: boolean;
|
||||
// "cli" when triggered by `hyperframes render` (default), "studio" when
|
||||
// triggered by a studio preview-server render (POST /api/projects/:id/render).
|
||||
source?: "cli" | "studio";
|
||||
// Composition metadata
|
||||
compositionDurationMs?: number;
|
||||
compositionWidth?: number;
|
||||
@@ -50,6 +53,7 @@ export function trackRenderComplete(props: {
|
||||
workers: props.workers,
|
||||
docker: props.docker,
|
||||
gpu: props.gpu,
|
||||
source: props.source ?? "cli",
|
||||
composition_duration_ms: props.compositionDurationMs,
|
||||
composition_width: props.compositionWidth,
|
||||
composition_height: props.compositionHeight,
|
||||
@@ -85,6 +89,7 @@ export function trackRenderError(props: {
|
||||
docker: boolean;
|
||||
workers?: number;
|
||||
gpu?: boolean;
|
||||
source?: "cli" | "studio";
|
||||
failedStage?: string;
|
||||
errorMessage?: string;
|
||||
elapsedMs?: number;
|
||||
@@ -97,6 +102,7 @@ export function trackRenderError(props: {
|
||||
docker: props.docker,
|
||||
workers: props.workers,
|
||||
gpu: props.gpu,
|
||||
source: props.source ?? "cli",
|
||||
failed_stage: props.failedStage,
|
||||
error_message: props.errorMessage,
|
||||
elapsed_ms: props.elapsedMs,
|
||||
|
||||
@@ -47,11 +47,24 @@ import {
|
||||
normalizeStudioCompositionPath,
|
||||
readStudioUrlStateFromWindow,
|
||||
} from "./utils/studioUrlState";
|
||||
import { trackStudioSessionStart } from "./telemetry/events";
|
||||
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
||||
|
||||
export function StudioApp() {
|
||||
const { projectId, resolving, waitingForServer } = useServerConnection();
|
||||
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
||||
|
||||
// Fire once per browser tab session — sessionStorage-backed so HMR
|
||||
// remounts, route changes, and any future StudioApp remount within the
|
||||
// same tab don't refire `studio_session_start`. `has_project` lets us
|
||||
// tell scratch-open from project-context-open.
|
||||
useEffect(() => {
|
||||
if (resolving || waitingForServer) return;
|
||||
if (hasFiredSessionStart()) return;
|
||||
markSessionStartFired();
|
||||
trackStudioSessionStart({ has_project: projectId != null });
|
||||
}, [projectId, resolving, waitingForServer]);
|
||||
|
||||
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
||||
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
||||
() => initialUrlStateRef.current.activeCompPath == null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { trackStudioRenderStart } from "../../telemetry/events";
|
||||
|
||||
export interface RenderJob {
|
||||
id: string;
|
||||
@@ -90,6 +91,14 @@ export function useRenderQueue(projectId: string | null) {
|
||||
const resolution = opts.resolution;
|
||||
const composition = opts.composition;
|
||||
|
||||
trackStudioRenderStart({
|
||||
fps,
|
||||
quality,
|
||||
format,
|
||||
resolution,
|
||||
composition,
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
// "auto" / undefined means "render at the composition's authored size".
|
||||
// Omit the field entirely — sending "auto" would trip the route's
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// `shouldTrack()` reads `POSTHOG_API_KEY` from module-level const that's
|
||||
// evaluated at module load time, so changing `import.meta.env` after import
|
||||
// has no effect on the key. Each test resets module cache and re-imports.
|
||||
|
||||
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
||||
|
||||
function setKey(value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY;
|
||||
} else {
|
||||
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setNoTelemetry(value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY;
|
||||
} else {
|
||||
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setDev(value: boolean): void {
|
||||
(import.meta.env as { DEV: boolean }).DEV = value;
|
||||
}
|
||||
|
||||
async function loadShouldTrack(): Promise<() => boolean> {
|
||||
vi.resetModules();
|
||||
const mod = await import("./client");
|
||||
return mod.shouldTrack;
|
||||
}
|
||||
|
||||
describe("studio client shouldTrack", () => {
|
||||
beforeEach(() => {
|
||||
setDev(false);
|
||||
setKey("phc_test_key");
|
||||
setNoTelemetry(undefined);
|
||||
localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns true when key is configured, not in dev mode, and no opt-outs", async () => {
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when API key does not start with phc_", async () => {
|
||||
setKey("not_a_real_key");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when API key is empty string", async () => {
|
||||
setKey("");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user has opted out via localStorage", async () => {
|
||||
localStorage.setItem(OPT_OUT_KEY, "1");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when navigator.doNotTrack is '1'", async () => {
|
||||
vi.stubGlobal("navigator", { ...navigator, doNotTrack: "1" });
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY=1 at build time", async () => {
|
||||
setNoTelemetry("1");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
|
||||
setNoTelemetry("true");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false in vite dev mode", async () => {
|
||||
setDev(true);
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("memoizes its decision after the first call", async () => {
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
const first = shouldTrack();
|
||||
// Flip an underlying input — memoized return must not change.
|
||||
localStorage.setItem(OPT_OUT_KEY, "1");
|
||||
expect(shouldTrack()).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight PostHog client for the studio browser bundle.
|
||||
// Mirrors `packages/cli/src/telemetry/client.ts` but uses fetch/sendBeacon.
|
||||
// All calls are fire-and-forget; telemetry must never break the studio UI.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
|
||||
import { getBrowserSystemMeta } from "./system";
|
||||
|
||||
// HeyGen's PostHog project key — write-only, safe to embed in client code.
|
||||
// OSS builds can override via `VITE_HYPERFRAMES_POSTHOG_KEY` at build time,
|
||||
// or set it to an empty string to disable telemetry entirely.
|
||||
const POSTHOG_API_KEY =
|
||||
(import.meta.env.VITE_HYPERFRAMES_POSTHOG_KEY as string | undefined) ??
|
||||
"phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
const POSTHOG_HOST =
|
||||
(import.meta.env.VITE_HYPERFRAMES_POSTHOG_HOST as string | undefined) ??
|
||||
"https://us.i.posthog.com";
|
||||
const FLUSH_INTERVAL_MS = 1_000;
|
||||
|
||||
type EventProperties = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string;
|
||||
properties: EventProperties;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let eventQueue: QueuedEvent[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let telemetryEnabled: boolean | null = null;
|
||||
|
||||
function isDoNotTrackOn(): boolean {
|
||||
return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
|
||||
}
|
||||
|
||||
function isApiKeyConfigured(): boolean {
|
||||
return POSTHOG_API_KEY.startsWith("phc_");
|
||||
}
|
||||
|
||||
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
|
||||
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
|
||||
// bundle the same way. Vite injects it at build time. Accepts "1" or "true".
|
||||
function isBuildTimeOptOut(): boolean {
|
||||
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
|
||||
return v === "1" || v === "true";
|
||||
}
|
||||
|
||||
// `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
|
||||
// so developers running `hyperframes preview` don't pollute production telemetry.
|
||||
function isViteDevMode(): boolean {
|
||||
return import.meta.env.DEV === true;
|
||||
}
|
||||
|
||||
export function shouldTrack(): boolean {
|
||||
if (telemetryEnabled !== null) return telemetryEnabled;
|
||||
telemetryEnabled =
|
||||
isApiKeyConfigured() &&
|
||||
!isBuildTimeOptOut() &&
|
||||
!isViteDevMode() &&
|
||||
!isOptedOut() &&
|
||||
!isDoNotTrackOn();
|
||||
return telemetryEnabled;
|
||||
}
|
||||
|
||||
export function trackEvent(event: string, properties: EventProperties = {}): void {
|
||||
if (!shouldTrack()) return;
|
||||
|
||||
const sys = getBrowserSystemMeta();
|
||||
eventQueue.push({
|
||||
event,
|
||||
properties: { ...properties, ...sys },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
showNoticeOnce();
|
||||
}
|
||||
|
||||
// Fire-and-forget: the queue is cleared before `send()` resolves, so a network
|
||||
// failure drops the batch rather than retrying. Matches the CLI client's
|
||||
// design. Do NOT add retry logic here — a retry without cross-batch dedup
|
||||
// would risk double-counting events on transient PostHog 5xx responses.
|
||||
function flush(): void {
|
||||
if (eventQueue.length === 0) return;
|
||||
const distinctId = getAnonymousId();
|
||||
const batch = eventQueue.map((e) => ({
|
||||
event: e.event,
|
||||
// $ip: null tells PostHog to not record the request IP.
|
||||
properties: { ...e.properties, $ip: null },
|
||||
distinct_id: distinctId,
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
eventQueue = [];
|
||||
send(`${POSTHOG_HOST}/batch/`, JSON.stringify({ api_key: POSTHOG_API_KEY, batch }));
|
||||
}
|
||||
|
||||
function send(url: string, payload: string): void {
|
||||
// Prefer fetch with keepalive (survives page navigation). sendBeacon is a
|
||||
// fallback for older runtimes where fetch isn't available.
|
||||
try {
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
/* silent */
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
function showNoticeOnce(): void {
|
||||
if (hasShownNotice()) return;
|
||||
markNoticeShown();
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
"%c[HyperFrames]%c Anonymous studio usage analytics enabled. " +
|
||||
"Disable: localStorage.setItem('hyperframes-studio:telemetryDisabled','1') (then reload).",
|
||||
"color:#7c3aed;font-weight:bold",
|
||||
"color:inherit",
|
||||
);
|
||||
}
|
||||
|
||||
// Flush queued events when the tab is being hidden or closed so tail events
|
||||
// (e.g. a render_start fired moments before the user navigates away) aren't lost.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", () => flush(), { capture: true });
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") flush();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalStorage-backed config for studio telemetry.
|
||||
// Anonymous ID + opt-out flag are stored per-browser-profile.
|
||||
// Users opt out via DevTools:
|
||||
// localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ANON_ID_KEY = "hyperframes-studio:anonymousId";
|
||||
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
||||
const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function newAnonymousId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||
return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function getAnonymousId(): string {
|
||||
const ls = safeLocalStorage();
|
||||
if (!ls) return "anonymous";
|
||||
const existing = ls.getItem(ANON_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const id = newAnonymousId();
|
||||
try {
|
||||
ls.setItem(ANON_ID_KEY, id);
|
||||
} catch {
|
||||
/* private browsing / quota — return the in-memory ID for this session */
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function isOptedOut(): boolean {
|
||||
return safeLocalStorage()?.getItem(OPT_OUT_KEY) === "1";
|
||||
}
|
||||
|
||||
export function hasShownNotice(): boolean {
|
||||
return safeLocalStorage()?.getItem(NOTICE_KEY) === "1";
|
||||
}
|
||||
|
||||
export function markNoticeShown(): void {
|
||||
try {
|
||||
safeLocalStorage()?.setItem(NOTICE_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Session-scoped (cleared when the tab closes) so HMR remounts and
|
||||
// route-level remounts within one tab don't refire `studio_session_start`.
|
||||
// Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
|
||||
const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired";
|
||||
|
||||
function safeSessionStorage(): Storage | null {
|
||||
try {
|
||||
return typeof sessionStorage === "undefined" ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasFiredSessionStart(): boolean {
|
||||
return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1";
|
||||
}
|
||||
|
||||
export function markSessionStartFired(): void {
|
||||
try {
|
||||
safeSessionStorage()?.setItem(SESSION_FIRED_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock client.trackEvent so we can assert event names and payloads without
|
||||
// firing network requests or relying on memoized shouldTrack() state.
|
||||
const trackEvent = vi.fn();
|
||||
vi.mock("./client", () => ({
|
||||
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
||||
}));
|
||||
|
||||
const { trackStudioSessionStart, trackStudioRenderStart } = await import("./events");
|
||||
|
||||
describe("studio telemetry events", () => {
|
||||
beforeEach(() => {
|
||||
trackEvent.mockClear();
|
||||
});
|
||||
|
||||
it("trackStudioSessionStart emits 'studio_session_start' with has_project", () => {
|
||||
trackStudioSessionStart({ has_project: true });
|
||||
expect(trackEvent).toHaveBeenCalledOnce();
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: true });
|
||||
});
|
||||
|
||||
it("trackStudioSessionStart preserves false for has_project (scratch open)", () => {
|
||||
trackStudioSessionStart({ has_project: false });
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
|
||||
});
|
||||
|
||||
it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
|
||||
trackStudioRenderStart({
|
||||
fps: 30,
|
||||
quality: "standard",
|
||||
format: "mp4",
|
||||
resolution: "landscape",
|
||||
composition: "intro.html",
|
||||
});
|
||||
expect(trackEvent).toHaveBeenCalledOnce();
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_render_start", {
|
||||
fps: 30,
|
||||
quality: "standard",
|
||||
format: "mp4",
|
||||
resolution: "landscape",
|
||||
composition: "intro.html",
|
||||
});
|
||||
});
|
||||
|
||||
it("trackStudioRenderStart leaves optional fields undefined when omitted", () => {
|
||||
trackStudioRenderStart({ fps: 60, quality: "high", format: "webm" });
|
||||
const payload = trackEvent.mock.calls[0][1];
|
||||
expect(payload).toEqual({
|
||||
fps: 60,
|
||||
quality: "high",
|
||||
format: "webm",
|
||||
resolution: undefined,
|
||||
composition: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { trackEvent } from "./client";
|
||||
|
||||
// Studio frontend events. The corresponding `render_complete` / `render_error`
|
||||
// events are emitted server-side by `packages/cli/src/server/studioServer.ts`
|
||||
// with `source: "studio"` — keeping rich perf data on a single unified event.
|
||||
|
||||
export function trackStudioSessionStart(props: { has_project: boolean }): void {
|
||||
trackEvent("studio_session_start", {
|
||||
has_project: props.has_project,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackStudioRenderStart(props: {
|
||||
fps: number;
|
||||
quality: string;
|
||||
format: string;
|
||||
resolution?: string;
|
||||
composition?: string;
|
||||
}): void {
|
||||
trackEvent("studio_render_start", {
|
||||
fps: props.fps,
|
||||
quality: props.quality,
|
||||
format: props.format,
|
||||
resolution: props.resolution,
|
||||
composition: props.composition,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser metadata attached to every studio telemetry event.
|
||||
// Mirrors `packages/cli/src/telemetry/system.ts` but uses browser APIs.
|
||||
// No PII — only environment characteristics useful for product analytics.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BrowserSystemMeta {
|
||||
user_agent: string;
|
||||
language: string;
|
||||
screen_width: number;
|
||||
screen_height: number;
|
||||
device_pixel_ratio: number;
|
||||
timezone_offset_minutes: number;
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_META: BrowserSystemMeta = {
|
||||
user_agent: "",
|
||||
language: "",
|
||||
screen_width: 0,
|
||||
screen_height: 0,
|
||||
device_pixel_ratio: 0,
|
||||
timezone_offset_minutes: 0,
|
||||
is_mobile: false,
|
||||
};
|
||||
|
||||
let cached: BrowserSystemMeta | null = null;
|
||||
|
||||
export function getBrowserSystemMeta(): BrowserSystemMeta {
|
||||
if (cached) return cached;
|
||||
// SSR / no-DOM: return zeroed meta. Cheap to detect once at module load.
|
||||
if (typeof navigator === "undefined" || typeof window === "undefined") {
|
||||
cached = EMPTY_META;
|
||||
return cached;
|
||||
}
|
||||
const ua = navigator.userAgent;
|
||||
const screen = window.screen;
|
||||
cached = {
|
||||
user_agent: ua,
|
||||
language: navigator.language,
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
device_pixel_ratio: window.devicePixelRatio,
|
||||
timezone_offset_minutes: new Date().getTimezoneOffset(),
|
||||
is_mobile: /Android|iPhone|iPad/i.test(ua),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
Reference in New Issue
Block a user