fix(telemetry): attribute studio renders to the browser user (joinable funnel) (#1492)

Studio-triggered renders emit render_complete / render_error from the CLI
preview-server process, which stamps every event with the install's
anonymousId (client.ts drainQueueToPayload). The browser, meanwhile, fires
studio_session_start / studio_render_start under its own getAnonymousId(). So
the render outcome and the render start never share a person_id — verified in
data: of 15,125 users who started a studio render in 30d, ZERO have any
render_complete under any source, and the 898 studio-tagged completers are
disjoint server UUIDs. The studio render funnel — the product's core value
moment and strongest retention signal — is therefore unmeasurable.

Thread the browser's telemetry id through to the render-outcome events:

- client.ts: trackEvent takes an optional distinctId; drainQueueToPayload uses
  `event.distinctId ?? config.anonymousId`. CLI renders unchanged.
- events.ts: trackRenderComplete/trackRenderError forward an optional distinctId.
- studioRenderTelemetry.ts: emitStudioRender* pass opts.distinctId through.
- core studio-api (types.ts + routes/render.ts): the render route reads
  `telemetryDistinctId` from the request body (validated string) and passes it
  to the adapter's startRender, which already forwards opts to the emitters.
- studio (useRenderQueue.ts): include getAnonymousId() as telemetryDistinctId
  in the render POST — the same id studio_* events already use.

Result: studio render_complete/error now carry the browser user's id and join
studio_session_start / studio_render_start. Older clients that don't send the
field fall back to anonymousId (no regression). No new tracking surface — it's
the existing anonymous studio id.

Tests: per-event override forwarding (events), studio render distinctId
threading + older-client fallback (studioRenderTelemetry), and route body →
adapter forwarding incl. non-string rejection (core render route).
This commit is contained in:
Miguel Ángel
2026-06-16 02:55:22 -04:00
committed by GitHub
parent d6a846300e
commit 121cdd2d9f
9 changed files with 186 additions and 52 deletions
@@ -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];
@@ -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];
@@ -105,6 +109,7 @@ export function emitStudioRenderError(
failedStage,
errorMessage: err instanceof Error ? err.message : String(err),
elapsedMs,
distinctId: opts.distinctId,
...renderJobObservabilityTelemetryPayload(job),
...memSnapshot(),
});
@@ -122,6 +127,7 @@ export function emitStudioRenderComplete(
docker: false,
gpu: false,
source: "studio",
distinctId: opts.distinctId,
...perfPayload(perf, elapsedMs),
...memSnapshot(),
});
+11 -2
View File
@@ -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;
@@ -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,
@@ -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 = [];
+17
View File
@@ -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",
);
});
+64 -50
View File
@@ -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(
@@ -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: {
@@ -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();
}
});
});
@@ -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" };
@@ -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 });
+7
View File
@@ -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. */
@@ -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;
@@ -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;