From 7f1e6d0cd963ecc6c702ce0b35c37b83d251285c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 24 Mar 2026 20:47:32 +0200 Subject: [PATCH] feat(site): add Profiler instrumentation for agents chat (#23355) Wraps the chat timeline in React's to emit performance.measure() entries and throttled console.warn for slow renders. Inert in standard builds, only produces output with a profiling build. Refs #23354 --- .../AgentDetail/useOnRenderProfiler.ts | 90 +++++++++++++++++++ .../components/AgentDetailContent.tsx | 38 ++++---- 2 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/AgentDetail/useOnRenderProfiler.ts diff --git a/site/src/pages/AgentsPage/components/AgentDetail/useOnRenderProfiler.ts b/site/src/pages/AgentsPage/components/AgentDetail/useOnRenderProfiler.ts new file mode 100644 index 0000000000..282879ccff --- /dev/null +++ b/site/src/pages/AgentsPage/components/AgentDetail/useOnRenderProfiler.ts @@ -0,0 +1,90 @@ +import { type ProfilerOnRenderCallback, useCallback, useRef } from "react"; + +// Threshold in milliseconds. Renders exceeding one frame budget +// (16.67ms at 60fps) are logged as warnings. +const SLOW_RENDER_THRESHOLD_MS = 16; + +// Minimum interval between consecutive warnings for the same profiler +// id, to avoid flooding the console during rapid streaming updates. +const WARN_THROTTLE_MS = 2000; + +// Cap the number of performance.measure entries to avoid unbounded +// memory growth during long streaming sessions. When the cap is +// reached, only this profiler's entries are cleared by name +// and counting restarts. +const MAX_MEASURE_ENTRIES = 500; + +/** + * Returns a stable onRender callback for React's component. + * Every render emits a performance.measure() entry visible in browser + * devtools (including Safari Timeline). Renders exceeding + * SLOW_RENDER_THRESHOLD_MS additionally log a console.warn with + * timing details (throttled per profiler id). + * + * In standard production builds, React does not call the onRender + * callback with timing data, so the hook is effectively inert. It + * only produces output when built with react-dom/profiling (enabled + * via CODER_REACT_PROFILING=true). + */ +export function useOnRenderProfiler(): ProfilerOnRenderCallback { + const lastWarnTime = useRef(0); + const measureCount = useRef(0); + const measureNames = useRef(new Set()); + + return useCallback( + (id, phase, actualDuration, baseDuration, startTime, commitTime) => { + // In standard production builds the Profiler callback + // receives zero for all timing values. Bail out early to + // avoid creating garbage performance entries. + if (actualDuration <= 0) { + return; + } + + // Emit a performance.measure entry for every render so + // the Performance/Timeline panel shows the full render + // timeline when investigating jank, not just outliers. + const measureName = `⚛ ${id} (${phase})`; + try { + performance.measure(measureName, { + start: startTime, + duration: actualDuration, + }); + } catch { + // performance.measure can throw if startTime is invalid + // (e.g. negative or before time origin). Safe to ignore. + } + measureNames.current.add(measureName); + measureCount.current++; + if (measureCount.current >= MAX_MEASURE_ENTRIES) { + for (const name of measureNames.current) { + performance.clearMeasures(name); + } + measureNames.current.clear(); + measureCount.current = 0; + } + + if (actualDuration <= SLOW_RENDER_THRESHOLD_MS) { + return; + } + + const now = performance.now(); + if (now - lastWarnTime.current < WARN_THROTTLE_MS) { + return; + } + lastWarnTime.current = now; + + // actualDuration covers the render phase only. The commit + // offset (commitTime - startTime) includes yield/suspend + // time in concurrent React, so it can be larger. + console.warn( + `[Slow render] %c${id}%c ${phase}: ` + + `${actualDuration.toFixed(1)}ms actual, ` + + `${baseDuration.toFixed(1)}ms base ` + + `(commit ${(commitTime - startTime).toFixed(1)}ms after start)`, + "font-weight: bold", + "font-weight: normal", + ); + }, + [], + ); +} diff --git a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx index 265215775a..19e138320b 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx @@ -1,7 +1,7 @@ import type * as TypesGen from "api/typesGenerated"; import type { ModelSelectorOption } from "components/ai-elements"; import { useDashboard } from "modules/dashboard/useDashboard"; -import { type FC, useEffect } from "react"; +import { type FC, Profiler, useEffect } from "react"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; import { useFileAttachments } from "../hooks/useFileAttachments"; @@ -32,6 +32,7 @@ import { } from "./AgentDetail/messageParsing"; import { buildStreamTools } from "./AgentDetail/streamState"; import type { ParsedMessageEntry } from "./AgentDetail/types"; +import { useOnRenderProfiler } from "./AgentDetail/useOnRenderProfiler"; type ChatStoreHandle = ReturnType["store"]; @@ -145,6 +146,7 @@ const StreamingBridge: FC<{ }) => { const streamState = useChatSelector(store, selectStreamState); const streamTools = buildStreamTools(streamState); + const onRenderProfiler = useOnRenderProfiler(); const isAwaitingFirstStreamChunk = !streamState && (chatStatus === "running" || chatStatus === "pending") && @@ -152,22 +154,24 @@ const StreamingBridge: FC<{ const hasStreamOutput = Boolean(streamState) || isAwaitingFirstStreamChunk; return ( - + + + ); };