Skip to content

Commit da38de1

Browse files
committed
test+fix(telemetry): address PR review — dev-mode gate, session-storage dedupe, payload tests
Addresses review comments on heygen-com#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.
1 parent 3cc4c82 commit da38de1

7 files changed

Lines changed: 393 additions & 8 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import type { RenderPerfSummary } from "@hyperframes/producer";
3+
4+
// Mock `../telemetry/events.js` so we can capture trackRenderComplete /
5+
// trackRenderError calls and verify the payload mapping without firing
6+
// network requests.
7+
const trackRenderComplete = vi.fn();
8+
const trackRenderError = vi.fn();
9+
vi.mock("../telemetry/events.js", () => ({
10+
trackRenderComplete: (...args: unknown[]) => trackRenderComplete(...args),
11+
trackRenderError: (...args: unknown[]) => trackRenderError(...args),
12+
}));
13+
14+
// Imported after the mock is registered so the module picks up the mocked
15+
// trackRenderComplete / trackRenderError.
16+
const { emitStudioRenderComplete, emitStudioRenderError } =
17+
await import("./studioRenderTelemetry.js");
18+
19+
const opts = {
20+
fps: { num: 30, den: 1 } as const,
21+
quality: "standard",
22+
};
23+
24+
const fullPerf: RenderPerfSummary = {
25+
renderId: "r-1",
26+
totalElapsedMs: 5000,
27+
fps: 30,
28+
quality: "standard",
29+
workers: 4,
30+
chunkedEncode: false,
31+
chunkSizeFrames: null,
32+
compositionDurationSeconds: 10,
33+
totalFrames: 300,
34+
resolution: { width: 1920, height: 1080 },
35+
videoCount: 1,
36+
audioCount: 0,
37+
stages: {
38+
compileMs: 100,
39+
videoExtractMs: 200,
40+
audioProcessMs: 50,
41+
captureMs: 4000,
42+
encodeMs: 500,
43+
assembleMs: 150,
44+
},
45+
videoExtractBreakdown: {
46+
resolveMs: 10,
47+
hdrProbeMs: 20,
48+
hdrPreflightMs: 30,
49+
hdrPreflightCount: 1,
50+
vfrProbeMs: 40,
51+
vfrPreflightMs: 50,
52+
vfrPreflightCount: 2,
53+
extractMs: 60,
54+
cacheHits: 3,
55+
cacheMisses: 4,
56+
},
57+
tmpPeakBytes: 1024,
58+
captureAvgMs: 13,
59+
capturePeakMs: 25,
60+
};
61+
62+
describe("studioRenderTelemetry", () => {
63+
beforeEach(() => {
64+
trackRenderComplete.mockClear();
65+
trackRenderError.mockClear();
66+
});
67+
68+
describe("emitStudioRenderComplete", () => {
69+
it("tags the event with source: 'studio' and fps as a number", () => {
70+
emitStudioRenderComplete(opts, 5000, fullPerf);
71+
expect(trackRenderComplete).toHaveBeenCalledOnce();
72+
const payload = trackRenderComplete.mock.calls[0]![0];
73+
expect(payload.source).toBe("studio");
74+
expect(payload.fps).toBe(30);
75+
expect(payload.quality).toBe("standard");
76+
expect(payload.docker).toBe(false);
77+
expect(payload.gpu).toBe(false);
78+
});
79+
80+
it("maps every RenderPerfSummary field to the expected payload key", () => {
81+
emitStudioRenderComplete(opts, 5000, fullPerf);
82+
const p = trackRenderComplete.mock.calls[0]![0];
83+
expect(p.durationMs).toBe(5000);
84+
expect(p.workers).toBe(4);
85+
expect(p.compositionDurationMs).toBe(10_000);
86+
expect(p.compositionWidth).toBe(1920);
87+
expect(p.compositionHeight).toBe(1080);
88+
expect(p.totalFrames).toBe(300);
89+
// speedRatio = compositionDurationMs / elapsedMs = 10000 / 5000 = 2
90+
expect(p.speedRatio).toBe(2);
91+
expect(p.captureAvgMs).toBe(13);
92+
expect(p.capturePeakMs).toBe(25);
93+
expect(p.tmpPeakBytes).toBe(1024);
94+
// stages
95+
expect(p.stageCompileMs).toBe(100);
96+
expect(p.stageVideoExtractMs).toBe(200);
97+
expect(p.stageAudioProcessMs).toBe(50);
98+
expect(p.stageCaptureMs).toBe(4000);
99+
expect(p.stageEncodeMs).toBe(500);
100+
expect(p.stageAssembleMs).toBe(150);
101+
// video-extract breakdown
102+
expect(p.extractResolveMs).toBe(10);
103+
expect(p.extractHdrProbeMs).toBe(20);
104+
expect(p.extractHdrPreflightMs).toBe(30);
105+
expect(p.extractHdrPreflightCount).toBe(1);
106+
expect(p.extractVfrProbeMs).toBe(40);
107+
expect(p.extractVfrPreflightMs).toBe(50);
108+
expect(p.extractVfrPreflightCount).toBe(2);
109+
// `extractMs` on RenderPerfSummary maps to `extractPhase3Ms` on the event
110+
// (named for legacy reasons — see packages/cli/src/commands/render.ts).
111+
expect(p.extractPhase3Ms).toBe(60);
112+
expect(p.extractCacheHits).toBe(3);
113+
expect(p.extractCacheMisses).toBe(4);
114+
});
115+
116+
it("omits all perf-derived fields when perfSummary is undefined", () => {
117+
emitStudioRenderComplete(opts, 5000, undefined);
118+
const p = trackRenderComplete.mock.calls[0]![0];
119+
// Identity fields still present
120+
expect(p.source).toBe("studio");
121+
expect(p.fps).toBe(30);
122+
expect(p.durationMs).toBe(5000);
123+
// Perf-derived fields all undefined
124+
expect(p.workers).toBeUndefined();
125+
expect(p.compositionDurationMs).toBeUndefined();
126+
expect(p.totalFrames).toBeUndefined();
127+
expect(p.speedRatio).toBeUndefined();
128+
expect(p.stageCompileMs).toBeUndefined();
129+
expect(p.extractResolveMs).toBeUndefined();
130+
});
131+
132+
it("omits videoExtractBreakdown fields when only the breakdown is absent", () => {
133+
const perfNoExtract: RenderPerfSummary = { ...fullPerf, videoExtractBreakdown: undefined };
134+
emitStudioRenderComplete(opts, 5000, perfNoExtract);
135+
const p = trackRenderComplete.mock.calls[0]![0];
136+
expect(p.workers).toBe(4);
137+
expect(p.extractResolveMs).toBeUndefined();
138+
expect(p.extractCacheHits).toBeUndefined();
139+
});
140+
141+
it("leaves speedRatio undefined when elapsedMs is zero", () => {
142+
emitStudioRenderComplete(opts, 0, fullPerf);
143+
const p = trackRenderComplete.mock.calls[0]![0];
144+
expect(p.speedRatio).toBeUndefined();
145+
});
146+
});
147+
148+
describe("emitStudioRenderError", () => {
149+
it("tags with source: 'studio' and forwards failedStage + elapsedMs", () => {
150+
emitStudioRenderError(opts, 1200, "encode", new Error("boom"));
151+
expect(trackRenderError).toHaveBeenCalledOnce();
152+
const p = trackRenderError.mock.calls[0]![0];
153+
expect(p.source).toBe("studio");
154+
expect(p.fps).toBe(30);
155+
expect(p.quality).toBe("standard");
156+
expect(p.docker).toBe(false);
157+
expect(p.failedStage).toBe("encode");
158+
expect(p.elapsedMs).toBe(1200);
159+
expect(p.errorMessage).toBe("boom");
160+
});
161+
162+
it("stringifies non-Error throwables", () => {
163+
emitStudioRenderError(opts, 100, undefined, "string error");
164+
expect(trackRenderError.mock.calls[0]![0].errorMessage).toBe("string error");
165+
});
166+
167+
it("does not include a workers field on the error event payload", () => {
168+
// Documented behavior: studio renders don't request a worker count,
169+
// and the early-failure path doesn't have perfSummary to read it from.
170+
emitStudioRenderError(opts, 100, undefined, new Error("x"));
171+
const p = trackRenderError.mock.calls[0]![0];
172+
expect(p.workers).toBeUndefined();
173+
});
174+
});
175+
});

