diff --git a/src/doctor.ts b/src/doctor.ts index 5db1e80e..10cf8e1b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -316,7 +316,7 @@ async function collectLogInfo(): Promise { return { requestsLast24h: stats.totalRequests, costLast24h: `$${stats.totalCost.toFixed(4)}`, - errorsFound: 0, // TODO: parse error logs + errorsFound: stats.errorCount, }; } catch { return { diff --git a/src/stats.error-status.test.ts b/src/stats.error-status.test.ts new file mode 100644 index 00000000..425e2ccf --- /dev/null +++ b/src/stats.error-status.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("usage log error status", () => { + let home: string; + + beforeEach(async () => { + vi.resetModules(); + home = await mkdtemp(join(tmpdir(), "clawrouter-error-status-")); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => home }; + }); + const logs = join(home, ".openclaw", "blockrun", "logs"); + await mkdir(logs, { recursive: true }); + const lines = [ + JSON.stringify({ + timestamp: "2026-10-24T10:00:00.000Z", + model: "gpt-4", + tier: "PREMIUM", + cost: 0.01, + baselineCost: 0.02, + savings: 0.5, + latencyMs: 120, + status: "success", + }), + JSON.stringify({ + timestamp: "2026-10-24T10:01:00.000Z", + model: "gpt-4", + tier: "PREMIUM", + cost: 0, + baselineCost: 0.02, + savings: 0, + latencyMs: 50, + status: "error", + }), + ].join("\n"); + await writeFile(join(logs, "usage-2026-10-24.jsonl"), lines); + }); + + afterEach(async () => { + vi.doUnmock("node:os"); + await rm(home, { recursive: true, force: true }); + }); + + it("counts error entries and marks them in recent log output", async () => { + const { getStats, formatRecentLogs } = await import("./stats.js"); + + const stats = await getStats(1); + expect(stats.totalRequests).toBe(2); + expect(stats.errorCount).toBe(1); + + const recent = await formatRecentLogs(1); + expect(recent).toContain("ERROR"); + }); +}); diff --git a/src/stats.ts b/src/stats.ts index 37df65a7..98404989 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -64,6 +64,7 @@ export type AggregatedStats = { byModel: Record; dailyBreakdown: DailyStats[]; entriesWithBaseline: number; // Entries with valid baseline tracking + errorCount: number; }; /** @@ -86,6 +87,7 @@ async function parseLogFile(filePath: string): Promise { baselineCost: entry.baselineCost || entry.cost || 0, savings: entry.savings || 0, latencyMs: entry.latencyMs || 0, + status: entry.status === "error" || entry.status === "success" ? entry.status : undefined, }); } catch { // Skip malformed lines, keep valid ones @@ -163,6 +165,7 @@ export async function getStats(days: number = 7): Promise { let totalCost = 0; let totalBaselineCost = 0; let totalLatency = 0; + let errorCount = 0; for (const file of filesToRead) { const date = file.replace("usage-", "").replace(".jsonl", ""); @@ -171,6 +174,12 @@ export async function getStats(days: number = 7): Promise { if (entries.length === 0) continue; + for (const entry of entries) { + if (entry.status === "error") { + errorCount++; + } + } + const dayStats = aggregateDay(date, entries); dailyBreakdown.push(dayStats); @@ -237,6 +246,7 @@ export async function getStats(days: number = 7): Promise { byModel: byModelWithPercentage, dailyBreakdown: dailyBreakdown.reverse(), // Oldest first for charts entriesWithBaseline, // How many entries have valid baseline tracking + errorCount, }; } @@ -367,8 +377,7 @@ export async function formatRecentLogs(requestedDays: number = 1): Promise 24 ? e.model.slice(0, 21) + "..." : e.model; const cost = `$${e.cost.toFixed(4)}`; const ms = e.latencyMs > 9999 ? `${(e.latencyMs / 1000).toFixed(1)}s` : `${e.latencyMs}ms`; - const status = - (e as UsageEntry & { status?: string }).status === "error" ? " ERROR " : " OK "; + const status = e.status === "error" ? " ERROR " : " OK "; totalCost += e.cost; lines.push( `║ ${displayTime.padEnd(16)}║ ${model.padEnd(24)}║ ${cost.padStart(7)}║ ${ms.padStart(4)}║${status}║`,