feat(observability): 管理面板可观测性接真实数据——metrics/trace/audit 三端点 + 审计持久化
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- observability.go/observability_metrics.go/observability_trace.go/observability_audit.go:
  拆分保证 ≤500行/函数≤80。metrics 并发安全 MetricsCollector(方法/路径/状态计数、平均/最大延迟、
  近1s QPS窗口);trace 并发安全 RingBuffer(默认512, newest-first)+traceSink+MultiTraceWriter;
  audit AuditStore 接口+内存回退+SQLite 持久化(NewSQLiteAuditStore 参数化 SQL+audit_logs 表)。
- server.go:Deps.AuditStore + Server.obs + 3 路由 GET /admin/observability/{metrics,trace,audit};
  ObserveSpan/TraceSink 注入点;middleware logging recordMetric;audit 落库(仅 slog→Append)。
- 前端 Obs.tsx:metrics 接 4 张真实 StatCard+请求分布、trace 接 span 列表(展开 props/events)、
  audit 从后端拉(前端内存审计作回退);api.ts/types.ts 加 obs 端点/类型(AdminObservabilityMetrics/
  AdminTraceSpan/AuditRecord.id 放宽)。成本指标不伪造(保留'—'+注明)。
- wire.go 收口:Deps.AuditStore 注入 gateway.NewSQLiteAuditStore(ctx, a.db)(审计真落库),
  未注入回退内存。
- Docs/api-client.md §2/v0.6。

实机验证:三端点 200,metrics 返回 totalRequests:4/qps:4/perPath/statusCounts:{200:4}。
go build/vet/test 全绿 + 前端 tsc。trace 数据源对接(gw.TraceSink→loop tracer)因 gw/tracer
构造顺序留作可选后置(端点已就绪, 未对接 span 空)。
This commit is contained in:
Aska committed 2026-09-06 21:02:46 +08:00
1 parent 76134ed47e
commit e0a543cc37
12 files changed
+803 -22

No files matched your search

