mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add Profiler instrumentation for agents chat (#23355)
Wraps the chat timeline in React's <Profiler> 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
This commit is contained in:
@@ -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 <Profiler> 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<string>());
|
||||
|
||||
return useCallback<ProfilerOnRenderCallback>(
|
||||
(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",
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useChatStore>["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 (
|
||||
<ConversationTimeline
|
||||
isEmpty={isEmpty}
|
||||
parsedMessages={parsedMessages}
|
||||
hasStreamOutput={hasStreamOutput}
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
retryState={retryState}
|
||||
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
|
||||
detailError={detailError}
|
||||
onEditUserMessage={onEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
savingMessageId={savingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
<Profiler id="AgentChat" onRender={onRenderProfiler}>
|
||||
<ConversationTimeline
|
||||
isEmpty={isEmpty}
|
||||
parsedMessages={parsedMessages}
|
||||
hasStreamOutput={hasStreamOutput}
|
||||
streamState={streamState}
|
||||
streamTools={streamTools}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
retryState={retryState}
|
||||
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
|
||||
detailError={detailError}
|
||||
onEditUserMessage={onEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
savingMessageId={savingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
</Profiler>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user