packages/cli/src/server/studioRenderTelemetry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ export function emitStudioRenderError(
8787
failedStage: string | undefined,
8888
err: unknown,
8989
): void {
90+
// `workers` is intentionally omitted: studio renders don't accept a
91+
// user-supplied worker count (the producer picks its default), so on early
92+
// failures we genuinely don't know one. The CLI side has the value from
93+
// `options.workers` even before `job.perfSummary` exists; studio doesn't.
9094
trackRenderError({
9195
fps: fpsToNumber(opts.fps),
9296
quality: opts.quality,

packages/studio/src/App.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,20 @@ import {
4848
readStudioUrlStateFromWindow,
4949
} from "./utils/studioUrlState";
5050
import { trackStudioSessionStart } from "./telemetry/events";
51+
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
5152

5253
export function StudioApp() {
5354
const { projectId, resolving, waitingForServer } = useServerConnection();
5455
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
5556

56-
// Fire once per browser session to mark a "studio open" event so we can
57-
// separate studio sessions from CLI invocations in product analytics.
58-
// `has_project` lets us tell scratch-open from project-context-open.
59-
const sessionFiredRef = useRef(false);
57+
// Fire once per browser tab session — sessionStorage-backed so HMR
58+
// remounts, route changes, and any future StudioApp remount within the
59+
// same tab don't refire `studio_session_start`. `has_project` lets us
60+
// tell scratch-open from project-context-open.
6061
useEffect(() => {
61-
if (sessionFiredRef.current) return;
6262
if (resolving || waitingForServer) return;
63-
sessionFiredRef.current = true;
63+
if (hasFiredSessionStart()) return;
64+
markSessionStartFired();
6465
trackStudioSessionStart({ has_project: projectId != null });
6566
}, [projectId, resolving, waitingForServer]);
6667

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// @vitest-environment happy-dom
2+
3+
import { describe, expect, it, vi, beforeEach } from "vitest";
4+
5+
// `shouldTrack()` reads `POSTHOG_API_KEY` from module-level const that's
6+
// evaluated at module load time, so changing `import.meta.env` after import
7+
// has no effect on the key. Each test resets module cache and re-imports.
8+
9+
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
10+
11+
function setKey(value: string | undefined): void {
12+
if (value === undefined) {
13+
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY;
14+
} else {
15+
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY = value;
16+
}
17+
}
18+
19+
function setNoTelemetry(value: string | undefined): void {
20+
if (value === undefined) {
21+
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY;
22+
} else {
23+
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY = value;
24+
}
25+
}
26+
27+
function setDev(value: boolean): void {
28+
(import.meta.env as { DEV: boolean }).DEV = value;
29+
}
30+
31+
async function loadShouldTrack(): Promise<() => boolean> {
32+
vi.resetModules();
33+
const mod = await import("./client");
34+
return mod.shouldTrack;
35+
}
36+
37+
describe("studio client shouldTrack", () => {
38+
beforeEach(() => {
39+
setDev(false);
40+
setKey("phc_test_key");
41+
setNoTelemetry(undefined);
42+
localStorage.clear();
43+
vi.unstubAllGlobals();
44+
});
45+
46+
it("returns true when key is configured, not in dev mode, and no opt-outs", async () => {
47+
const shouldTrack = await loadShouldTrack();
48+
expect(shouldTrack()).toBe(true);
49+
});
50+
51+
it("returns false when API key does not start with phc_", async () => {
52+
setKey("not_a_real_key");
53+
const shouldTrack = await loadShouldTrack();
54+
expect(shouldTrack()).toBe(false);
55+
});
56+
57+
it("returns false when API key is empty string", async () => {
58+
setKey("");
59+
const shouldTrack = await loadShouldTrack();
60+
expect(shouldTrack()).toBe(false);
61+
});
62+
63+
it("returns false when user has opted out via localStorage", async () => {
64+
localStorage.setItem(OPT_OUT_KEY, "1");
65+
const shouldTrack = await loadShouldTrack();
66+
expect(shouldTrack()).toBe(false);
67+
});
68+
69+
it("returns false when navigator.doNotTrack is '1'", async () => {
70+
vi.stubGlobal("navigator", { ...navigator, doNotTrack: "1" });
71+
const shouldTrack = await loadShouldTrack();
72+
expect(shouldTrack()).toBe(false);
73+
});
74+
75+
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY=1 at build time", async () => {
76+
setNoTelemetry("1");
77+
const shouldTrack = await loadShouldTrack();
78+
expect(shouldTrack()).toBe(false);
79+
});
80+
81+
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
82+
setNoTelemetry("true");
83+
const shouldTrack = await loadShouldTrack();
84+
expect(shouldTrack()).toBe(false);
85+
});
86+
87+
it("returns false in vite dev mode", async () => {
88+
setDev(true);
89+
const shouldTrack = await loadShouldTrack();
90+
expect(shouldTrack()).toBe(false);
91+
});
92+
93+
it("memoizes its decision after the first call", async () => {
94+
const shouldTrack = await loadShouldTrack();
95+
const first = shouldTrack();
96+
// Flip an underlying input — memoized return must not change.
97+
localStorage.setItem(OPT_OUT_KEY, "1");
98+
expect(shouldTrack()).toBe(first);
99+
});
100+
});

