diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index c70899cb4..3fe44e20d 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1102,6 +1102,7 @@ let deParallelRouterTrialFiredThisProcess = false; * resetting outside a test process where many independent test cases share * one imported module instance. */ +// fallow-ignore-next-line unused-export export function __resetDeParallelRouterTrialStateForTests(): void { deParallelRouterTrialManagedByUs = false; deParallelRouterTrialFiredThisProcess = false; diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 05a0bb50f..d7593d84f 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -51,6 +51,7 @@ export interface RenderObservabilityTelemetryPayload { captureDeWorkerInversion?: string; captureDePreInversionWorkers?: number; captureDeParallelRouter?: string; + captureDeGpuRenderer?: string; captureDePreRouterWorkers?: number; captureDeSelfVerifyFallback?: boolean; captureDeFallbackReason?: string; @@ -108,6 +109,7 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP de_worker_inversion: props.captureDeWorkerInversion, de_pre_inversion_workers: props.captureDePreInversionWorkers, de_parallel_router: props.captureDeParallelRouter, + gpu_renderer: props.captureDeGpuRenderer, de_pre_router_workers: props.captureDePreRouterWorkers, de_self_verify_fallback: props.captureDeSelfVerifyFallback, de_fallback_reason: props.captureDeFallbackReason, @@ -183,7 +185,7 @@ export function trackRenderComplete( deParallelRouter?: string; dePreRouterWorkers?: number; deGateReason?: string; - /** Raw WebGL renderer string from DE session init (ANGLE backend + GPU vendor). */ + /** Low-cardinality GPU bucket from DE session init (`/`, e.g. `d3d11/nvidia`). */ gpuRenderer?: string; deWorkerEncode?: boolean; deVerifyArmed?: number; @@ -377,6 +379,10 @@ export function trackRenderError( elapsed_ms: props.elapsedMs, peak_memory_mb: props.peakMemoryMb, memory_free_mb: props.memoryFreeMb, + // gpu_renderer arrives via renderObservabilityEventProperties below: + // on the failure path perfSummary is never built, so live capture + // observability is the only source. Backend attribution matters MOST + // here — a win32 D3D11 crash is what the rollout is watching for. ...renderObservabilityEventProperties(props), }, props.distinctId, diff --git a/packages/cli/src/telemetry/renderObservability.ts b/packages/cli/src/telemetry/renderObservability.ts index 3df009f4d..87554d693 100644 --- a/packages/cli/src/telemetry/renderObservability.ts +++ b/packages/cli/src/telemetry/renderObservability.ts @@ -43,6 +43,7 @@ export function renderObservabilityTelemetryPayload( captureDeWorkerInversion: capture.deWorkerInversion, captureDePreInversionWorkers: capture.dePreInversionWorkers, captureDeParallelRouter: capture.deParallelRouter, + captureDeGpuRenderer: capture.deGpuRenderer, captureDePreRouterWorkers: capture.dePreRouterWorkers, captureDeSelfVerifyFallback: capture.deSelfVerifyFallback, captureDeFallbackReason: capture.deFallbackReason, diff --git a/packages/engine/src/services/drawElementService.test.ts b/packages/engine/src/services/drawElementService.test.ts index a21f9602e..364542507 100644 --- a/packages/engine/src/services/drawElementService.test.ts +++ b/packages/engine/src/services/drawElementService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Page } from "puppeteer-core"; import { + classifyGpuRenderer, detectGpuBackend, detectSwiftShader, resolveDrawElementCaptureMode, @@ -48,6 +49,42 @@ describe("detectGpuBackend", () => { }); }); +// ── classifyGpuRenderer ──────────────────────────────────────────────────────── + +describe("classifyGpuRenderer", () => { + it("buckets real ANGLE renderer strings to /", () => { + expect( + classifyGpuRenderer("ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)"), + ).toBe("metal/apple"); + expect( + classifyGpuRenderer( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)", + ), + ).toBe("d3d11/nvidia"); + expect( + classifyGpuRenderer("ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)"), + ).toBe("d3d11/intel"); + expect(classifyGpuRenderer("ANGLE (AMD, AMD Radeon RX 6800 Direct3D11 vs_5_0 ps_5_0)")).toBe( + "d3d11/amd", + ); + expect(classifyGpuRenderer("Google SwiftShader")).toBe("swiftshader/other"); + }); + + it("drops the GPU model — the bucket must stay low cardinality", () => { + // Two different NVIDIA cards must collapse to ONE bucket, otherwise the + // property is unbounded and useless for aggregation. + expect(classifyGpuRenderer("ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)")).toBe( + classifyGpuRenderer("ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 Direct3D11)"), + ); + }); + + it("returns undefined for missing input rather than a bogus bucket", () => { + expect(classifyGpuRenderer(null)).toBeUndefined(); + expect(classifyGpuRenderer(undefined)).toBeUndefined(); + expect(classifyGpuRenderer("")).toBeUndefined(); + }); +}); + // ── resolveDrawElementCaptureMode ────────────────────────────────────────────── describe("resolveDrawElementCaptureMode", () => { diff --git a/packages/engine/src/services/drawElementService.ts b/packages/engine/src/services/drawElementService.ts index a5d08eb08..05f8ab820 100644 --- a/packages/engine/src/services/drawElementService.ts +++ b/packages/engine/src/services/drawElementService.ts @@ -110,15 +110,58 @@ export interface GpuBackendInfo { isSwiftShader: boolean; /** * Raw UNMASKED_RENDERER_WEBGL string (e.g. "ANGLE (Apple, ANGLE Metal - * Renderer: Apple M4 Pro, ...)", "ANGLE (NVIDIA, D3D11 ...)"), or null when - * WebGL / the debug extension is unavailable. Carried to render telemetry: - * drawElement failure modes proved compositor-backend-specific during the - * macOS rollout, so the win32/D3D11 cohort needs damage clusters - * attributable to a GPU vendor + ANGLE backend, not just `os`. + * Renderer: Apple M4 Pro, ...)", "ANGLE (NVIDIA, GeForce RTX 3080 Direct3D11 + * vs_5_0 ps_5_0, D3D11)"), or null when WebGL / the debug extension is + * unavailable. LOCAL USE ONLY — this is unbounded driver-supplied text and + * must not be shipped to telemetry verbatim; send + * {@link classifyGpuRenderer}'s bucket instead. */ renderer: string | null; } +/** + * Low-cardinality bucket for a raw WebGL renderer string: `/` + * (e.g. `metal/apple`, `d3d11/nvidia`, `swiftshader/other`). + * + * drawElement failure modes proved compositor-backend-specific during the + * macOS rollout, so the win32/D3D11 cohort needs damage attributable to an + * ANGLE backend + GPU vendor. The raw string can't do that job in telemetry: + * it is unbounded, driver-authored, carries specific GPU model names, and is + * joined across parallel sessions — high cardinality by construction. The + * bucket keeps the analytic signal (which backend, which vendor) and drops + * everything else, matching how `deGateReason` is a sanitized bucket rather + * than the full fallback trigger. Pure; exported for tests. + */ +export function classifyGpuRenderer(renderer: string | null | undefined): string | undefined { + if (!renderer) return undefined; + const r = renderer.toLowerCase(); + const backend = r.includes("swiftshader") + ? "swiftshader" + : r.includes("metal") + ? "metal" + : r.includes("direct3d11") || r.includes("d3d11") + ? "d3d11" + : r.includes("direct3d9") || r.includes("d3d9") + ? "d3d9" + : r.includes("vulkan") + ? "vulkan" + : r.includes("opengl") || r.includes("angle") + ? "opengl" + : "other"; + const vendor = r.includes("apple") + ? "apple" + : r.includes("nvidia") + ? "nvidia" + : r.includes("amd") || r.includes("radeon") + ? "amd" + : r.includes("intel") + ? "intel" + : r.includes("microsoft") + ? "microsoft" + : "other"; + return `${backend}/${vendor}`; +} + /** * Detect the page's WebGL backend: SwiftShader vs a real GPU, plus the raw * renderer string for telemetry. diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 88e044cb4..4751f7a05 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -34,6 +34,7 @@ import { shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js"; import { + classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, @@ -147,10 +148,12 @@ export interface CaptureSession { /** True if running on SwiftShader (detected at init). Undefined before init. */ isSwiftShader?: boolean; /** - * Raw WebGL UNMASKED_RENDERER_WEBGL string, captured alongside the - * SwiftShader probe at DE session init (e.g. "ANGLE (NVIDIA, D3D11 ...)"). - * Surfaces in CapturePerfSummary → render telemetry so backend-specific - * drawElement damage (Metal vs D3D11 vs GL) clusters attributably. + * Low-cardinality GPU bucket (`/`, e.g. `d3d11/nvidia`) + * derived from the WebGL renderer at DE session init. Surfaces in + * CapturePerfSummary → render telemetry so backend-specific drawElement + * damage (Metal vs D3D11 vs GL) clusters attributably. The raw + * driver-supplied string is deliberately NOT retained — see + * classifyGpuRenderer. */ gpuRenderer?: string; /** drawElementImage canvas was injected and is ready for capture. */ @@ -713,7 +716,7 @@ async function initDrawElementOrTransparentBackground( if (useDrawElement) { const gpuBackend = await detectGpuBackend(page); session.isSwiftShader = gpuBackend.isSwiftShader; - session.gpuRenderer = gpuBackend.renderer ?? undefined; + session.gpuRenderer = classifyGpuRenderer(gpuBackend.renderer); const transparent = session.options.format === "png"; async function routeToFallback(): Promise { session.captureMode = session.launchCaptureMode; diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 9253e00df..4f30c1efb 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -277,11 +277,11 @@ export interface CapturePerfSummary { /** Final capture mode this session used: "drawelement" | "screenshot" | "beginframe". */ captureMode: string; /** - * Raw WebGL UNMASKED_RENDERER_WEBGL string from DE session init (ANGLE - * backend + GPU vendor, e.g. "ANGLE (Apple, ANGLE Metal Renderer: ...)"). - * Undefined when drawElement was never attempted. Lets telemetry cluster - * backend-specific damage now that DE engages on both Metal (darwin) and - * D3D11 (win32). + * Low-cardinality GPU bucket from DE session init: `/` + * (e.g. `metal/apple`, `d3d11/nvidia`). Undefined when drawElement was + * never attempted. Lets telemetry cluster backend-specific damage now that + * DE engages on both Metal (darwin) and D3D11 (win32). Bucketed, not raw — + * see `classifyGpuRenderer`. */ gpuRenderer?: string; /** diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts index 23bc93814..39111960c 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -72,6 +72,13 @@ export interface RenderCaptureObservability { dePreInversionWorkers?: number; /** DE parallel-router outcome: "routed" (fired, held) | "reverted" (fired, self-verify retry rolled back). */ deParallelRouter?: "routed" | "reverted"; + /** + * Low-cardinality GPU bucket (`/`) from the DE probe + * session. Lives on capture observability (not just perfSummary) so a hard + * failure — crash / OOM / timeout — still reports which GPU backend it hit: + * that is precisely the cohort the win32 D3D11 rollout must attribute. + */ + deGpuRenderer?: string; /** Worker count the resolver would have used absent the router; undefined if it never fired. */ dePreRouterWorkers?: number; /** diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 5417ef3fd..b6bb80af2 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -498,7 +498,7 @@ export interface RenderPerfSummary { preRouterWorkers?: number; /** Engine init-time gate: swiftshader | css_effect:* | at_risk_timeline | 3d_init_failed | supersampling | render_mode_hint. */ gateReason?: string; - /** Raw WebGL renderer string from DE session init (ANGLE backend + GPU vendor); |-joined across parallel sessions. */ + /** Low-cardinality GPU bucket from DE session init (`/`, e.g. `d3d11/nvidia`); |-joined across parallel sessions (bounded: one bucket per distinct backend on the host). */ gpuRenderer?: string; /** Worker-encode drain (the verified path) was active. */ workerEncode: boolean; @@ -2644,6 +2644,11 @@ async function executeRenderPipeline(input: { // any resource-pressure failure unique to this cohort. dePreInversionWorkers: deWorkerInversion ? preRoutingWorkerCount : undefined, dePreRouterWorkers: deParallelRouter ? preRoutingWorkerCount : undefined, + // Same rationale as the counters above: carried on live capture + // observability, not only the success-path perfSummary, so a crash / + // OOM / timeout still reports which GPU backend it happened on. That + // is the cohort the win32 D3D11 rollout most needs to attribute. + deGpuRenderer: probeSession?.gpuRenderer, }); observability.checkpoint("worker_resolution", "resolved", { workerCount,