Fix telemetry shutdown noise and improve token usage diagnostics (#444)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-13 13:57:49 +08:00
committed by GitHub
Unverified
parent 01adc828b5
commit 995a7f070d
21 changed files with 923 additions and 116 deletions

View File

@@ -219,6 +219,29 @@
"cliPowershell": "PowerShell command.",
"cmdUnavailable": "Command unavailable",
"cmdCopied": "CLI command copied",
"doctor": "OpenClaw Doctor",
"doctorDesc": "Run `openclaw doctor --json` and inspect the raw diagnostic output.",
"runDoctor": "Run Doctor",
"runDoctorFix": "Run Doctor Fix",
"doctorSucceeded": "OpenClaw doctor completed",
"doctorFailed": "OpenClaw doctor reported issues",
"doctorRunFailed": "Failed to run OpenClaw doctor",
"doctorFixSucceeded": "OpenClaw doctor fix completed",
"doctorFixFailed": "OpenClaw doctor fix reported issues",
"doctorFixRunFailed": "Failed to run OpenClaw doctor fix",
"doctorCopied": "Doctor output copied",
"doctorOk": "Healthy",
"doctorIssue": "Issues Found",
"doctorFixOk": "Fixed",
"doctorFixIssue": "Fix Failed",
"doctorExitCode": "Exit code",
"doctorDuration": "Duration",
"doctorCommand": "Command",
"doctorWorkingDir": "Working dir",
"doctorError": "Error",
"doctorStdout": "Stdout",
"doctorStderr": "Stderr",
"doctorOutputEmpty": "(empty)",
"wsDiagnostic": "WS Diagnostic Mode",
"wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.",
"wsDiagnosticEnabled": "WS diagnostic mode enabled",

View File

@@ -216,6 +216,29 @@
"cliPowershell": "PowerShell コマンド。",
"cmdUnavailable": "コマンドが利用できません",
"cmdCopied": "CLI コマンドをコピーしました",
"doctor": "OpenClaw Doctor",
"doctorDesc": "`openclaw doctor --json` を実行して診断の生出力を確認します。",
"runDoctor": "Doctor を実行",
"runDoctorFix": "Doctor 修復を実行",
"doctorSucceeded": "OpenClaw doctor が完了しました",
"doctorFailed": "OpenClaw doctor が問題を報告しました",
"doctorRunFailed": "OpenClaw doctor の実行に失敗しました",
"doctorFixSucceeded": "OpenClaw doctor 修復が完了しました",
"doctorFixFailed": "OpenClaw doctor 修復後も問題が残っています",
"doctorFixRunFailed": "OpenClaw doctor 修復の実行に失敗しました",
"doctorCopied": "診断出力をコピーしました",
"doctorOk": "正常",
"doctorIssue": "問題あり",
"doctorFixOk": "修復済み",
"doctorFixIssue": "修復失敗",
"doctorExitCode": "終了コード",
"doctorDuration": "所要時間",
"doctorCommand": "コマンド",
"doctorWorkingDir": "作業ディレクトリ",
"doctorError": "エラー",
"doctorStdout": "標準出力",
"doctorStderr": "標準エラー",
"doctorOutputEmpty": "(空)",
"wsDiagnostic": "WS 診断モード",
"wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。",
"wsDiagnosticEnabled": "WS 診断モードを有効化しました",

View File

@@ -219,6 +219,29 @@
"cliPowershell": "PowerShell 命令。",
"cmdUnavailable": "命令不可用",
"cmdCopied": "CLI 命令已复制",
"doctor": "OpenClaw Doctor 诊断",
"doctorDesc": "运行 `openclaw doctor --json` 并查看原始诊断输出。",
"runDoctor": "运行 Doctor",
"runDoctorFix": "运行 Doctor 并修复",
"doctorSucceeded": "OpenClaw doctor 已完成",
"doctorFailed": "OpenClaw doctor 检测到问题",
"doctorRunFailed": "运行 OpenClaw doctor 失败",
"doctorFixSucceeded": "OpenClaw doctor 修复已完成",
"doctorFixFailed": "OpenClaw doctor 修复后仍有问题",
"doctorFixRunFailed": "运行 OpenClaw doctor 修复失败",
"doctorCopied": "诊断输出已复制",
"doctorOk": "正常",
"doctorIssue": "发现问题",
"doctorFixOk": "已修复",
"doctorFixIssue": "修复失败",
"doctorExitCode": "退出码",
"doctorDuration": "耗时",
"doctorCommand": "命令",
"doctorWorkingDir": "工作目录",
"doctorError": "错误",
"doctorStdout": "标准输出",
"doctorStderr": "标准错误",
"doctorOutputEmpty": "(空)",
"wsDiagnostic": "WS 诊断模式",
"wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。",
"wsDiagnosticEnabled": "已启用 WS 诊断模式",

View File

@@ -12,25 +12,15 @@ import { hostApiFetch } from '@/lib/host-api';
import { trackUiEvent } from '@/lib/telemetry';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { FeedbackState } from '@/components/common/FeedbackState';
type UsageHistoryEntry = {
timestamp: string;
sessionId: string;
agentId: string;
model?: string;
provider?: string;
content?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
costUsd?: number;
};
type UsageWindow = '7d' | '30d' | 'all';
type UsageGroupBy = 'model' | 'day';
const USAGE_FETCH_MAX_ATTEMPTS = 6;
import {
filterUsageHistoryByWindow,
groupUsageHistory,
type UsageGroupBy,
type UsageHistoryEntry,
type UsageWindow,
} from './usage-history';
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6;
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10;
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
export function Models() {
@@ -38,6 +28,9 @@ export function Models() {
const gatewayStatus = useGatewayStore((state) => state.status);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const isGatewayRunning = gatewayStatus.state === 'running';
const usageFetchMaxAttempts = window.electron.platform === 'win32'
? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS
: DEFAULT_USAGE_FETCH_MAX_ATTEMPTS;
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
@@ -87,7 +80,7 @@ export function Models() {
restartMarker,
});
if (normalized.length === 0 && attempt < USAGE_FETCH_MAX_ATTEMPTS) {
if (normalized.length === 0 && attempt < usageFetchMaxAttempts) {
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
generation,
attempt,
@@ -113,7 +106,7 @@ export function Models() {
restartMarker,
message: error instanceof Error ? error.message : String(error),
});
if (attempt < USAGE_FETCH_MAX_ATTEMPTS) {
if (attempt < usageFetchMaxAttempts) {
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
generation,
attempt,
@@ -143,7 +136,7 @@ export function Models() {
usageFetchTimerRef.current = null;
}
};
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid]);
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
@@ -383,84 +376,6 @@ function formatUsageTimestamp(timestamp: string): string {
}).format(date);
}
function groupUsageHistory(
entries: UsageHistoryEntry[],
groupBy: UsageGroupBy,
): Array<{
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
sortKey: number | string;
}> {
const grouped = new Map<string, {
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
sortKey: number | string;
}>();
for (const entry of entries) {
const label = groupBy === 'model'
? (entry.model || 'Unknown')
: formatUsageDay(entry.timestamp);
const current = grouped.get(label) ?? {
label,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
};
current.totalTokens += entry.totalTokens;
current.inputTokens += entry.inputTokens;
current.outputTokens += entry.outputTokens;
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
grouped.set(label, current);
}
return Array.from(grouped.values())
.sort((a, b) => {
if (groupBy === 'day') {
return Number(a.sortKey) - Number(b.sortKey);
}
return b.totalTokens - a.totalTokens;
})
.slice(0, 8);
}
function formatUsageDay(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(date);
}
function getUsageDaySortKey(timestamp: string): number {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return 0;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function filterUsageHistoryByWindow(entries: UsageHistoryEntry[], window: UsageWindow): UsageHistoryEntry[] {
if (window === 'all') return entries;
const now = Date.now();
const days = window === '7d' ? 7 : 30;
const cutoff = now - days * 24 * 60 * 60 * 1000;
return entries.filter((entry) => {
const timestamp = Date.parse(entry.timestamp);
return Number.isFinite(timestamp) && timestamp >= cutoff;
});
}
function UsageBarChart({
groups,
emptyLabel,

View File

@@ -0,0 +1,93 @@
export type UsageHistoryEntry = {
timestamp: string;
sessionId: string;
agentId: string;
model?: string;
provider?: string;
content?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
costUsd?: number;
};
export type UsageWindow = '7d' | '30d' | 'all';
export type UsageGroupBy = 'model' | 'day';
export type UsageGroup = {
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
sortKey: number | string;
};
export function formatUsageDay(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(date);
}
export function getUsageDaySortKey(timestamp: string): number {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return 0;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
export function groupUsageHistory(
entries: UsageHistoryEntry[],
groupBy: UsageGroupBy,
): UsageGroup[] {
const grouped = new Map<string, UsageGroup>();
for (const entry of entries) {
const label = groupBy === 'model'
? (entry.model || 'Unknown')
: formatUsageDay(entry.timestamp);
const current = grouped.get(label) ?? {
label,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
};
current.totalTokens += entry.totalTokens;
current.inputTokens += entry.inputTokens;
current.outputTokens += entry.outputTokens;
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
grouped.set(label, current);
}
const sorted = Array.from(grouped.values()).sort((a, b) => {
if (groupBy === 'day') {
return Number(a.sortKey) - Number(b.sortKey);
}
return b.totalTokens - a.totalTokens;
});
return groupBy === 'model' ? sorted.slice(0, 8) : sorted;
}
export function filterUsageHistoryByWindow(
entries: UsageHistoryEntry[],
window: UsageWindow,
now = Date.now(),
): UsageHistoryEntry[] {
if (window === 'all') return entries;
const days = window === '7d' ? 7 : 30;
const cutoff = now - days * 24 * 60 * 60 * 1000;
return entries.filter((entry) => {
const timestamp = Date.parse(entry.timestamp);
return Number.isFinite(timestamp) && timestamp >= cutoff;
});
}

View File

@@ -100,6 +100,19 @@ export function Settings() {
const showCliTools = true;
const [showLogs, setShowLogs] = useState(false);
const [logContent, setLogContent] = useState('');
const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null);
const [doctorResult, setDoctorResult] = useState<{
mode: 'diagnose' | 'fix';
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
} | null>(null);
const handleShowLogs = async () => {
try {
@@ -123,6 +136,72 @@ export function Settings() {
}
};
const handleRunOpenClawDoctor = async (mode: 'diagnose' | 'fix') => {
setDoctorRunningMode(mode);
try {
const result = await hostApiFetch<{
mode: 'diagnose' | 'fix';
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
}>('/api/app/openclaw-doctor', {
method: 'POST',
body: JSON.stringify({ mode }),
});
setDoctorResult(result);
if (result.success) {
toast.success(mode === 'fix' ? t('developer.doctorFixSucceeded') : t('developer.doctorSucceeded'));
} else {
toast.error(result.error || (mode === 'fix' ? t('developer.doctorFixFailed') : t('developer.doctorFailed')));
}
} catch (error) {
const message = toUserMessage(error) || (mode === 'fix' ? t('developer.doctorFixRunFailed') : t('developer.doctorRunFailed'));
toast.error(message);
setDoctorResult({
mode,
success: false,
exitCode: null,
stdout: '',
stderr: '',
command: 'openclaw doctor --json',
cwd: '',
durationMs: 0,
error: message,
});
} finally {
setDoctorRunningMode(null);
}
};
const handleCopyDoctorOutput = async () => {
if (!doctorResult) return;
const payload = [
`command: ${doctorResult.command}`,
`cwd: ${doctorResult.cwd}`,
`exitCode: ${doctorResult.exitCode ?? 'null'}`,
`durationMs: ${doctorResult.durationMs}`,
'',
'[stdout]',
doctorResult.stdout.trim() || '(empty)',
'',
'[stderr]',
doctorResult.stderr.trim() || '(empty)',
].join('\n');
try {
await navigator.clipboard.writeText(payload);
toast.success(t('developer.doctorCopied'));
} catch (error) {
toast.error(`Failed to copy doctor output: ${String(error)}`);
}
};
const refreshControlUiInfo = async () => {
@@ -737,6 +816,86 @@ export function Settings() {
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<Label className="text-[14px] font-medium text-foreground">{t('developer.doctor')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('developer.doctorDesc')}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => void handleRunOpenClawDoctor('diagnose')}
disabled={doctorRunningMode !== null}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'diagnose' ? ' animate-spin' : ''}`} />
{doctorRunningMode === 'diagnose' ? t('common:status.running') : t('developer.runDoctor')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => void handleRunOpenClawDoctor('fix')}
disabled={doctorRunningMode !== null}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'fix' ? ' animate-spin' : ''}`} />
{doctorRunningMode === 'fix' ? t('common:status.running') : t('developer.runDoctorFix')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCopyDoctorOutput}
disabled={!doctorResult}
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
>
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
</div>
</div>
{doctorResult && (
<div className="space-y-3 rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-black/5 dark:bg-white/5">
<div className="flex flex-wrap gap-2 text-[12px]">
<Badge variant={doctorResult.success ? 'secondary' : 'destructive'} className="rounded-full px-3 py-1">
{doctorResult.mode === 'fix'
? (doctorResult.success ? t('developer.doctorFixOk') : t('developer.doctorFixIssue'))
: (doctorResult.success ? t('developer.doctorOk') : t('developer.doctorIssue'))}
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1">
{t('developer.doctorExitCode')}: {doctorResult.exitCode ?? 'null'}
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1">
{t('developer.doctorDuration')}: {Math.round(doctorResult.durationMs)}ms
</Badge>
</div>
<div className="space-y-1 text-[12px] text-muted-foreground font-mono break-all">
<p>{t('developer.doctorCommand')}: {doctorResult.command}</p>
<p>{t('developer.doctorWorkingDir')}: {doctorResult.cwd || '-'}</p>
{doctorResult.error && <p>{t('developer.doctorError')}: {doctorResult.error}</p>}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStdout')}</p>
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
{doctorResult.stdout.trim() || t('developer.doctorOutputEmpty')}
</pre>
</div>
<div className="space-y-2">
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStderr')}</p>
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
{doctorResult.stderr.trim() || t('developer.doctorOutputEmpty')}
</pre>
</div>
</div>
</div>
)}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
<div>