mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Show partial agent session failures in observability UI
This commit is contained in:
@@ -2280,7 +2280,7 @@ export type AgentAnalysisSubagentRow = {
|
||||
|
||||
export type AgentAnalysisTraceRunKind = "agent" | "llm" | "route" | "subagent" | "tool";
|
||||
|
||||
export type AgentAnalysisTraceRunStatus = "error" | "success";
|
||||
export type AgentAnalysisTraceRunStatus = "error" | "partial" | "success";
|
||||
|
||||
export type AgentAnalysisTracePayloadPreview = {
|
||||
kind: "empty" | "json" | "text";
|
||||
|
||||
@@ -2579,7 +2579,11 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace {
|
||||
outputTokens: totals.outputTokens,
|
||||
sessionId,
|
||||
startedAt: isoFromMs(startMs),
|
||||
status: totals.errorCount > 0 ? "error" : "success",
|
||||
status: totals.errorCount === 0
|
||||
? "success"
|
||||
: totals.errorCount === totals.requestCount
|
||||
? "error"
|
||||
: "partial",
|
||||
totalTokens: totals.totalTokens
|
||||
}
|
||||
];
|
||||
|
||||
@@ -987,6 +987,10 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
|
||||
assert.equal(selected.selectedSession?.trace.toolRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.llmRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true);
|
||||
assert.equal(
|
||||
selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status,
|
||||
"success"
|
||||
);
|
||||
|
||||
const inputPayload = await store.getTracePayload({
|
||||
callId: "call-read",
|
||||
@@ -1010,6 +1014,88 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore distinguishes partial session failures from failed sessions", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-"));
|
||||
try {
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const baseTime = Date.now() - 5000;
|
||||
|
||||
async function recordAgentRequest({ offsetMs, requestId, sessionId, statusCode }) {
|
||||
const startedAtMs = baseTime + offsetMs;
|
||||
await store.record({
|
||||
completedAt: new Date(startedAtMs + 100).toISOString(),
|
||||
durationMs: 100,
|
||||
...(statusCode >= 400 ? { error: "upstream request failed" } : {}),
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
providerName: "test-provider",
|
||||
providerProtocol: "openai_chat_completions",
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
messages: [{ content: "continue task", role: "user" }],
|
||||
model: "gpt-test",
|
||||
session_id: sessionId
|
||||
}), "utf8"),
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "openai-codex test",
|
||||
"x-codex-session-id": sessionId
|
||||
},
|
||||
requestId,
|
||||
responseBodyText: JSON.stringify({ model: "gpt-test" }),
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt: new Date(startedAtMs).toISOString(),
|
||||
statusCode,
|
||||
url: "http://127.0.0.1:3456/v1/chat/completions"
|
||||
});
|
||||
}
|
||||
|
||||
await recordAgentRequest({
|
||||
offsetMs: 0,
|
||||
requestId: "mixed-failed",
|
||||
sessionId: "session-mixed",
|
||||
statusCode: 502
|
||||
});
|
||||
await recordAgentRequest({
|
||||
offsetMs: 1000,
|
||||
requestId: "mixed-recovered",
|
||||
sessionId: "session-mixed",
|
||||
statusCode: 200
|
||||
});
|
||||
await recordAgentRequest({
|
||||
offsetMs: 2000,
|
||||
requestId: "failed-only",
|
||||
sessionId: "session-failed",
|
||||
statusCode: 502
|
||||
});
|
||||
|
||||
const mixed = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "session-mixed"
|
||||
});
|
||||
const mixedTrace = mixed.selectedSession?.trace;
|
||||
assert.equal(
|
||||
mixedTrace?.runs.find((run) => run.id === mixedTrace.rootRunId)?.status,
|
||||
"partial"
|
||||
);
|
||||
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "error"), true);
|
||||
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "success"), true);
|
||||
|
||||
const failed = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "session-failed"
|
||||
});
|
||||
const failedTrace = failed.selectedSession?.trace;
|
||||
assert.equal(
|
||||
failedTrace?.runs.find((run) => run.id === failedTrace.rootRunId)?.status,
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore agent analysis cache ratio denominator includes cache tokens when total tokens omit cache", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-cache-ratio-test-"));
|
||||
try {
|
||||
|
||||
@@ -3557,19 +3557,22 @@ function AgentSessionDetailCard({
|
||||
const agentListSurfaceClassName = "rounded-md border border-border/70 bg-card/70 shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
|
||||
const agentListFrameClassName = cn("overflow-auto", agentListSurfaceClassName);
|
||||
const agentListTableClassName = "w-full border-collapse text-left text-[11px]";
|
||||
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur";
|
||||
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur [&_th]:min-w-[64px] [&_th]:whitespace-nowrap";
|
||||
const agentListBodyClassName = "divide-y divide-border/50";
|
||||
|
||||
function agentListRowClassName({
|
||||
danger,
|
||||
selected
|
||||
selected,
|
||||
warning
|
||||
}: {
|
||||
danger?: boolean;
|
||||
selected?: boolean;
|
||||
warning?: boolean;
|
||||
} = {}) {
|
||||
return cn(
|
||||
"bg-card/40 transition-colors hover:bg-muted/30",
|
||||
danger && "bg-rose-500/5 hover:bg-rose-500/10",
|
||||
warning && "bg-amber-500/5 hover:bg-amber-500/10",
|
||||
selected && "bg-teal-500/10 shadow-[inset_2px_0_0_rgba(20,184,166,0.7)] hover:bg-teal-500/15"
|
||||
);
|
||||
}
|
||||
@@ -3613,7 +3616,10 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
|
||||
</thead>
|
||||
<tbody className={agentListBodyClassName}>
|
||||
{trace.runs.map((run) => (
|
||||
<tr className={agentListRowClassName({ danger: run.status === "error" })} key={run.id}>
|
||||
<tr className={agentListRowClassName({
|
||||
danger: run.status === "error",
|
||||
warning: run.status === "partial"
|
||||
})} key={run.id}>
|
||||
<td className="max-w-[360px] px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2" style={{ paddingLeft: `${Math.min(run.depth, 8) * 16}px` }}>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", traceRunDotClass(run))} />
|
||||
@@ -3636,8 +3642,8 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={cn("border", run.status === "error" ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700")} variant="outline">
|
||||
{t(run.status === "error" ? "Error" : "Success")}
|
||||
<Badge className={cn("border", traceRunStatusBadgeClass(run.status))} variant="outline">
|
||||
{t(traceRunStatusLabel(run.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="max-w-[260px] px-3 py-2" title={traceRunTarget(run)}>
|
||||
@@ -4000,6 +4006,7 @@ function traceRunBarStyle(run: AgentAnalysisTraceRun, traceDurationMs: number):
|
||||
|
||||
function traceRunDotClass(run: AgentAnalysisTraceRun): string {
|
||||
if (run.status === "error") return "bg-rose-500";
|
||||
if (run.status === "partial") return "bg-amber-500";
|
||||
if (run.kind === "agent") return "bg-teal-500";
|
||||
if (run.kind === "route") return "bg-cyan-500";
|
||||
if (run.kind === "subagent") return "bg-amber-500";
|
||||
@@ -4009,6 +4016,7 @@ function traceRunDotClass(run: AgentAnalysisTraceRun): string {
|
||||
|
||||
function traceRunBarClass(run: AgentAnalysisTraceRun): string {
|
||||
if (run.status === "error") return "bg-rose-500";
|
||||
if (run.status === "partial") return "bg-amber-500";
|
||||
if (run.kind === "agent") return "bg-teal-500";
|
||||
if (run.kind === "route") return "bg-cyan-500";
|
||||
if (run.kind === "subagent") return "bg-amber-500";
|
||||
@@ -4016,6 +4024,18 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string {
|
||||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
function traceRunStatusBadgeClass(status: AgentAnalysisTraceRun["status"]): string {
|
||||
if (status === "error") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
if (status === "partial") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
function traceRunStatusLabel(status: AgentAnalysisTraceRun["status"]): string {
|
||||
if (status === "error") return "Error";
|
||||
if (status === "partial") return "Partial failure";
|
||||
return "Success";
|
||||
}
|
||||
|
||||
function formatRouteReason(value: string | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
@@ -4041,7 +4061,7 @@ function AgentSessionsCard({
|
||||
<AnalysisEmptyState label={t("No session activity")} />
|
||||
) : (
|
||||
<div className={cn("h-full", agentListFrameClassName)}>
|
||||
<table className={cn("min-w-[1260px]", agentListTableClassName)}>
|
||||
<table className={cn("min-w-[1420px]", agentListTableClassName)}>
|
||||
<thead className={agentListHeadClassName}>
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">{t("Session")}</th>
|
||||
@@ -4054,6 +4074,8 @@ function AgentSessionsCard({
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Tools")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Subagents")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Errors")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cache rate")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Models")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Providers")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("UA")}</th>
|
||||
@@ -4077,6 +4099,8 @@ function AgentSessionsCard({
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.toolCallCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.subagentCallCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.errorCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatPercent(session.cacheRatio)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{formatUsdCost(session.costUsd)}</td>
|
||||
<td className="max-w-[240px] px-3 py-2" title={session.models.join(", ")}>{session.models.join(", ") || "-"}</td>
|
||||
<td className="max-w-[220px] px-3 py-2" title={session.providers.join(", ")}>{session.providers.join(", ") || "-"}</td>
|
||||
<td className="max-w-[220px] px-3 py-2 font-mono" title={session.userAgent}>{compactUserAgent(session.userAgent)}</td>
|
||||
|
||||
@@ -1045,6 +1045,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。",
|
||||
"Display name": "显示名称",
|
||||
"Double click to copy": "双击复制",
|
||||
"Duration": "持续时间",
|
||||
"Edit": "编辑",
|
||||
"Edit bot": "编辑 Bot",
|
||||
"Edit API Key": "编辑 API 密钥",
|
||||
@@ -1300,6 +1301,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。",
|
||||
"Profile ready": "配置档案已就绪",
|
||||
"Password": "密码",
|
||||
"Partial failure": "部分失败",
|
||||
"Recent Errors": "最近错误",
|
||||
"Recent Requests": "最近请求",
|
||||
"Refresh": "刷新",
|
||||
@@ -1569,7 +1571,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Subagent": "子代理",
|
||||
"Subagent Routing": "Subagent 路由",
|
||||
"Subagent calls": "Subagent 调用",
|
||||
"Subagents": "Subagent",
|
||||
"Subagents": "子代理",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
"System proxy": "系统代理",
|
||||
|
||||
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { RequestLogPage } from "@ccr/core/contracts/app.ts";
|
||||
import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogPage } from "@ccr/core/contracts/app.ts";
|
||||
import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx";
|
||||
import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx";
|
||||
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
|
||||
import { createEmptyAgentAnalysis } from "@ccr/ui/pages/home/shared/usage.ts";
|
||||
|
||||
const emptyLogPage: RequestLogPage = {
|
||||
generatedAt: "2026-07-23T00:00:00.000Z",
|
||||
@@ -59,3 +61,115 @@ test("LogsView explains filtered empty results and translates page sizes", () =>
|
||||
assert.match(html, /25 \/ page/);
|
||||
assert.doesNotMatch(html, /\/ 页/);
|
||||
});
|
||||
|
||||
test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => {
|
||||
const session: AgentAnalysisSessionRow = {
|
||||
agent: "claude-code",
|
||||
avgDurationMs: 420,
|
||||
cacheRatio: 0.375,
|
||||
cacheReadTokens: 300,
|
||||
cacheTokens: 300,
|
||||
cacheWriteTokens: 100,
|
||||
client: "claude-code",
|
||||
costUsd: 1.25,
|
||||
durationMs: 900,
|
||||
errorCount: 0,
|
||||
id: "session-cache-cost",
|
||||
inputTokens: 500,
|
||||
lastSeenAt: "2026-07-23T00:01:00.000Z",
|
||||
maxConcurrentRequests: 1,
|
||||
maxDurationMs: 500,
|
||||
models: ["claude-sonnet-4"],
|
||||
outputTokens: 100,
|
||||
p50DurationMs: 420,
|
||||
p95DurationMs: 500,
|
||||
p99DurationMs: 500,
|
||||
providers: ["anthropic"],
|
||||
requestCount: 2,
|
||||
sessionCount: 1,
|
||||
startedAt: "2026-07-23T00:00:00.000Z",
|
||||
subagentCallCount: 0,
|
||||
successRate: 1,
|
||||
toolCallCount: 1,
|
||||
topTools: [{ count: 1, name: "Read" }],
|
||||
totalTokens: 1000
|
||||
};
|
||||
const snapshot = {
|
||||
...createEmptyAgentAnalysis("24h"),
|
||||
scannedRequestCount: 2,
|
||||
selectedSession: {
|
||||
endpoints: [],
|
||||
errors: [],
|
||||
models: [],
|
||||
requests: [],
|
||||
routes: [],
|
||||
session,
|
||||
statusCodes: [],
|
||||
subagents: [],
|
||||
tools: [],
|
||||
totals: session,
|
||||
trace: {
|
||||
agent: session.agent,
|
||||
durationMs: session.durationMs,
|
||||
endedAt: session.lastSeenAt,
|
||||
errorCount: 1,
|
||||
id: `${session.agent}:${session.id}`,
|
||||
llmRunCount: 0,
|
||||
maxDepth: 0,
|
||||
rootRunId: `agent:${session.agent}:${session.id}`,
|
||||
runCount: 1,
|
||||
runs: [{
|
||||
agent: session.agent,
|
||||
cacheReadTokens: session.cacheReadTokens,
|
||||
cacheWriteTokens: session.cacheWriteTokens,
|
||||
concurrentRequests: session.maxConcurrentRequests,
|
||||
depth: 0,
|
||||
durationMs: session.durationMs,
|
||||
endedAt: session.lastSeenAt,
|
||||
id: `agent:${session.agent}:${session.id}`,
|
||||
inputTokens: session.inputTokens,
|
||||
kind: "agent",
|
||||
name: "Claude Code session",
|
||||
offsetMs: 0,
|
||||
outputTokens: session.outputTokens,
|
||||
sessionId: session.id,
|
||||
startedAt: session.startedAt,
|
||||
status: "partial",
|
||||
totalTokens: session.totalTokens
|
||||
} satisfies AgentAnalysisTraceRun],
|
||||
sessionId: session.id,
|
||||
startedAt: session.startedAt,
|
||||
subagentRunCount: 0,
|
||||
toolRunCount: 0
|
||||
}
|
||||
},
|
||||
sessions: [session]
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<AgentAnalysisView
|
||||
agentFilter="all"
|
||||
error=""
|
||||
loading={false}
|
||||
range="24h"
|
||||
refreshAnalysis={() => undefined}
|
||||
setAgentFilter={() => undefined}
|
||||
setRange={() => undefined}
|
||||
setSelectedSession={() => undefined}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.match(html, /持续时间/);
|
||||
assert.match(html, /子代理/);
|
||||
assert.match(html, /缓存率/);
|
||||
assert.match(html, /38%/);
|
||||
assert.match(html, /成本/);
|
||||
assert.match(html, /\$1\.25/);
|
||||
assert.match(html, /部分失败/);
|
||||
assert.match(html, /border-amber-200/);
|
||||
assert.match(html, /min-w-\[64px\]/);
|
||||
assert.match(html, /whitespace-nowrap/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user