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

@@ -30,7 +30,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.

View File

@@ -187,6 +187,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
---

View File

@@ -191,6 +191,7 @@ Notes:
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
---

View File

@@ -191,6 +191,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
---

View File

@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { setCorsHeaders, sendNoContent } from '../route-utils';
import { parseJsonBody } from '../route-utils';
import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
@@ -23,6 +25,13 @@ export async function handleAppRoutes(
return true;
}
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
if (req.method === 'OPTIONS') {
sendNoContent(res);
return true;

View File

@@ -13,7 +13,7 @@ import { createMenu } from './menu';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
import { warmupNetworkOptimization } from '../utils/uv-env';
import { initTelemetry, shutdownTelemetry } from '../utils/telemetry';
import { initTelemetry } from '../utils/telemetry';
import { ClawHubService } from '../gateway/clawhub';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
@@ -392,10 +392,6 @@ app.on('before-quit', () => {
setQuitting();
hostEventBus.closeAll();
hostApiServer?.close();
// Flush telemetry data
void shutdownTelemetry().catch((err) => {
logger.warn('Failed to shutdown telemetry:', err);
});
// Fire-and-forget: do not await gatewayManager.stop() here.
// Awaiting inside before-quit can stall Electron's quit sequence.
void gatewayManager.stop().catch((err) => {

View File

@@ -0,0 +1,158 @@
import { app, utilityProcess } from 'electron';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
import { logger } from './logger';
import { getUvMirrorEnv } from './uv-env';
const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000;
const OPENCLAW_DOCTOR_ARGS = ['doctor', '--json'];
const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive'];
export type OpenClawDoctorMode = 'diagnose' | 'fix';
export interface OpenClawDoctorResult {
mode: OpenClawDoctorMode;
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
}
function getBundledBinPath(): string {
const target = `${process.platform}-${process.arch}`;
return app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
}
async function runDoctorCommand(mode: OpenClawDoctorMode): Promise<OpenClawDoctorResult> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
const args = mode === 'fix' ? OPENCLAW_DOCTOR_FIX_ARGS : OPENCLAW_DOCTOR_ARGS;
const command = `openclaw ${args.join(' ')}`;
const startedAt = Date.now();
if (!existsSync(entryScript)) {
const error = `OpenClaw entry script not found at ${entryScript}`;
logger.error(`Cannot run OpenClaw doctor: ${error}`);
return {
mode,
success: false,
exitCode: null,
stdout: '',
stderr: '',
command,
cwd: openclawDir,
durationMs: Date.now() - startedAt,
error,
};
}
const binPath = getBundledBinPath();
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const uvEnv = await getUvMirrorEnv();
logger.info(
`Running OpenClaw doctor (mode=${mode}, entry="${entryScript}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
);
return await new Promise<OpenClawDoctorResult>((resolve) => {
const child = utilityProcess.fork(entryScript, args, {
cwd: openclawDir,
stdio: 'pipe',
env: {
...process.env,
...uvEnv,
PATH: finalPath,
OPENCLAW_NO_RESPAWN: '1',
} as NodeJS.ProcessEnv,
});
let stdout = '';
let stderr = '';
let settled = false;
const finish = (result: Omit<OpenClawDoctorResult, 'durationMs'>) => {
if (settled) return;
settled = true;
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timeout = setTimeout(() => {
logger.error(`OpenClaw doctor timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`);
try {
child.kill();
} catch {
// ignore
}
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd: openclawDir,
timedOut: true,
error: `Timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`,
});
}, OPENCLAW_DOCTOR_TIMEOUT_MS);
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (error) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor process:', error);
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd: openclawDir,
error: error instanceof Error ? error.message : String(error),
});
});
child.on('exit', (code) => {
clearTimeout(timeout);
logger.info(`OpenClaw doctor exited with code ${code ?? 'null'}`);
finish({
mode,
success: code === 0,
exitCode: code,
stdout,
stderr,
command,
cwd: openclawDir,
});
});
});
}
export async function runOpenClawDoctor(): Promise<OpenClawDoctorResult> {
return await runDoctorCommand('diagnose');
}
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
return await runDoctorCommand('fix');
}

View File

@@ -6,10 +6,32 @@ import { logger } from './logger';
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
const POSTHOG_HOST = 'https://us.i.posthog.com';
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
let posthogClient: PostHog | null = null;
let distinctId: string = '';
function isIgnorablePostHogShutdownError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = `${error.name} ${error.message}`.toLowerCase();
if (
message.includes('posthogfetchnetworkerror') ||
message.includes('network error while fetching posthog') ||
message.includes('timeouterror') ||
message.includes('aborted due to timeout') ||
message.includes('fetch failed')
) {
return true;
}
return 'cause' in error && error.cause !== error
? isIgnorablePostHogShutdownError(error.cause)
: false;
}
/**
* Initialize PostHog telemetry
*/
@@ -65,15 +87,51 @@ export async function initTelemetry(): Promise<void> {
}
/**
* Ensure PostHog flushes all pending events before shutting down
* Best-effort telemetry shutdown that never blocks app exit on network issues.
*/
export async function shutdownTelemetry(): Promise<void> {
if (posthogClient) {
try {
await posthogClient.shutdown();
logger.debug('Flushed telemetry events on shutdown');
} catch (error) {
logger.error('Error shutting down telemetry:', error);
const client = posthogClient;
posthogClient = null;
distinctId = '';
if (!client) {
return;
}
let didTimeout = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const shutdownPromise = client.shutdown().catch((error) => {
if (isIgnorablePostHogShutdownError(error)) {
logger.debug('Ignored telemetry shutdown network error:', error);
return;
}
throw error;
});
try {
await Promise.race([
shutdownPromise,
new Promise<void>((resolve) => {
timeoutHandle = setTimeout(() => {
didTimeout = true;
resolve();
}, TELEMETRY_SHUTDOWN_TIMEOUT_MS);
}),
]);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (didTimeout) {
logger.debug(`Skipped waiting for telemetry shutdown after ${TELEMETRY_SHUTDOWN_TIMEOUT_MS}ms`);
return;
}
logger.debug('Flushed telemetry events on shutdown');
} catch (error) {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
logger.error('Error shutting down telemetry:', error);
}
}

View File

@@ -16,7 +16,7 @@ export interface TokenUsageHistoryEntry {
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
return fileName
.replace(/\.jsonl\.reset\..+$/, '')
.replace(/\.reset\..+$/, '')
.replace(/\.deleted\.jsonl$/, '')
.replace(/\.jsonl$/, '');
}

View File

@@ -15,12 +15,45 @@ export {
type TokenUsageHistoryEntry,
} from './token-usage-core';
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
const agentIds = new Set<string>();
try {
for (const agentId of await listConfiguredAgentIds()) {
const normalized = agentId.trim();
if (normalized) {
agentIds.add(normalized);
}
}
} catch {
// Ignore config discovery failures and fall back to disk scan.
}
try {
const agentEntries = await readdir(agentsDir, { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isDirectory()) {
const normalized = entry.name.trim();
if (normalized) {
agentIds.add(normalized);
}
}
}
} catch {
// Ignore disk discovery failures and return whatever we already found.
}
return [...agentIds];
}
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
try {
const agentEntries = await listConfiguredAgentIds();
const agentEntries = await listAgentIdsWithSessionDirs();
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
for (const agentId of agentEntries) {

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>

View File

@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
const runOpenClawDoctorMock = vi.fn();
const runOpenClawDoctorFixMock = vi.fn();
const sendJsonMock = vi.fn();
const sendNoContentMock = vi.fn();
vi.mock('@electron/utils/openclaw-doctor', () => ({
runOpenClawDoctor: (...args: unknown[]) => runOpenClawDoctorMock(...args),
runOpenClawDoctorFix: (...args: unknown[]) => runOpenClawDoctorFixMock(...args),
}));
vi.mock('@electron/api/route-utils', () => ({
setCorsHeaders: vi.fn(),
parseJsonBody: vi.fn().mockResolvedValue({}),
sendJson: (...args: unknown[]) => sendJsonMock(...args),
sendNoContent: (...args: unknown[]) => sendNoContentMock(...args),
}));
describe('handleAppRoutes', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('runs openclaw doctor through the host api', async () => {
runOpenClawDoctorMock.mockResolvedValueOnce({ success: true, exitCode: 0 });
const { handleAppRoutes } = await import('@electron/api/routes/app');
const handled = await handleAppRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
{} as never,
);
expect(handled).toBe(true);
expect(runOpenClawDoctorMock).toHaveBeenCalledTimes(1);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true, exitCode: 0 });
});
it('runs openclaw doctor fix when requested', async () => {
const { parseJsonBody } = await import('@electron/api/route-utils');
vi.mocked(parseJsonBody).mockResolvedValueOnce({ mode: 'fix' });
runOpenClawDoctorFixMock.mockResolvedValueOnce({ success: false, exitCode: 1 });
const { handleAppRoutes } = await import('@electron/api/routes/app');
const handled = await handleAppRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
{} as never,
);
expect(handled).toBe(true);
expect(runOpenClawDoctorFixMock).toHaveBeenCalledTimes(1);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: false, exitCode: 1 });
});
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
filterUsageHistoryByWindow,
groupUsageHistory,
type UsageHistoryEntry,
} from '@/pages/Models/usage-history';
function createEntry(day: number, totalTokens: number): UsageHistoryEntry {
return {
timestamp: `2026-03-${String(day).padStart(2, '0')}T12:00:00.000Z`,
sessionId: `session-${day}`,
agentId: 'main',
model: 'gpt-5',
inputTokens: totalTokens,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens,
};
}
describe('models usage history helpers', () => {
it('keeps all day buckets instead of truncating to the first eight', () => {
const entries = Array.from({ length: 12 }, (_, index) => createEntry(index + 1, index + 1));
const groups = groupUsageHistory(entries, 'day');
expect(groups).toHaveLength(12);
expect(groups[0]?.totalTokens).toBe(1);
expect(groups[11]?.totalTokens).toBe(12);
});
it('limits model buckets to the top eight by total tokens', () => {
const entries = Array.from({ length: 10 }, (_, index) => ({
...createEntry(index + 1, index + 1),
model: `model-${index + 1}`,
}));
const groups = groupUsageHistory(entries, 'model');
expect(groups).toHaveLength(8);
expect(groups[0]?.label).toBe('model-10');
expect(groups[7]?.label).toBe('model-3');
});
it('filters the last 30 days relative to now instead of calendar month boundaries', () => {
const now = Date.parse('2026-03-12T12:00:00.000Z');
const entries = [
{
...createEntry(12, 12),
timestamp: '2026-03-12T12:00:00.000Z',
},
{
...createEntry(11, 11),
timestamp: '2026-02-11T12:00:00.000Z',
},
{
...createEntry(10, 10),
timestamp: '2026-02-10T11:59:59.000Z',
},
];
const filtered = filterUsageHistoryByWindow(entries, '30d', now);
expect(filtered).toHaveLength(2);
expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]);
});
});

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
shutdownMock,
captureMock,
getSettingMock,
setSettingMock,
loggerDebugMock,
loggerErrorMock,
} = vi.hoisted(() => ({
shutdownMock: vi.fn(),
captureMock: vi.fn(),
getSettingMock: vi.fn(),
setSettingMock: vi.fn(),
loggerDebugMock: vi.fn(),
loggerErrorMock: vi.fn(),
}));
vi.mock('posthog-node', () => ({
PostHog: vi.fn(function PostHogMock() {
return {
capture: captureMock,
shutdown: shutdownMock,
};
}),
}));
vi.mock('@electron/utils/store', () => ({
getSetting: getSettingMock,
setSetting: setSettingMock,
}));
vi.mock('@electron/utils/logger', () => ({
logger: {
debug: loggerDebugMock,
error: loggerErrorMock,
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('electron', () => ({
app: {
getVersion: () => '0.2.1',
},
}));
vi.mock('node-machine-id', () => ({
machineIdSync: () => 'machine-id-1',
}));
describe('main telemetry shutdown', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
getSettingMock.mockImplementation(async (key: string) => {
switch (key) {
case 'telemetryEnabled':
return true;
case 'machineId':
return 'existing-machine-id';
case 'hasReportedInstall':
return true;
default:
return undefined;
}
});
setSettingMock.mockResolvedValue(undefined);
captureMock.mockReturnValue(undefined);
});
it('ignores PostHog network timeout errors during shutdown', async () => {
shutdownMock.mockRejectedValueOnce(
Object.assign(new Error('Network error while fetching PostHog'), {
name: 'PostHogFetchNetworkError',
cause: Object.assign(new Error('The operation was aborted due to timeout'), {
name: 'TimeoutError',
}),
}),
);
const { initTelemetry, shutdownTelemetry } = await import('@electron/utils/telemetry');
await initTelemetry();
await shutdownTelemetry();
expect(loggerErrorMock).not.toHaveBeenCalled();
expect(loggerDebugMock).toHaveBeenCalledWith(
'Ignored telemetry shutdown network error:',
expect.objectContaining({ name: 'PostHogFetchNetworkError' }),
);
});
});

View File

@@ -14,6 +14,10 @@ describe('extractSessionIdFromTranscriptFileName', () => {
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
});
it('parses deleted reset transcript names', () => {
expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
});
it('returns undefined for non-transcript files', () => {
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();

View File

@@ -0,0 +1,89 @@
import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { testHome, testUserData } = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
return {
testHome: `/tmp/clawx-token-usage-${suffix}`,
testUserData: `/tmp/clawx-token-usage-user-data-${suffix}`,
};
});
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os');
const mocked = {
...actual,
homedir: () => testHome,
};
return {
...mocked,
default: mocked,
};
});
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: () => testUserData,
getVersion: () => '0.0.0-test',
},
}));
describe('token usage session scan', () => {
beforeEach(async () => {
vi.resetModules();
vi.restoreAllMocks();
await rm(testHome, { recursive: true, force: true });
await rm(testUserData, { recursive: true, force: true });
});
it('includes transcripts from agent directories that exist on disk but are not configured', async () => {
const openclawDir = join(testHome, '.openclaw');
await mkdir(openclawDir, { recursive: true });
await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
],
},
}, null, 2), 'utf8');
const diskOnlySessionsDir = join(openclawDir, 'agents', 'custom-custom25', 'sessions');
await mkdir(diskOnlySessionsDir, { recursive: true });
await writeFile(
join(diskOnlySessionsDir, 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'),
[
JSON.stringify({
type: 'message',
timestamp: '2026-03-12T12:19:00.000Z',
message: {
role: 'assistant',
model: 'gpt-5.2-2025-12-11',
provider: 'openai',
usage: {
input: 17649,
output: 107,
total: 17756,
},
},
}),
].join('\n'),
'utf8',
);
const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage');
const entries = await getRecentTokenUsageHistory();
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
agentId: 'custom-custom25',
sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a',
model: 'gpt-5.2-2025-12-11',
totalTokens: 17756,
}),
]),
);
});
});