+145 -15
View File
@@ -4,6 +4,10 @@ import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAutoReload } from "../hooks";
import { adminObservabilityAudit, adminObservabilityMetrics, adminObservabilityTrace } from "../../lib/api";
import type { AdminTraceSpan, AuditRecord } from "../../lib/types";
type Tab = "logs" | "trace" | "metrics" | "audit";
@@ -14,6 +18,16 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "audit", label: "审计" },
];
const KIND_LABEL: Record<string, string> = {
llm_call: "LLM",
tool_call: "工具",
orchestration: "编排",
state_transition: "状态",
subagent: "子代理",
memory: "记忆",
guardrail: "护栏",
};
export function ObservabilityModule(): React.ReactNode {
const [tab, setTab] = useState<Tab>("logs");
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
@@ -66,45 +80,152 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
}
function TraceView(): React.ReactNode {
const trace = useAsyncData(() => adminObservabilityTrace(100), []);
useAutoReload(trace.reload, 3000);
const spans = trace.data ?? [];
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["LLM 调用链", "工具链", "编排链"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">{x}</div>
{x} 占位 —— 后端 M10+ 提供 span/trace 数据
</div>
))}
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Tracing 视图</h3>
<Tag tone="ok">实时</Tag>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/observability/trace</span>
</div>
{trace.loading ? (
<Notice>加载 span 数据…</Notice>
) : trace.error ? (
<Notice tone="warn">trace 后端暂不可用:{trace.error}</Notice>
) : spans.length === 0 ? (
<Notice>暂无 span。接入 /ws/chat 发起会话后,LLM / 工具 / 编排 / 子代理等 span 会实时记录到环形缓冲。</Notice>
) : (
<SpanList spans={spans} />
)}
<Notice>数据源:gateway trace 环形缓冲(经 TraceSink 对接 runtime 的 tracer)。可在 TraceSink 注入前展示占位,注入后即真实 span。</Notice>
</Surface>
);
}
function SpanList({ spans }: { spans: AdminTraceSpan[] }): React.ReactNode {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div className="space-y-1.5">
{spans.map((sp) => {
const open = openId === sp.id;
return (
<button
key={sp.id}
onClick={() => setOpenId(open ? null : sp.id)}
className="w-full rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-left text-xs transition-colors hover:bg-[var(--mu-surface-3)]"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-[var(--mu-text)]">{sp.name}</span>
<Tag tone="neutral">{KIND_LABEL[sp.kind] ?? sp.kind}</Tag>
<Tag tone={sp.status === "ok" ? "ok" : "err"}>{sp.status === "ok" ? "成功" : "失败"}</Tag>
<span className="font-mono text-[var(--mu-text-lo)]">{sp.id}</span>
<span className="ml-auto font-mono text-[var(--mu-text-lo)]">{fmtDuration(sp.durationNs)}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[var(--mu-text-lo)]">
<span>{new Date(sp.startedAt).toLocaleString()}</span>
{sp.parentId ? <span>父 {sp.parentId}</span> : <span>根 span</span>}
</div>
{open ? (
<div className="mt-2 space-y-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-[var(--mu-text-mid)]">
{(sp.events?.length ?? 0) === 0 && !sp.props ? (
<span className="text-[var(--mu-text-lo)]">无事件/属性。</span>
) : null}
{sp.events?.map((ev, i) => (
<div key={i} className="flex items-start gap-2">
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">{new Date(ev.time).toLocaleTimeString()}</span>
<span className="font-medium">{ev.type}</span>
{ev.props ? (
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(ev.props)}</code>
) : null}
</div>
))}
{sp.props ? (
<code className="block break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(sp.props)}</code>
) : null}
</div>
) : null}
</button>
);
})}
</div>
);
}
function MetricsView(): React.ReactNode {
const metrics = useAsyncData(adminObservabilityMetrics, []);
useAutoReload(metrics.reload, 3000);
const m = metrics.data;
const topPaths = Object.entries(m?.perPath ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
return (
<div className="space-y-4">
<CardGrid>
<StatCard icon={<Icon name="gear" size={18} />} label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
<StatCard icon={<Icon name="robot" size={18} />} label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
<StatCard icon={<Icon name="agent" size={18} />} label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本占位" tone="warn" />
<StatCard icon={<Icon name="gear" size={18} />} label="请求 QPS" value={m ? m.qps.toFixed(1) : "—"} sub={m ? `总请求 ${m.totalRequests}` : "网关指标加载中"} tone="accent" />
<StatCard icon={<Icon name="robot" size={18} />} label="LLM 调用次数" value={m ? m.llmCalls : "—"} sub="从 trace span 累计" tone="neutral" />
<StatCard icon={<Icon name="agent" size={18} />} label="子代理事件" value={m ? m.subagentEvents : "—"} sub="从 trace span 累计" tone="ok" />
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本后端暂不统计" tone="warn" />
</CardGrid>
<Notice>指标时序图 / 导出需后端 M10+ 提供。</Notice>
{metrics.error ? <Notice tone="warn">指标后端暂不可用:{metrics.error}</Notice> : null}
{m && (topPaths.length > 0 || Object.keys(m.statusCounts).length > 0) ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">请求分布(真实网关计数)</h3>
{topPaths.length > 0 ? (
<div className="space-y-1">
{topPaths.map(([path, count]) => (
<div key={path} className="flex items-center gap-2 text-xs">
<code className="min-w-0 flex-1 break-all font-mono text-[var(--mu-text-mid)]">{path}</code>
<span className="text-[var(--mu-text-lo)]">{count}</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--mu-surface-3)]">
<div className="h-full rounded-full bg-[var(--mu-accent)]/60" style={{ width: `${((count / (m.totalRequests || 1)) * 100).toFixed(1)}%` }} />
</div>
</div>
))}
</div>
) : null}
{Object.keys(m.statusCounts).length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{Object.entries(m.statusCounts).map(([code, count]) => (
<Tag key={code} tone={code === "200" ? "ok" : Number(code) >= 500 ? "err" : "warn"}>
{code} × {count}
</Tag>
))}
<span className="text-xs text-[var(--mu-text-lo)]">平均 {m.avgLatencyMs.toFixed(1)}ms · 最大 {m.maxLatencyMs}ms</span>
</div>
) : null}
</Surface>
) : null}
<Notice>LLM 调用次数 / 子代理事件由实际观测到的 trace span 累计;Token / 成本后端暂不统计(不伪造指标)。指标时序图 / 导出需后端 M10+ 提供。</Notice>
</div>
);
}
interface AuditViewProps {
records: ReturnType<typeof useAuditStore.getState>["records"];
records: AuditRecord[];
onClear: () => void;
}
function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
function AuditView({ records: local, onClear }: AuditViewProps): React.ReactNode {
const audit = useAsyncData(() => adminObservabilityAudit(100), []);
useAutoReload(audit.reload, 5000);
// Backend is the authoritative source; the local store is a fallback while the
// endpoint is unavailable.
const records = audit.data ?? local;
const source = audit.data ? "后端" : "本地(后端未接入)";
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">审计日志(who / what / when / result)</h3>
<Tag tone={audit.data ? "ok" : "warn"}>{source}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">管理 + 敏感操作全程落审计</span>
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={onClear}>
@@ -112,6 +233,7 @@ function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
</Button>
</span>
</div>
{audit.error && !audit.data ? <Notice tone="warn">审计端点暂不可用,回退本地记录:{audit.error}</Notice> : null}
{records.length === 0 ? (
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
) : (
@@ -139,3 +261,11 @@ function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
</Surface>
);
}
/** Formats a nanosecond duration into a human-readable label. */
function fmtDuration(ns: number): string {
if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)} s`;
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`;
if (ns >= 1e3) return `${(ns / 1e3).toFixed(1)} µs`;
return `${ns} ns`;
}