Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/cli/src/server/studioRenderTelemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ describe("studioRenderTelemetry", () => {
expect(payload.gpu).toBe(false);
});

it("forwards the browser user's distinctId so the render funnel is joinable", () => {
emitStudioRenderComplete({ ...opts, distinctId: "browser-user-123" }, 5000, fullPerf);
expect(trackRenderComplete.mock.calls[0]![0].distinctId).toBe("browser-user-123");
});

it("leaves distinctId undefined for older clients that don't send one", () => {
emitStudioRenderComplete(opts, 5000, fullPerf);
expect(trackRenderComplete.mock.calls[0]![0].distinctId).toBeUndefined();
});

it("maps every RenderPerfSummary field to the expected payload key", () => {
emitStudioRenderComplete(opts, 5000, fullPerf);
const p = trackRenderComplete.mock.calls[0]![0];
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/server/studioRenderTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { bytesToMb } from "../telemetry/system.js";
export interface StudioRenderOpts {
fps: Fps;
quality: string;
// 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;
}

type RenderCompleteProps = Parameters<typeof trackRenderComplete>[0];
Expand Down Expand Up @@ -105,6 +109,7 @@ export function emitStudioRenderError(
failedStage,
errorMessage: err instanceof Error ? err.message : String(err),
elapsedMs,
distinctId: opts.distinctId,
...renderJobObservabilityTelemetryPayload(job),
...memSnapshot(),
});
Expand All @@ -122,6 +127,7 @@ export function emitStudioRenderComplete(
docker: false,
gpu: false,
source: "studio",
distinctId: opts.distinctId,
...perfPayload(perf, elapsedMs),
...memSnapshot(),
});
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/telemetry/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ let eventQueue: Array<{
event: string;
properties: EventProperties;
timestamp: string;
// Override for the batch distinct_id. Defaults to the install's anonymousId.
// Used to attribute server-side studio renders to the browser user who
// triggered them, so the render funnel is joinable across processes.
distinctId?: string;
}> = [];

