diff --git a/packages/cli/src/server/studioRenderTelemetry.test.ts b/packages/cli/src/server/studioRenderTelemetry.test.ts index 8454765b61..c8eefb5738 100644 --- a/packages/cli/src/server/studioRenderTelemetry.test.ts +++ b/packages/cli/src/server/studioRenderTelemetry.test.ts @@ -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]; diff --git a/packages/cli/src/server/studioRenderTelemetry.ts b/packages/cli/src/server/studioRenderTelemetry.ts index 4e6b310b67..f7da6fd018 100644 --- a/packages/cli/src/server/studioRenderTelemetry.ts +++ b/packages/cli/src/server/studioRenderTelemetry.ts @@ -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[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(), }); diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index feb55989d3..44f10891ad 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -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 = []; diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 57ada06bd5..8b9773ba46 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -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", ); }); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 49c29a3811..fc95c8a400 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -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: { diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/core/src/studio-api/routes/render.test.ts index ef299cd780..fca63003b4 100644 --- a/packages/core/src/studio-api/routes/render.test.ts +++ b/packages/core/src/studio-api/routes/render.test.ts @@ -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(); + } + }); +}); diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index bf62bbb55e..1435148c0d 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -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 = { 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 }); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 0ef6e17cde..eb553d2e59 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -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. */ diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index a6d36433e5..79188da579 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -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;