feat: add control UI responsiveness diagnostics

This commit is contained in:
Peter Steinberger
2026-05-04 08:04:10 +01:00
parent fbf9132b32
commit 826786b114
6 changed files with 303 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure.
- Control UI/performance: record browser long animation frame or long task entries in the debug event log when supported, making slow dashboard renders easier to attribute from the UI.
- Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams.
- Slack/streaming: add `streaming.progress.render: "rich"` for Block Kit progress drafts backed by structured progress line data.
- Slack/streaming: keep the newest rich progress lines when Block Kit limits trim long progress drafts. Thanks @vincentkoc.

View File

@@ -127,6 +127,7 @@ Imported themes are stored only in the current browser profile. They are not wri
</Accordion>
<Accordion title="Debug, logs, update">
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
- The event log includes Control UI refresh/RPC timings plus browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types.
- Logs: live tail of gateway file logs with filter/export (`logs.tail`).
- Update: run a package/git update + restart (`update.run`) with a restart report, then poll `update.status` after reconnect to verify the running gateway version.

View File

@@ -15,6 +15,7 @@ import {
syncTabWithLocation,
syncThemeWithSettings,
} from "./app-settings.ts";
import { startControlUiResponsivenessObserver } from "./control-ui-performance.ts";
import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts";
import type { Tab } from "./navigation.ts";
@@ -52,6 +53,7 @@ type LifecycleHost = {
chatScrollTimeout?: number | null;
logsScrollFrame?: number | null;
controlUiTabPaintSeq?: number;
controlUiResponsivenessObserver?: { disconnect: () => void } | null;
popStateHandler: () => void;
topbarObserver: ResizeObserver | null;
};
@@ -77,6 +79,9 @@ export function handleConnected(host: LifecycleHost) {
if (host.tab === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
}
host.controlUiResponsivenessObserver ??= startControlUiResponsivenessObserver(
host as unknown as Parameters<typeof startControlUiResponsivenessObserver>[0],
);
}
export function handleFirstUpdated(host: LifecycleHost) {
@@ -120,6 +125,8 @@ export function handleDisconnected(host: LifecycleHost) {
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
host.topbarObserver?.disconnect();
host.topbarObserver = null;
host.controlUiResponsivenessObserver?.disconnect();
host.controlUiResponsivenessObserver = null;
}
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {

View File

@@ -564,6 +564,7 @@ export class OpenClawApp extends LitElement {
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;
private logsScrollFrame: number | null = null;
private controlUiResponsivenessObserver: { disconnect: () => void } | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
refreshSessionsAfterChat = new Set<string>();

View File

@@ -0,0 +1,176 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
recordControlUiPerformanceEvent,
startControlUiResponsivenessObserver,
} from "./control-ui-performance.ts";
const originalPerformanceObserver = globalThis.PerformanceObserver;
type ObserverCallback = ConstructorParameters<typeof PerformanceObserver>[0];
function installPerformanceObserverMock(options: {
supportedEntryTypes: string[];
observe?: (options: PerformanceObserverInit) => void;
}) {
let callback: ObserverCallback | null = null;
const disconnect = vi.fn();
class MockPerformanceObserver {
static supportedEntryTypes = options.supportedEntryTypes;
constructor(nextCallback: ObserverCallback) {
callback = nextCallback;
}
observe(observeOptions: PerformanceObserverInit) {
options.observe?.(observeOptions);
}
disconnect() {
disconnect();
}
}
Object.defineProperty(globalThis, "PerformanceObserver", {
configurable: true,
value: MockPerformanceObserver,
});
return {
disconnect,
emit(entries: PerformanceEntry[]) {
callback?.(
{
getEntries: () => entries,
} as PerformanceObserverEntryList,
{} as PerformanceObserver,
);
},
};
}
function createHost() {
return {
tab: "chat" as const,
eventLog: [] as Array<{ payload: Record<string, unknown> }>,
eventLogBuffer: [] as Array<{ payload: Record<string, unknown> }>,
};
}
afterEach(() => {
Object.defineProperty(globalThis, "PerformanceObserver", {
configurable: true,
value: originalPerformanceObserver,
});
});
describe("recordControlUiPerformanceEvent", () => {
it("keeps the performance event buffer bounded", () => {
const host = createHost();
for (let i = 0; i < 260; i += 1) {
recordControlUiPerformanceEvent(host, "control-ui.test", { i }, { console: false });
}
expect(host.eventLogBuffer).toHaveLength(250);
expect(host.eventLogBuffer[0]?.payload).toEqual({ i: 259 });
expect(host.eventLogBuffer.at(-1)?.payload).toEqual({ i: 10 });
});
});
describe("startControlUiResponsivenessObserver", () => {
it("records long animation frames with script attribution", () => {
const observe = vi.fn();
const mock = installPerformanceObserverMock({
supportedEntryTypes: ["longtask", "long-animation-frame"],
observe,
});
const host = createHost();
const observer = startControlUiResponsivenessObserver(host);
mock.emit([
{
name: "long-frame",
startTime: 12.4,
duration: 83.6,
blockingDuration: 42.2,
scripts: [
{
duration: 12.1,
sourceURL: "http://localhost/assets/a.js?token=redacted",
},
{
duration: 50.8,
invoker: "event-listener",
sourceURL: "http://localhost/assets/app.js?token=redacted#hash",
sourceFunctionName: "renderApp",
},
],
} as unknown as PerformanceEntry,
]);
observer?.disconnect();
expect(observe).toHaveBeenCalledWith({ type: "long-animation-frame", buffered: true });
expect(mock.disconnect).toHaveBeenCalledOnce();
expect(host.eventLogBuffer).toEqual([
expect.objectContaining({
event: "control-ui.long-animation-frame",
payload: expect.objectContaining({
tab: "chat",
name: "long-frame",
startTimeMs: 12,
durationMs: 84,
blockingDurationMs: 42,
scriptCount: 2,
topScript: {
durationMs: 51,
invoker: "event-listener",
sourceUrl: "/assets/app.js",
sourceFunctionName: "renderApp",
},
}),
}),
]);
});
it("falls back to long task entries when long animation frames are unavailable", () => {
const observe = vi.fn();
const mock = installPerformanceObserverMock({
supportedEntryTypes: ["longtask"],
observe,
});
const host = createHost();
startControlUiResponsivenessObserver(host);
mock.emit([
{
name: "self",
startTime: 5,
duration: 51,
} as unknown as PerformanceEntry,
{
name: "small",
startTime: 10,
duration: 49,
} as unknown as PerformanceEntry,
]);
expect(observe).toHaveBeenCalledWith({ type: "longtask", buffered: true });
expect(host.eventLogBuffer).toEqual([
expect.objectContaining({
event: "control-ui.longtask",
payload: expect.objectContaining({
name: "self",
durationMs: 51,
}),
}),
]);
});
it("returns null when responsiveness entries are unsupported or observe fails", () => {
installPerformanceObserverMock({ supportedEntryTypes: [] });
expect(startControlUiResponsivenessObserver(createHost())).toBeNull();
installPerformanceObserverMock({
supportedEntryTypes: ["longtask"],
observe: () => {
throw new Error("unsupported");
},
});
expect(startControlUiResponsivenessObserver(createHost())).toBeNull();
});
});

View File

@@ -21,6 +21,28 @@ export type ControlUiRefreshRun = {
const EVENT_LOG_LIMIT = 250;
const SLOW_RPC_MS = 1_000;
const RESPONSIVENESS_ENTRY_MS = 50;
type ControlUiResponsivenessObserver = {
disconnect: () => void;
};
type PerformanceObserverCtor = {
readonly supportedEntryTypes?: readonly string[];
new (callback: PerformanceObserverCallback): PerformanceObserver;
};
type LongAnimationFrameScriptTiming = {
duration?: number;
invoker?: string;
sourceURL?: string;
sourceFunctionName?: string;
};
type ResponsivenessPerformanceEntry = PerformanceEntry & {
blockingDuration?: number;
scripts?: LongAnimationFrameScriptTiming[];
};
export function controlUiNowMs(): number {
return typeof performance !== "undefined" && typeof performance.now === "function"
@@ -169,3 +191,98 @@ export function recordControlUiRpcTiming(
{ warn },
);
}
function getPerformanceObserverCtor(): PerformanceObserverCtor | null {
const observer = globalThis.PerformanceObserver;
return typeof observer === "function" ? (observer as PerformanceObserverCtor) : null;
}
function normalizeScriptSourceUrl(sourceUrl: string | undefined): string | undefined {
if (!sourceUrl) {
return undefined;
}
try {
const url = new URL(sourceUrl, globalThis.location?.href);
return url.pathname;
} catch {
return sourceUrl.split(/[?#]/, 1)[0];
}
}
function getTopLongAnimationFrameScript(
scripts: LongAnimationFrameScriptTiming[] | undefined,
): Record<string, unknown> | undefined {
if (!Array.isArray(scripts) || scripts.length === 0) {
return undefined;
}
let topScript: LongAnimationFrameScriptTiming | undefined;
for (const script of scripts) {
if (!topScript || (script.duration ?? 0) > (topScript.duration ?? 0)) {
topScript = script;
}
}
if (!topScript) {
return undefined;
}
return {
durationMs: roundedControlUiDurationMs(topScript.duration ?? 0),
invoker: topScript.invoker,
sourceUrl: normalizeScriptSourceUrl(topScript.sourceURL),
sourceFunctionName: topScript.sourceFunctionName,
};
}
function recordResponsivenessEntry(
host: ControlUiPerformanceHost,
entryType: "long-animation-frame" | "longtask",
entry: ResponsivenessPerformanceEntry,
) {
const durationMs = roundedControlUiDurationMs(entry.duration);
if (durationMs < RESPONSIVENESS_ENTRY_MS) {
return;
}
recordControlUiPerformanceEvent(
host,
`control-ui.${entryType}`,
{
tab: host.tab,
name: entry.name,
startTimeMs: roundedControlUiDurationMs(entry.startTime),
durationMs,
blockingDurationMs:
typeof entry.blockingDuration === "number"
? roundedControlUiDurationMs(entry.blockingDuration)
: undefined,
scriptCount: Array.isArray(entry.scripts) ? entry.scripts.length : undefined,
topScript: getTopLongAnimationFrameScript(entry.scripts),
},
{ warn: true },
);
}
export function startControlUiResponsivenessObserver(
host: ControlUiPerformanceHost,
): ControlUiResponsivenessObserver | null {
const Observer = getPerformanceObserverCtor();
const supportedEntryTypes = Observer?.supportedEntryTypes ?? [];
const entryType = supportedEntryTypes.includes("long-animation-frame")
? "long-animation-frame"
: supportedEntryTypes.includes("longtask")
? "longtask"
: null;
if (!Observer || !entryType) {
return null;
}
const observer = new Observer((list) => {
for (const entry of list.getEntries() as ResponsivenessPerformanceEntry[]) {
recordResponsivenessEntry(host, entryType, entry);
}
});
try {
observer.observe({ type: entryType, buffered: true });
} catch {
return null;
}
return observer;
}