feat(debug): add fetch requests-in-flight tracker with age percentiles

Adds a 'Requests' tab to the debug panel (Ctrl/Cmd+Shift+D) that wraps
window.fetch while the panel is open to track every request as in-flight
from call to promise settle, sampled once per second over a 5-minute
rolling window.

Charts:
- in-flight request count over time (current + peak)
- p50/p90/p99/max age distribution of currently in-flight requests,
  with the legend below the chart and a y-axis max label for scale

Tracking is fully gated behind the panel: closing it stops the sampling
interval, unwraps window.fetch, and drops all state (zero overhead when
not debugging). i18n keys added to all 9 locales.
This commit is contained in:
Tom Rochette
2026-06-24 05:24:48 +00:00
parent 07e6935a3e
commit ecc70fa15d
12 changed files with 594 additions and 7 deletions
@@ -7,6 +7,7 @@ import { MEMORY_LIMITS } from '@/stores/types/sessionTypes';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug';
import { getRequestsInFlightSnapshot, resetRequestsInFlight, type RequestsInFlightSnapshot } from '@/stores/utils/requestsInFlight';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
@@ -18,7 +19,7 @@ interface DebugPanelProps {
onClose?: () => void;
}
type DebugTab = 'memory' | 'streaming';
type DebugTab = 'memory' | 'streaming' | 'requests';
const formatDuration = (durationMs: number): string => {
if (durationMs < 1000) {
@@ -35,6 +36,10 @@ const formatDuration = (durationMs: number): string => {
return `${minutes}m ${remainderSeconds}s`;
};
// Fixed-width seconds format ("XX.XX s") for the percentile series so the
// legend/labels don't jitter as values change. Pair with `tabular-nums`.
const formatSeconds = (durationMs: number): string => `${(durationMs / 1000).toFixed(2)} s`;
const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => {
return (
<div
@@ -99,6 +104,74 @@ const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; empty
);
};
type LineSeries = { samples: number[]; color: string; filled?: boolean };
const LineChart: React.FC<{
series: LineSeries[];
peak: number;
windowSeconds: number;
ariaLabel: string;
maxLabel: string;
}> = ({ series, peak, windowSeconds, ariaLabel, maxLabel }) => {
const width = windowSeconds;
const height = 56;
const padTop = 4;
const n = series.reduce((max, s) => Math.max(max, s.samples.length), 0);
const scale = peak > 0 ? (height - padTop) / peak : 0;
const xFor = (i: number): number => width - n + i;
const yFor = (v: number): number => height - v * scale;
const baseline = height;
return (
<div className="relative w-full">
<span className="pointer-events-none absolute left-0 top-0 typography-meta text-[var(--surface-muted-foreground)]">{maxLabel}</span>
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="h-14 w-full"
role="img"
aria-label={ariaLabel}
>
<line
x1={0}
y1={baseline}
x2={width}
y2={baseline}
stroke="var(--interactive-border)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
{series.map((s, si) => {
const sn = s.samples.length;
if (sn === 0) return null;
const points = s.samples.map((v, i) => `${xFor(i)},${yFor(v).toFixed(2)}`);
const linePath = `M ${points.join(' L ')}`;
return (
<React.Fragment key={si}>
{s.filled ? (
<path
d={`M ${xFor(0)},${baseline} L ${points.join(' L ')} L ${xFor(sn - 1)},${baseline} Z`}
fill={`color-mix(in srgb, ${s.color} 18%, transparent)`}
stroke="none"
/>
) : null}
<path
d={linePath}
fill="none"
stroke={s.color}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</React.Fragment>
);
})}
</svg>
</div>
);
};
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const { t } = useI18n();
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
@@ -110,6 +183,15 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
const [streamSnapshot, setStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getStreamPerfSnapshot());
const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getVsCodeStreamPerfSnapshot());
const [requestsSnapshot, setRequestsSnapshot] = React.useState<RequestsInFlightSnapshot>(() => getRequestsInFlightSnapshot());
const ageLines = [
{ label: 'p50', current: requestsSnapshot.ageP50, samples: requestsSnapshot.p50Samples, color: 'var(--status-success)' },
{ label: 'p90', current: requestsSnapshot.ageP90, samples: requestsSnapshot.p90Samples, color: 'var(--status-info)' },
{ label: 'p99', current: requestsSnapshot.ageP99, samples: requestsSnapshot.p99Samples, color: 'var(--status-warning)' },
{ label: 'max', current: requestsSnapshot.ageMax, samples: requestsSnapshot.maxSamples, color: 'var(--status-error)' },
];
const countMax = requestsSnapshot.samples.reduce((m, v) => Math.max(m, v), 0);
const percentileMax = ageLines.reduce((m, l) => l.samples.reduce((mm, v) => Math.max(mm, v), m), 0);
const streamMetricCounts = React.useMemo(() => {
const counts = new Map<string, number>();
streamSnapshot.entries.forEach((entry) => {
@@ -130,6 +212,7 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const refresh = () => {
setStreamSnapshot(getStreamPerfSnapshot());
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
setRequestsSnapshot(getRequestsInFlightSnapshot());
};
refresh();
@@ -218,11 +301,10 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{activeTab === 'memory' ? (
<Icon name="database-2" className="h-4 w-4 text-[var(--surface-foreground)]" />
) : (
<Icon name="bar-chart-box" className="h-4 w-4 text-[var(--surface-foreground)]" />
)}
<Icon
name={activeTab === 'memory' ? 'database-2' : activeTab === 'streaming' ? 'bar-chart-box' : 'pulse'}
className="h-4 w-4 text-[var(--surface-foreground)]"
/>
<h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{t('memoryDebugPanel.title')}</h3>
</div>
<div className="flex items-center gap-1">
@@ -244,6 +326,18 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
</Button>
</>
) : null}
{activeTab === 'requests' ? (
<Button
size="xs"
variant="ghost"
onClick={() => {
resetRequestsInFlight();
setRequestsSnapshot(getRequestsInFlightSnapshot());
}}
>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
) : null}
{onClose ? (
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onClose}>
<Icon name="close" className="h-4 w-4" />
@@ -272,6 +366,14 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
>
{t('memoryDebugPanel.tabs.streaming')}
</Button>
<Button
size="sm"
variant={activeTab === 'requests' ? 'secondary' : 'ghost'}
className="flex-1"
onClick={() => setActiveTab('requests')}
>
{t('memoryDebugPanel.tabs.requests')}
</Button>
</div>
{activeTab === 'memory' ? (
@@ -366,7 +468,7 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
</Tooltip>
</div>
</div>
) : (
) : activeTab === 'streaming' ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--interactive-border)] px-3 py-2 typography-meta text-[var(--surface-muted-foreground)]">
<span>
@@ -409,6 +511,70 @@ export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
/>
) : null}
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 typography-meta">
<MetricCard label={t('memoryDebugPanel.requests.totalRequests')} value={`${requestsSnapshot.totalSettled} / ${requestsSnapshot.totalStarted}`} />
<MetricCard
label={t('memoryDebugPanel.requests.tracking')}
value={requestsSnapshot.startedAt ? formatDuration(requestsSnapshot.durationMs) : t('memoryDebugPanel.common.idle')}
/>
</div>
{requestsSnapshot.samples.length === 0 ? (
<div
className="rounded-md p-3 typography-meta text-[var(--surface-muted-foreground)]"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 45%, transparent)' }}
>
{t('memoryDebugPanel.requests.noSamples')}
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between typography-meta">
<span className="text-[var(--surface-muted-foreground)]">{t('memoryDebugPanel.requests.inFlight')}</span>
<span>
<span className="font-medium text-[var(--surface-foreground)]">{requestsSnapshot.inFlight}</span>
<span className="text-[var(--surface-muted-foreground)]"> · {t('memoryDebugPanel.requests.peak')} </span>
<span className="font-medium text-[var(--surface-foreground)]">{requestsSnapshot.peak}</span>
</span>
</div>
<LineChart
series={[{ samples: requestsSnapshot.samples, color: 'var(--status-info)', filled: true }]}
peak={countMax}
windowSeconds={requestsSnapshot.windowSeconds}
ariaLabel={t('memoryDebugPanel.requests.chartLabel', { peak: requestsSnapshot.peak })}
maxLabel={`${countMax}`}
/>
<div className="flex items-center justify-between typography-meta">
<span className="text-[var(--surface-muted-foreground)]">{t('memoryDebugPanel.requests.duration')}</span>
<span className="font-medium tabular-nums text-[var(--surface-foreground)]">{formatSeconds(requestsSnapshot.peakAgeMs)}</span>
</div>
<LineChart
series={ageLines.map((line) => ({ samples: line.samples, color: line.color }))}
peak={percentileMax}
windowSeconds={requestsSnapshot.windowSeconds}
ariaLabel={t('memoryDebugPanel.requests.percentileChartLabel')}
maxLabel={formatSeconds(percentileMax)}
/>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 typography-meta">
{ageLines.map((line) => (
<span key={line.label} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: line.color }} />
<span className="text-[var(--surface-muted-foreground)]">{line.label}</span>
<span className="font-medium tabular-nums text-[var(--surface-foreground)]">{formatSeconds(line.current)}</span>
</span>
))}
</div>
<div className="flex items-center justify-between typography-meta text-[var(--surface-muted-foreground)]">
<span>{t('memoryDebugPanel.requests.windowHint', { seconds: requestsSnapshot.windowSeconds })}</span>
<span>{t('memoryDebugPanel.requests.now')}</span>
</div>
</div>
)}
</div>
)}
</Card>
);