Skip to content

Commit 90a5946

Browse files
authored
perf(dashboard): opt-in /control?debug=perf overlay (P0 #1-2) (Th0rgal#437)
Adds a live diagnostics panel for the /control page so we can measure the freezes we've been hunting (DOM growth, SSE noise, reducer cost) without instrumenting a browser session by hand. Behind a query flag so production users never see or pay for it. - `lib/perf-bus.ts`: shared counter bus. Reads `?debug=perf` once at module load, installs a `PerformanceObserver({type:'longtask'})` with a 10s sliding window. Exposes `recordSseBytes`, `recordSseEvent` (received/filtered), `recordReducer`, and a `time(name, fn)` wrapper that pairs `console.time` with cumulative timing. Everything no-ops when the flag is off — one boolean check per call site. - `components/perf-overlay.tsx`: fixed-position panel polling the bus every 500ms. Shows 10s longtask total/max, DOM node count, JS heap used/limit, SSE bytes/s + total, SSE events/s rx vs dropped, and the top 6 reducers by cumulative ms. Color-codes longtask > 2s, DOM > 5k, heap > 50% of limit. - `control-client.tsx`: mounts the overlay near the root, hooks SSE byte counts via the diagnostics callback, marks received/ filtered events at the cross-mission filter site, and wraps the historical replay (`eventsToItems` → `replay:apply`) plus the per-render view derivation (`deriveItemViews` → `replay:group`) in `perfBus.time`. The same timings show up as named entries in Chrome's Performance panel via console.time/timeEnd. Usage: append `?debug=perf` to any /control URL.
1 parent ebeb35d commit 90a5946

3 files changed

Lines changed: 300 additions & 2 deletions

File tree

dashboard/src/app/control/control-client.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
type FilePasteContext,
1414
} from "@/components/enhanced-input";
1515
import { MissionAutomationsDialog } from "@/components/mission-automations-dialog";
16+
import { PerfOverlay } from "@/components/perf-overlay";
17+
import { perfBus } from "@/lib/perf-bus";
1618
import { MissionDebugStats } from "./MissionDebugStats";
1719
import { LazyCodeBlock } from "@/components/lazy-code-block";
1820
import { LazyJsonHighlighter } from "@/components/lazy-json-highlighter";
@@ -4044,7 +4046,10 @@ export default function ControlClient() {
40444046
// block the main thread for several seconds, so feed it through
40454047
// `useDeferredValue`: the toggle button + panel mount react instantly
40464048
// while the chat-list regrouping is treated as a non-urgent transition.
4047-
() => deriveItemViews(items, deferredShowThinkingPanel),
4049+
() =>
4050+
perfBus.time("replay:group", () =>
4051+
deriveItemViews(items, deferredShowThinkingPanel)
4052+
),
40484053
[items, deferredShowThinkingPanel]
40494054
);
40504055

@@ -4812,6 +4817,12 @@ export default function ControlClient() {
48124817
// Convert stored events (from SQLite) to ChatItems for display
48134818
// This enables full history replay including tool calls on page refresh
48144819
const eventsToItems = useCallback((events: StoredEvent[], mission?: Mission | null): ChatItem[] => {
4820+
return perfBus.time("replay:apply", () => eventsToItemsImpl(events, mission));
4821+
}, []);
4822+
4823+
// Implementation extracted so the perf wrapper above stays one-liner; the
4824+
// function body is unchanged from the original eventsToItems.
4825+
function eventsToItemsImpl(events: StoredEvent[], mission?: Mission | null): ChatItem[] {
48154826
const items: ChatItem[] = [];
48164827
const toolCallMap = new Map<string, number>(); // tool_call_id -> index in items
48174828
// Track seen event IDs to prevent duplicate items (backend may store duplicates)
@@ -5061,7 +5072,7 @@ export default function ControlClient() {
50615072
}
50625073

50635074
return items;
5064-
}, []);
5075+
}
50655076

50665077
renderDeferredTraceRef.current = (id, traceEvents, maxSequence) => {
50675078
if (traceEvents.length === 0) return;
@@ -5661,6 +5672,9 @@ export default function ControlClient() {
56615672
}, []);
56625673

