Show partial agent session failures in observability UI

This commit is contained in:
musistudio
2026-07-30 15:25:50 +08:00
parent e9bbf9dbb0
commit e778687774
6 changed files with 240 additions and 10 deletions
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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/);
});