let telemetryEnabled: boolean | null = null;
Expand Down Expand Up @@ -59,12 +63,17 @@ export function shouldTrack(): boolean {
/**
* Queue a telemetry event. Non-blocking, fail-silent.
*/
export function trackEvent(event: string, properties: EventProperties = {}): void {
export function trackEvent(
event: string,
properties: EventProperties = {},
distinctId?: string,
): void {
if (!shouldTrack()) return;

const sys = getSystemMeta();
eventQueue.push({
event,
distinctId,
properties: {
...properties,
cli_version: VERSION,
Expand Down Expand Up @@ -102,7 +111,7 @@ function drainQueueToPayload(): string | null {
const batch = eventQueue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
distinct_id: e.distinctId ?? config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/telemetry/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ describe("render telemetry events", () => {
error_message: "ENOENT: open '[path]' https://example.com/video.mp4?…",
observability_composition_hash: "abc123",
}),
undefined,
);
});

it("forwards distinctId to trackEvent so studio renders attribute to the browser user", () => {
trackRenderError({
fps: 30,
quality: "standard",
docker: false,
source: "studio",
distinctId: "browser-user-123",
});

expect(trackEvent).toHaveBeenCalledWith(
"render_error",
expect.objectContaining({ source: "studio" }),
"browser-user-123",
);
});

Expand Down
114 changes: 64 additions & 50 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,44 +135,51 @@ export function trackRenderComplete(
extractPhase3Ms?: number;
extractCacheHits?: number;
extractCacheMisses?: number;
// Attribute this event to a specific user (e.g. the browser user who
// triggered a studio render); defaults to the install anonymousId.
distinctId?: string;
} & RenderObservabilityTelemetryPayload,
): void {
trackEvent("render_complete", {
duration_ms: props.durationMs,
fps: props.fps,
quality: props.quality,
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,
total_frames: props.totalFrames,
speed_ratio: props.speedRatio,
capture_avg_ms: props.captureAvgMs,
capture_peak_ms: props.capturePeakMs,
peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb,
tmp_peak_bytes: props.tmpPeakBytes,
stage_compile_ms: props.stageCompileMs,
stage_video_extract_ms: props.stageVideoExtractMs,
stage_audio_process_ms: props.stageAudioProcessMs,
stage_capture_ms: props.stageCaptureMs,
stage_encode_ms: props.stageEncodeMs,
stage_assemble_ms: props.stageAssembleMs,
extract_resolve_ms: props.extractResolveMs,
extract_hdr_probe_ms: props.extractHdrProbeMs,
extract_hdr_preflight_ms: props.extractHdrPreflightMs,
extract_hdr_preflight_count: props.extractHdrPreflightCount,
extract_vfr_probe_ms: props.extractVfrProbeMs,
extract_vfr_preflight_ms: props.extractVfrPreflightMs,
extract_vfr_preflight_count: props.extractVfrPreflightCount,
extract_phase3_ms: props.extractPhase3Ms,
extract_cache_hits: props.extractCacheHits,
extract_cache_misses: props.extractCacheMisses,
...renderObservabilityEventProperties(props),
});
trackEvent(
"render_complete",
{
duration_ms: props.durationMs,
fps: props.fps,
quality: props.quality,
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,
total_frames: props.totalFrames,
speed_ratio: props.speedRatio,
capture_avg_ms: props.captureAvgMs,
capture_peak_ms: props.capturePeakMs,
peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb,
tmp_peak_bytes: props.tmpPeakBytes,
stage_compile_ms: props.stageCompileMs,
stage_video_extract_ms: props.stageVideoExtractMs,
stage_audio_process_ms: props.stageAudioProcessMs,
stage_capture_ms: props.stageCaptureMs,
stage_encode_ms: props.stageEncodeMs,
stage_assemble_ms: props.stageAssembleMs,
extract_resolve_ms: props.extractResolveMs,
extract_hdr_probe_ms: props.extractHdrProbeMs,
extract_hdr_preflight_ms: props.extractHdrPreflightMs,
extract_hdr_preflight_count: props.extractHdrPreflightCount,
extract_vfr_probe_ms: props.extractVfrProbeMs,
extract_vfr_preflight_ms: props.extractVfrPreflightMs,
extract_vfr_preflight_count: props.extractVfrPreflightCount,
extract_phase3_ms: props.extractPhase3Ms,
extract_cache_hits: props.extractCacheHits,
extract_cache_misses: props.extractCacheMisses,
...renderObservabilityEventProperties(props),
},
props.distinctId,
);
}

export function trackRenderError(
Expand All @@ -188,22 +195,29 @@ export function trackRenderError(
elapsedMs?: number;
peakMemoryMb?: number;
memoryFreeMb?: number;
// Attribute this event to a specific user (e.g. the browser user who
// triggered a studio render); defaults to the install anonymousId.
distinctId?: string;
} & RenderObservabilityTelemetryPayload,
): void {
trackEvent("render_error", {
fps: props.fps,
quality: props.quality,
docker: props.docker,
workers: props.workers,
gpu: props.gpu,
source: props.source ?? "cli",
failed_stage: props.failedStage,
error_message: props.errorMessage ? redactTelemetryMessage(props.errorMessage) : undefined,
elapsed_ms: props.elapsedMs,
peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb,
...renderObservabilityEventProperties(props),
});
trackEvent(
"render_error",
{
fps: props.fps,
quality: props.quality,
docker: props.docker,
workers: props.workers,
gpu: props.gpu,
source: props.source ?? "cli",
failed_stage: props.failedStage,
error_message: props.errorMessage ? redactTelemetryMessage(props.errorMessage) : undefined,
elapsed_ms: props.elapsedMs,
peak_memory_mb: props.peakMemoryMb,
memory_free_mb: props.memoryFreeMb,
...renderObservabilityEventProperties(props),
},
props.distinctId,
);
}

export function trackRenderObservation(props: {
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/studio-api/routes/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,63 @@ describe("GET /projects/:id/renders/file/* — path safety", () => {
expect(await res.text()).toBe("nested-bytes");
});
});

describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: "browser-user-123",
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBe("browser-user-123");
} finally {
cleanup();
}
});

it("passes undefined when no telemetryDistinctId is sent (older clients)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});

it("ignores a non-string telemetryDistinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: 42,
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
});
5 changes: 5 additions & 0 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
format?: string;
resolution?: string;
composition?: string;
// Browser telemetry id, so the server-emitted render outcome is
// attributed to the user who triggered the render (joinable funnel).
telemetryDistinctId?: string;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
Expand Down Expand Up @@ -108,6 +111,8 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
jobId,
outputResolution,
composition,
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ export interface StudioApiAdapter {
outputResolution?: CanvasResolution;
/** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */
composition?: string;
/**
* Telemetry id of the browser user who triggered the render. Lets the
* adapter attribute the server-emitted render_complete/render_error to
* that user so the studio render funnel is joinable. Undefined for older
* clients → falls back to the install's anonymous id.
*/
distinctId?: string;
}): RenderJobState;

/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/components/renders/useRenderQueue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { trackStudioRenderStart } from "../../telemetry/events";
import { getAnonymousId } from "../../telemetry/config";

export interface RenderJob {
id: string;
Expand Down Expand Up @@ -109,10 +110,15 @@ export function useRenderQueue(projectId: string | null) {
format: string;
resolution?: string;
composition?: string;
telemetryDistinctId: string;
} = {
fps,
quality,
format,
// So the server-emitted render_complete/render_error is attributed to
// this browser user (same id studio_* events use), making the render
// funnel joinable. Matches studio_render_start fired just above.
telemetryDistinctId: getAnonymousId(),
};
if (resolution && resolution !== "auto") body.resolution = resolution;
if (composition) body.composition = composition;
Expand Down
Loading