56635674
const handleStreamDiagnostics = useCallback((update: StreamDiagnosticUpdate) => {
5675+
if (typeof update.bytes === "number") {
5676+
perfBus.recordSseBytes(update.bytes);
5677+
}
56645678
switch (update.phase) {
56655679
case "connecting":
56665680
streamLog("info", "connecting", { url: update.url });
@@ -6670,6 +6684,7 @@ export default function ControlClient() {
66706684
? String(data["mission_id"])
66716685
: null;
66726686
const currentMissionId = currentMissionRef.current?.id;
6687+
perfBus.recordSseEvent("received");
66736688
streamLog("debug", "received", {
66746689
type: event.type,
66756690
eventMissionId,
@@ -6700,6 +6715,7 @@ export default function ControlClient() {
67006715
}
67016716
}
67026717
if (filterReason) {
6718+
perfBus.recordSseEvent("filtered");
67036719
streamLog("debug", "filtered", {
67046720
type: event.type,
67056721
eventMissionId,
@@ -8530,6 +8546,10 @@ export default function ControlClient() {
85308546
publishes a CustomEvent the parent listens to for shedding. */}
85318547
<MissionDebugStats items={items} visibleItems={visibleItemsLimit} />
85328548

8549+
{/* Opt-in perf overlay — `?debug=perf` only. Mounts no work in normal
8550+
sessions; the bus and observer self-disable when the flag is off. */}
8551+
<PerfOverlay />
8552+
85338553
{/* Hidden file input */}
85348554
<input
85358555
ref={fileInputRef}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { perfBus, type ReducerSample } from "@/lib/perf-bus";
5+
6+
type Snapshot = {
7+
longtaskTotalMs: number;
8+
longtaskMaxMs: number;
9+
longtaskCount: number;
10+
domNodes: number;
11+
heapUsedMB: number | null;
12+
heapLimitMB: number | null;
13+
sseBytesPerSec: number;
14+
sseTotalKb: number;
15+
sseEventsPerSec: number;
16+
sseFilteredPerSec: number;
17+
reducers: { name: string; sample: ReducerSample }[];
18+
};
19+
20+
type MemoryInfo = { usedJSHeapSize: number; jsHeapSizeLimit: number };
21+
22+
function readHeap(): MemoryInfo | null {
23+
const mem = (performance as unknown as { memory?: MemoryInfo }).memory;
24+
return mem && typeof mem.usedJSHeapSize === "number" ? mem : null;
25+
}
26+
27+
/**
28+
* Fixed-position diagnostics panel. Only mounts when `?debug=perf` is set on
29+
* the URL (the bus reads the flag once at module load). Polls the bus every
30+
* 500 ms; everything is best-effort and silently degrades on Safari/Firefox
31+
* (no `PerformanceObserver` for longtask, no `performance.memory`).
32+
*/
33+
export function PerfOverlay() {
34+
const [snap, setSnap] = useState<Snapshot | null>(null);
35+
36+
useEffect(() => {
37+
if (!perfBus.enabled) return;
38+
39+
let prevBytes = perfBus.sseBytes;
40+
let prevReceived = perfBus.sseEventsReceived;
41+
let prevFiltered = perfBus.sseEventsFiltered;
42+
let prevAt = performance.now();
43+
44+
const tick = () => {
45+
const now = performance.now();
46+
perfBus.pruneLongtasks(now);
47+
const dt = (now - prevAt) / 1000;
48+
const heap = readHeap();
49+
const reducers = [...perfBus.reducerTimings.entries()]
50+
.map(([name, sample]) => ({ name, sample }))
51+
.sort((a, b) => b.sample.totalMs - a.sample.totalMs)
52+
.slice(0, 6);
53+
54+
setSnap({
55+
longtaskTotalMs: perfBus.longtasks.reduce((a, b) => a + b.d, 0),
56+
longtaskMaxMs: perfBus.longtasks.reduce(
57+
(a, b) => (b.d > a ? b.d : a),
58+
0
59+
),
60+
longtaskCount: perfBus.longtasks.length,
61+
domNodes: document.getElementsByTagName("*").length,
62+
heapUsedMB: heap ? heap.usedJSHeapSize / 1024 / 1024 : null,
63+
heapLimitMB: heap ? heap.jsHeapSizeLimit / 1024 / 1024 : null,
64+
sseBytesPerSec: dt > 0 ? (perfBus.sseBytes - prevBytes) / dt : 0,
65+
sseTotalKb: perfBus.sseBytes / 1024,
66+
sseEventsPerSec:
67+
dt > 0 ? (perfBus.sseEventsReceived - prevReceived) / dt : 0,
68+
sseFilteredPerSec:
69+
dt > 0 ? (perfBus.sseEventsFiltered - prevFiltered) / dt : 0,
70+
reducers,
71+
});
72+
73+
prevBytes = perfBus.sseBytes;
74+
prevReceived = perfBus.sseEventsReceived;
75+
prevFiltered = perfBus.sseEventsFiltered;
76+
prevAt = now;
77+
};
78+
79+
tick();
80+
const id = window.setInterval(tick, 500);
81+
return () => window.clearInterval(id);
82+
}, []);
83+
84+
if (!perfBus.enabled) return null;
85+
if (!snap) {
86+
return (
87+
<div className="fixed right-2 top-2 z-[9999] rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white">
88+
perf overlay loading…
89+
</div>
90+
);
91+
}
92+
93+
const fmtMs = (ms: number) =>
94+
ms < 1 ? "<1ms" : ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(1)}s`;
95+
const fmtRate = (n: number) => (n < 10 ? n.toFixed(1) : n.toFixed(0));
96+
const fmtKB = (n: number) =>
97+
n < 1024 ? `${n.toFixed(0)} KB` : `${(n / 1024).toFixed(1)} MB`;
98+
99+
const heapWarn =
100+
snap.heapUsedMB != null && snap.heapLimitMB != null
101+
? snap.heapUsedMB / snap.heapLimitMB > 0.5
102+
: false;
103+
const ltWarn = snap.longtaskTotalMs > 2000;
104+
105+
return (
106+
<div
107+
className="pointer-events-auto fixed right-2 top-2 z-[9999] w-[260px] rounded-md border border-white/10 bg-black/85 px-3 py-2 font-mono text-[11px] leading-tight text-white shadow-lg backdrop-blur"
108+
data-testid="perf-overlay"
109+
>
110+
<div className="mb-1 flex items-center justify-between text-[10px] uppercase tracking-wider text-white/60">
111+
<span>perf · ?debug=perf</span>
112+
<span className={ltWarn ? "text-red-400" : "text-white/40"}>
113+
{snap.longtaskCount} LT/10s
114+
</span>
115+
</div>
116+
<Row
117+
label="Longtask 10s"
118+
value={`${fmtMs(snap.longtaskTotalMs)} (max ${fmtMs(snap.longtaskMaxMs)})`}
119+
warn={ltWarn}
120+
/>
121+
<Row label="DOM nodes" value={snap.domNodes.toLocaleString()} warn={snap.domNodes > 5000} />
122+
{snap.heapUsedMB != null && (
123+
<Row
124+
label="JS heap"
125+
value={`${snap.heapUsedMB.toFixed(0)} / ${snap.heapLimitMB?.toFixed(0) ?? "?"} MB`}
126+
warn={heapWarn}
127+
/>
128+
)}
129+
<Row
130+
label="SSE in"
131+
value={`${fmtKB(snap.sseBytesPerSec / 1)}/s · total ${fmtKB(snap.sseTotalKb)}`}
132+
/>
133+
<Row
134+
label="SSE evt/s"
135+
value={`${fmtRate(snap.sseEventsPerSec)} rx · ${fmtRate(snap.sseFilteredPerSec)} drop`}
136+
/>
137+
{snap.reducers.length > 0 && (
138+
<div className="mt-2 border-t border-white/10 pt-1">
139+
<div className="mb-0.5 text-[10px] uppercase tracking-wider text-white/40">
140+
Reducers (cum)
141+
</div>
142+
{snap.reducers.map(({ name, sample }) => (
143+
<div key={name} className="flex justify-between text-[10px]">
144+
<span className="truncate text-white/70">{name}</span>
145+
<span className="ml-2 shrink-0 text-white/80">
146+
{sample.count}× · {fmtMs(sample.totalMs)} · max {fmtMs(sample.maxMs)}
147+
</span>
148+
</div>
149+
))}
150+
</div>
151+
)}
152+
</div>
153+
);
154+
}
155+
156+
function Row({
157+
label,
158+
value,
159+
warn,
160+
}: {
161+
label: string;
162+
value: string;
163+
warn?: boolean;
164+
}) {
165+
return (
166+
<div className="flex items-baseline justify-between gap-2">
167+
<span className="text-white/60">{label}</span>
168+
<span className={warn ? "text-red-400" : "text-white/90"}>{value}</span>
169+
</div>
170+
);
171+
}

dashboard/src/lib/perf-bus.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Process-wide diagnostic bus for the `?debug=perf` overlay.
3+
*
4+
* The overlay polls this module every 500 ms to render counters; the rest of
5+
* the dashboard (SSE handler, reducers) call `recordX` to push data in.
6+
* Everything no-ops when the URL flag isn't set, so production cost is one
7+
* boolean check per call site.
8+
*/
9+
10+
type SseEventCategory = "received" | "filtered";
11+
12+
export type ReducerSample = {
13+
count: number;
14+
totalMs: number;
15+
maxMs: number;
16+
};
17+
18+
class PerfBus {
19+
enabled = false;
20+
21+
/** Cumulative bytes read from the SSE stream since this tab loaded. */
22+
sseBytes = 0;
23+
sseEventsReceived = 0;
24+
sseEventsFiltered = 0;
25+
26+
/** Reducer name → aggregated timing since tab load. */
27+
reducerTimings = new Map<string, ReducerSample>();
28+
29+
/** Sliding window of longtask entries in the last 10s (start-time, duration). */
30+
longtasks: { t: number; d: number }[] = [];
31+
32+
recordSseBytes(totalBytes: number) {
33+
if (!this.enabled) return;
34+
this.sseBytes = totalBytes;
35+
}
36+
37+
recordSseEvent(category: SseEventCategory) {
38+
if (!this.enabled) return;
39+
if (category === "received") this.sseEventsReceived++;
40+
else this.sseEventsFiltered++;
41+
}
42+
43+
recordReducer(name: string, durationMs: number) {
44+
if (!this.enabled) return;
45+
const entry = this.reducerTimings.get(name) ?? {
46+
count: 0,
47+
totalMs: 0,
48+
maxMs: 0,
49+
};
50+
entry.count++;
51+
entry.totalMs += durationMs;
52+
if (durationMs > entry.maxMs) entry.maxMs = durationMs;
53+
this.reducerTimings.set(name, entry);
54+
}
55+
56+
pruneLongtasks(now = performance.now()) {
57+
const cutoff = now - 10_000;
58+
while (this.longtasks.length && this.longtasks[0].t < cutoff) {
59+
this.longtasks.shift();
60+
}
61+
}
62+
63+
/** Wraps `fn` with a console.time + reducer timing record. Mirrors Chrome's perf panel. */
64+
time<T>(name: string, fn: () => T): T {
65+
if (!this.enabled) return fn();
66+
const t0 = performance.now();
67+
// eslint-disable-next-line no-console
68+
console.time(name);
69+
try {
70+
return fn();
71+
} finally {
72+
// eslint-disable-next-line no-console
73+
console.timeEnd(name);
74+
this.recordReducer(name, performance.now() - t0);
75+
}
76+
}
77+
}
78+
79+
export const perfBus = new PerfBus();
80+
81+
/**
82+
* One-shot install at module load. Behind `typeof window` so the bus stays
83+
* inert during SSR.
84+
*/
85+
if (typeof window !== "undefined") {
86+
try {
87+
const params = new URLSearchParams(window.location.search);
88+
perfBus.enabled = params.get("debug") === "perf";
89+
} catch {
90+
perfBus.enabled = false;
91+
}
92+
93+
if (perfBus.enabled && typeof PerformanceObserver !== "undefined") {
94+
try {
95+
const observer = new PerformanceObserver((list) => {
96+
const now = performance.now();
97+
for (const entry of list.getEntries()) {
98+
perfBus.longtasks.push({ t: entry.startTime, d: entry.duration });
99+
}
100+
perfBus.pruneLongtasks(now);
101+
});
102+
observer.observe({ type: "longtask", buffered: true });
103+
} catch {
104+
// longtask is not implemented in Safari/Firefox — silently degrade.
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)