packages/studio/src/telemetry/client.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,28 @@ function isApiKeyConfigured(): boolean {
3838
return POSTHOG_API_KEY.startsWith("phc_");
3939
}
4040

41-
function shouldTrack(): boolean {
41+
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
42+
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
43+
// bundle the same way. Vite injects it at build time. Accepts "1" or "true".
44+
function isBuildTimeOptOut(): boolean {
45+
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
46+
return v === "1" || v === "true";
47+
}
48+
49+
// `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
50+
// so developers running `hyperframes preview` don't pollute production telemetry.
51+
function isViteDevMode(): boolean {
52+
return import.meta.env.DEV === true;
53+
}
54+
55+
export function shouldTrack(): boolean {
4256
if (telemetryEnabled !== null) return telemetryEnabled;
43-
telemetryEnabled = isApiKeyConfigured() && !isOptedOut() && !isDoNotTrackOn();
57+
telemetryEnabled =
58+
isApiKeyConfigured() &&
59+
!isBuildTimeOptOut() &&
60+
!isViteDevMode() &&
61+
!isOptedOut() &&
62+
!isDoNotTrackOn();
4463
return telemetryEnabled;
4564
}
4665

@@ -63,6 +82,10 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
6382
showNoticeOnce();
6483
}
6584

85+
// Fire-and-forget: the queue is cleared before `send()` resolves, so a network
86+
// failure drops the batch rather than retrying. Matches the CLI client's
87+
// design. Do NOT add retry logic here — a retry without cross-batch dedup
88+
// would risk double-counting events on transient PostHog 5xx responses.
6689
function flush(): void {
6790
if (eventQueue.length === 0) return;
6891
const distinctId = getAnonymousId();

packages/studio/src/telemetry/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,28 @@ export function markNoticeShown(): void {
5151
/* ignore */
5252
}
5353
}
54+
55+
// Session-scoped (cleared when the tab closes) so HMR remounts and
56+
// route-level remounts within one tab don't refire `studio_session_start`.
57+
// Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
58+
const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired";
59+
60+
function safeSessionStorage(): Storage | null {
61+
try {
62+
return typeof sessionStorage === "undefined" ? null : sessionStorage;
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
export function hasFiredSessionStart(): boolean {
69+
return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1";
70+
}
71+
72+
export function markSessionStartFired(): void {
73+
try {
74+
safeSessionStorage()?.setItem(SESSION_FIRED_KEY, "1");
75+
} catch {
76+
/* ignore */
77+
}
78+
}

0 commit comments

Comments
 (0)