feat(observability): 管理面板可观测性接真实数据——metrics/trace/audit 三端点 + 审计持久化
- 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:
1 parent
76134ed47e
commit
e0a543cc37
12 files changed
+803
-22
No files matched your search
+10
-1
@@ -45,14 +45,22 @@
|
|||||||
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
|
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
|
||||||
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
|
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
|
||||||
| 14 | `/api/v1/admin/rag/delete` | POST | 🕐(写) | 删除知识库文档;写操作 M10,当前返回 `501 not_implemented` |
|
| 14 | `/api/v1/admin/rag/delete` | POST | 🕐(写) | 删除知识库文档;写操作 M10,当前返回 `501 not_implemented` |
|
||||||
|
| 15 | `/api/v1/admin/observability/metrics` | GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) |
|
||||||
|
| 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条) |
|
||||||
|
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
|
||||||
|
|
||||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/trace` · `GET /api/v1/admin/audit` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建、知识库上传/删除)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
|
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建、知识库上传/删除)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。
|
||||||
|
|
||||||
**知识库 RAG 契约**:
|
**知识库 RAG 契约**:
|
||||||
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||||
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
|
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
|
||||||
- `POST /admin/rag/delete` body `{"id":"..."}` → 写操作 M10,返回 `501` `{"error":{"code":"not_implemented",...}}`。
|
- `POST /admin/rag/delete` body `{"id":"..."}` → 写操作 M10,返回 `501` `{"error":{"code":"not_implemented",...}}`。
|
||||||
|
|
||||||
|
**可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin,`?limit=N` 缺省 50、上限 500):
|
||||||
|
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本后端不统计、不返回**(不伪造指标)。
|
||||||
|
- `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。
|
||||||
|
- `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。
|
||||||
|
|
||||||
## 3. WebSocket `/ws/chat`(实时)
|
## 3. WebSocket `/ws/chat`(实时)
|
||||||
|
|
||||||
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
|
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
|
||||||
@@ -78,6 +86,7 @@
|
|||||||
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
|
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
|
||||||
|
|
||||||
## 5. 变更记录
|
## 5. 变更记录
|
||||||
|
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
|
||||||
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
|
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
|
||||||
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
|
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
|
||||||
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。
|
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ func (a *app) wireFrontend() error {
|
|||||||
VectorOn: a.vindex != nil,
|
VectorOn: a.vindex != nil,
|
||||||
Log: a.log,
|
Log: a.log,
|
||||||
})
|
})
|
||||||
|
auditStore, aerr := gateway.NewSQLiteAuditStore(context.Background(), a.db, a.log)
|
||||||
|
if aerr != nil {
|
||||||
|
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
|
||||||
|
auditStore = nil
|
||||||
|
}
|
||||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||||
Log: logger.WithSource(a.log, "gateway"),
|
Log: logger.WithSource(a.log, "gateway"),
|
||||||
Auth: jwt,
|
Auth: jwt,
|
||||||
@@ -467,6 +472,7 @@ func (a *app) wireFrontend() error {
|
|||||||
Users: apiKeys,
|
Users: apiKeys,
|
||||||
Backthink: a.backthink,
|
Backthink: a.backthink,
|
||||||
RAG: ragStore,
|
RAG: ragStore,
|
||||||
|
AuditStore: auditStore,
|
||||||
Streaming: true,
|
Streaming: true,
|
||||||
Frontend: resolveFrontendFS(a.log),
|
Frontend: resolveFrontendFS(a.log),
|
||||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useAuditStore } from "../audit";
|
|||||||
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
|
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { Icon } from "../../components/Icon";
|
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";
|
type Tab = "logs" | "trace" | "metrics" | "audit";
|
||||||
|
|
||||||
@@ -14,6 +18,16 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
|
|||||||
{ id: "audit", label: "审计" },
|
{ 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 {
|
export function ObservabilityModule(): React.ReactNode {
|
||||||
const [tab, setTab] = useState<Tab>("logs");
|
const [tab, setTab] = useState<Tab>("logs");
|
||||||
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
|
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
|
||||||
@@ -66,45 +80,152 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TraceView(): React.ReactNode {
|
function TraceView(): React.ReactNode {
|
||||||
|
const trace = useAsyncData(() => adminObservabilityTrace(100), []);
|
||||||
|
useAutoReload(trace.reload, 3000);
|
||||||
|
const spans = trace.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Tracing 视图</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<h3 className="text-sm font-semibold">Tracing 视图</h3>
|
||||||
{["LLM 调用链", "工具链", "编排链"].map((x) => (
|
<Tag tone="ok">实时</Tag>
|
||||||
<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)]">
|
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/observability/trace</span>
|
||||||
<div className="mb-2 text-lg">{x}</div>
|
|
||||||
{x} 占位 —— 后端 M10+ 提供 span/trace 数据
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</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 {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<StatCard icon={<Icon name="gear" size={18} />} label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
|
<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="—" sub="用量/费用占位" tone="neutral" />
|
<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="—" sub="编排指标占位" tone="ok" />
|
<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" />
|
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本后端暂不统计" tone="warn" />
|
||||||
</CardGrid>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditViewProps {
|
interface AuditViewProps {
|
||||||
records: ReturnType<typeof useAuditStore.getState>["records"];
|
records: AuditRecord[];
|
||||||
onClear: () => void;
|
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 (
|
return (
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold">审计日志(who / what / when / result)</h3>
|
<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="text-xs text-[var(--mu-text-lo)]">管理 + 敏感操作全程落审计</span>
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
<Button size="sm" variant="ghost" onClick={onClear}>
|
<Button size="sm" variant="ghost" onClick={onClear}>
|
||||||
@@ -112,6 +233,7 @@ function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
|
|||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{audit.error && !audit.data ? <Notice tone="warn">审计端点暂不可用,回退本地记录:{audit.error}</Notice> : null}
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
|
<Notice>暂无审计记录。本面板的管理/敏感操作(策略写入、主人档案编辑、API key 签发等)都会在此登记。</Notice>
|
||||||
) : (
|
) : (
|
||||||
@@ -139,3 +261,11 @@ function AuditView({ records, onClear }: AuditViewProps): React.ReactNode {
|
|||||||
</Surface>
|
</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`;
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import type {
|
|||||||
AdminChannelRecord,
|
AdminChannelRecord,
|
||||||
AdminIoTDevice,
|
AdminIoTDevice,
|
||||||
AdminLlmConfig,
|
AdminLlmConfig,
|
||||||
|
AdminObservabilityMetrics,
|
||||||
AdminPluginRecord,
|
AdminPluginRecord,
|
||||||
AdminRagHit,
|
AdminRagHit,
|
||||||
AdminRagResponse,
|
AdminRagResponse,
|
||||||
|
AdminTraceSpan,
|
||||||
AdminUserRecord,
|
AdminUserRecord,
|
||||||
ApiErrorShape,
|
ApiErrorShape,
|
||||||
|
AuditRecord,
|
||||||
ChatSyncResult,
|
ChatSyncResult,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
MemoryRecord,
|
MemoryRecord,
|
||||||
@@ -269,3 +272,21 @@ export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
|||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
|
||||||
|
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
|
||||||
|
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
|
||||||
|
}
|
||||||
|
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
|
||||||
|
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
|
||||||
|
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
|
||||||
|
}
|
||||||
|
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
|
||||||
|
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
|
||||||
|
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an optional ?limit=N query suffix (empty when no positive limit). */
|
||||||
|
function limitQuery(limit?: number): string {
|
||||||
|
return limit && limit > 0 ? `?limit=${limit}` : "";
|
||||||
|
}
|
||||||
@@ -274,15 +274,52 @@ export interface AdminRagResponse {
|
|||||||
hits?: AdminRagHit[];
|
hits?: AdminRagHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One audit entry: who / what / when / result. */
|
/** One audit entry: who / what / when / result. `id` is a string for the local
|
||||||
|
* (client) trail but a number for backend-persisted entries. */
|
||||||
export interface AuditRecord {
|
export interface AuditRecord {
|
||||||
id: string;
|
id: string | number;
|
||||||
who: string;
|
who: string;
|
||||||
what: string;
|
what: string;
|
||||||
when: string;
|
when: string;
|
||||||
result: string;
|
result: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gateway request metrics (GET /api/v1/admin/observability/metrics). Token/cost
|
||||||
|
* is intentionally absent: the gateway does not track spend, and a fabricated
|
||||||
|
* number would violate the "不伪造指标" red line. */
|
||||||
|
export interface AdminObservabilityMetrics {
|
||||||
|
totalRequests: number;
|
||||||
|
qps: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
maxLatencyMs: number;
|
||||||
|
perMethod: Record<string, number>;
|
||||||
|
perPath: Record<string, number>;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
llmCalls: number;
|
||||||
|
subagentEvents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One traced span (GET /api/v1/admin/observability/trace). */
|
||||||
|
export interface AdminTraceSpan {
|
||||||
|
id: string;
|
||||||
|
parentId?: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationNs: number;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
events?: TraceEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fine-grained event attached to a span. */
|
||||||
|
export interface TraceEvent {
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
/** The owner profile (mirrors configs/users/owner.yaml). */
|
/** The owner profile (mirrors configs/users/owner.yaml). */
|
||||||
export interface OwnerProfile {
|
export interface OwnerProfile {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,15 +17,23 @@ func (s *Server) auditClaims(r *http.Request, what, result string) {
|
|||||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
||||||
who = claims.Sub + ":" + string(claims.Role)
|
who = claims.Sub + ":" + string(claims.Role)
|
||||||
}
|
}
|
||||||
s.audit(who, what, result)
|
s.audit(r.Context(), who, what, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// audit emits one structured audit record on the gateway logger.
|
// audit emits one structured audit record on the gateway logger and persists it to
|
||||||
func (s *Server) audit(who, what, result string) {
|
// the configured AuditStore (falling back to an in-memory store when none is
|
||||||
|
// injected). A persist failure is logged but never blocks the admin write, so a
|
||||||
|
// broken audit store cannot take down the audit trail's primary job.
|
||||||
|
func (s *Server) audit(ctx context.Context, who, what, result string) {
|
||||||
s.log.Info("admin audit",
|
s.log.Info("admin audit",
|
||||||
"who", who,
|
"who", who,
|
||||||
"what", what,
|
"what", what,
|
||||||
"when", time.Now().UTC().Format(time.RFC3339),
|
"when", time.Now().UTC().Format(time.RFC3339),
|
||||||
"result", result,
|
"result", result,
|
||||||
)
|
)
|
||||||
|
if s.obs != nil && s.obs.audit != nil {
|
||||||
|
if err := s.obs.audit.Append(ctx, who, what, result, time.Now()); err != nil {
|
||||||
|
s.log.Error("admin audit persist", "err", err, "what", what)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,13 @@ func (s *Server) logging(next http.Handler) http.Handler {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
next.ServeHTTP(rec, r)
|
next.ServeHTTP(rec, r)
|
||||||
|
durMs := time.Since(start).Milliseconds()
|
||||||
|
s.recordMetric(r.Method, r.URL.Path, rec.status, durMs)
|
||||||
s.log.Info("request",
|
s.log.Info("request",
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"status", rec.status,
|
"status", rec.status,
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", durMs,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default list limits shared by the trace/audit endpoints. Compile-time constants
|
||||||
|
// so no magic number appears in the run path (project rule §10).
|
||||||
|
const (
|
||||||
|
defaultObsLimit = 50
|
||||||
|
maxObsLimit = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// observability bundles the gateway's live admin instrumentation: the request
|
||||||
|
// metrics counter, the trace span ring buffer and the audit store. It is built in
|
||||||
|
// New and is safe for concurrent use.
|
||||||
|
type observability struct {
|
||||||
|
metrics *MetricsCollector
|
||||||
|
traceBuf *RingBuffer
|
||||||
|
audit AuditStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// newObservability builds the instrumentation, falling back to an in-memory audit
|
||||||
|
// store when no store is injected.
|
||||||
|
func newObservability(deps Deps) *observability {
|
||||||
|
obs := &observability{
|
||||||
|
metrics: newMetricsCollector(),
|
||||||
|
traceBuf: newRingBuffer(defaultTraceBufCap),
|
||||||
|
}
|
||||||
|
if deps.AuditStore != nil {
|
||||||
|
obs.audit = deps.AuditStore
|
||||||
|
} else {
|
||||||
|
obs.audit = newMemoryAuditStore()
|
||||||
|
}
|
||||||
|
return obs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveSpan feeds a completed trace span into the gateway's ring buffer and the
|
||||||
|
// LLM/subagent metric counters. It is the gateway's trace injection point: the
|
||||||
|
// composition root wires the loop tracer here (e.g. through TraceSink) so the
|
||||||
|
// admin panel can query spans without the gateway touching wire.go.
|
||||||
|
func (s *Server) ObserveSpan(rec trace.SpanRecord) {
|
||||||
|
if s.obs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.obs.traceBuf.Append(rec)
|
||||||
|
s.obs.metrics.Observe(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceSink returns a trace.Writer that appends completed spans to the gateway's
|
||||||
|
// trace ring buffer. Combine it with the existing JSONRecorder in the composition
|
||||||
|
// root (e.g. trace.New(MultiTraceWriter{...})) so spans both hit stderr and the
|
||||||
|
// admin panel.
|
||||||
|
func (s *Server) TraceSink() trace.Writer {
|
||||||
|
if s.obs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return traceSink{s.obs.traceBuf}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordMetric counts one request for the observability panel.
|
||||||
|
func (s *Server) recordMetric(method, path string, status int, durMs int64) {
|
||||||
|
if s.obs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.obs.metrics.Record(method, path, status, durMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminObservabilityMetrics returns the current gateway metrics (admin read).
|
||||||
|
func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.obs == nil {
|
||||||
|
writeJSON(w, http.StatusOK, AdminMetrics{
|
||||||
|
PerMethod: map[string]int64{}, PerPath: map[string]int64{}, StatusCounts: map[string]int64{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.obs.metrics.Snapshot(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminObservabilityTrace returns the most recent traced spans (admin read).
|
||||||
|
func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.obs == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []AdminTraceSpan{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spans := s.obs.traceBuf.Snapshot(parseLimit(r))
|
||||||
|
out := make([]AdminTraceSpan, 0, len(spans))
|
||||||
|
for _, sp := range spans {
|
||||||
|
out = append(out, adminSpanOf(sp))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminObservabilityAudit returns the most recent audit entries (admin read).
|
||||||
|
func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.obs == nil || s.obs.audit == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []AuditRecord{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
records, err := s.obs.audit.List(r.Context(), parseLimit(r))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("list audit", "err", err)
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "audit_failed", "failed to list audit records"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLimit parses the ?limit= query param, clamping to [1, maxObsLimit], and
|
||||||
|
// selects defaultObsLimit when absent or invalid.
|
||||||
|
func parseLimit(r *http.Request) int {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultObsLimit
|
||||||
|
}
|
||||||
|
if n > maxObsLimit {
|
||||||
|
return maxObsLimit
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditRecord is one persisted admin audit entry (who/what/when/result).
|
||||||
|
type AuditRecord struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Who string `json:"who"`
|
||||||
|
What string `json:"what"`
|
||||||
|
When string `json:"when"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditStore persists the admin audit trail. Implementations must be safe for
|
||||||
|
// concurrent use. *SQLiteAuditStore is the production implementation (wired by the
|
||||||
|
// composition root over a.db); when none is injected the gateway falls back to an
|
||||||
|
// in-memory store so the endpoint always returns data.
|
||||||
|
type AuditStore interface {
|
||||||
|
// Append persists one audit entry at ts.
|
||||||
|
Append(ctx context.Context, who, what, result string, ts time.Time) error
|
||||||
|
// List returns at most limit most-recent entries, newest first. A non-positive
|
||||||
|
// limit selects the default.
|
||||||
|
List(ctx context.Context, limit int) ([]AuditRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryAuditStore is the nil-injection fallback: it holds the audit trail in
|
||||||
|
// process memory so the admin endpoint works before the composition root injects a
|
||||||
|
// SQLite store. It is concurrency-safe.
|
||||||
|
type memoryAuditStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []AuditRecord
|
||||||
|
seq int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMemoryAuditStore builds an empty in-memory audit store.
|
||||||
|
func newMemoryAuditStore() *memoryAuditStore {
|
||||||
|
return &memoryAuditStore{records: make([]AuditRecord, 0, 64)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append stores one entry with a monotonically increasing id.
|
||||||
|
func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.seq++
|
||||||
|
m.records = append(m.records, AuditRecord{
|
||||||
|
ID: m.seq, Who: who, What: what, When: ts.UTC().Format(time.RFC3339), Result: result,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the most recent entries, newest first.
|
||||||
|
func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultObsLimit
|
||||||
|
}
|
||||||
|
if limit > maxObsLimit {
|
||||||
|
limit = maxObsLimit
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
n := len(m.records)
|
||||||
|
start := n - limit
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
out := make([]AuditRecord, n-start)
|
||||||
|
copy(out, m.records[start:])
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit DDL for a self-contained SQLite store (no migration bookkeeping), the
|
||||||
|
// same pattern channel/session and backthink use. recorded_at is the UTC RFC3339
|
||||||
|
// wall-clock of the audited action.
|
||||||
|
const (
|
||||||
|
createAuditSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
who TEXT NOT NULL,
|
||||||
|
what TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)`
|
||||||
|
createAuditIndexSQL = `CREATE INDEX IF NOT EXISTS idx_audit_logs_recorded_at ON audit_logs(recorded_at)`
|
||||||
|
insertAuditSQL = `INSERT INTO audit_logs (who, what, result, recorded_at) VALUES (?, ?, ?, ?)`
|
||||||
|
selectAuditSQL = `SELECT id, who, what, result, recorded_at FROM audit_logs ORDER BY id DESC LIMIT ?`
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteAuditStore persists the admin audit trail in SQLite. It is safe for
|
||||||
|
// concurrent use by virtue of the underlying *sql.DB.
|
||||||
|
type SQLiteAuditStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSQLiteAuditStore builds a SQLite-backed audit store over db, guaranteeing the
|
||||||
|
// audit_logs table and index exist. It returns an error when db is nil or the DDL
|
||||||
|
// fails. The composition root wires this over the primary *sql.DB (a.db).
|
||||||
|
func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQLiteAuditStore, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("gateway: audit store: nil database")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, createAuditSQL); err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: ensure audit_logs: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, createAuditIndexSQL); err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: ensure audit_logs index: %w", err)
|
||||||
|
}
|
||||||
|
return &SQLiteAuditStore{db: db, log: sourcedLog(log)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append persists one audit entry at ts.
|
||||||
|
func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, insertAuditSQL, who, what, result, ts.UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("gateway: insert audit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the most recent entries, newest first.
|
||||||
|
func (s *SQLiteAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultObsLimit
|
||||||
|
}
|
||||||
|
if limit > maxObsLimit {
|
||||||
|
limit = maxObsLimit
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, selectAuditSQL, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: list audit: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
records := make([]AuditRecord, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var r AuditRecord
|
||||||
|
if err := rows.Scan(&r.ID, &r.Who, &r.What, &r.Result, &r.When); err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: scan audit: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: iterate audit: %w", err)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics collector tuning. Compile-time constants.
|
||||||
|
const (
|
||||||
|
// metricsWindow bounds how long request timestamps are retained for QPS.
|
||||||
|
metricsWindow = 2 * time.Second
|
||||||
|
// metricsQPSSpan is the sliding window over which QPS is measured.
|
||||||
|
metricsQPSSpan = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminMetrics is the wire shape of GET /api/v1/admin/observability/metrics.
|
||||||
|
// LLM/Token cost is deliberately absent: the gateway does not track token usage or
|
||||||
|
// spend, and a fabricated number would violate the "不伪造指标" red line.
|
||||||
|
type AdminMetrics struct {
|
||||||
|
TotalRequests int64 `json:"totalRequests"`
|
||||||
|
QPS float64 `json:"qps"`
|
||||||
|
AvgLatencyMs float64 `json:"avgLatencyMs"`
|
||||||
|
MaxLatencyMs int64 `json:"maxLatencyMs"`
|
||||||
|
PerMethod map[string]int64 `json:"perMethod"`
|
||||||
|
PerPath map[string]int64 `json:"perPath"`
|
||||||
|
StatusCounts map[string]int64 `json:"statusCounts"`
|
||||||
|
LLMCalls int64 `json:"llmCalls"`
|
||||||
|
SubagentEvents int64 `json:"subagentEvents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsCollector accumulates request metrics and derived counters for the admin
|
||||||
|
// observability panel. It is concurrency-safe.
|
||||||
|
type MetricsCollector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
total int64
|
||||||
|
perMethod map[string]int64
|
||||||
|
perPath map[string]int64
|
||||||
|
statuses map[int]int64
|
||||||
|
sumDur int64
|
||||||
|
maxDur int64
|
||||||
|
recent []time.Time
|
||||||
|
llm int64
|
||||||
|
subagent int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMetricsCollector builds an empty metrics collector.
|
||||||
|
func newMetricsCollector() *MetricsCollector {
|
||||||
|
return &MetricsCollector{
|
||||||
|
perMethod: make(map[string]int64),
|
||||||
|
perPath: make(map[string]int64),
|
||||||
|
statuses: make(map[int]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record counts one request with the given method, path, status and duration.
|
||||||
|
func (m *MetricsCollector) Record(method, path string, status int, durMs int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
m.total++
|
||||||
|
m.perMethod[method]++
|
||||||
|
m.perPath[path]++
|
||||||
|
m.statuses[status]++
|
||||||
|
m.sumDur += durMs
|
||||||
|
if durMs > m.maxDur {
|
||||||
|
m.maxDur = durMs
|
||||||
|
}
|
||||||
|
m.recent = append(m.recent, now)
|
||||||
|
m.prune(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe increments the LLM/subagent counters when a span of that kind is
|
||||||
|
// recorded. It is called by ObserveSpan so the counters reflect real work.
|
||||||
|
func (m *MetricsCollector) Observe(rec trace.SpanRecord) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
switch rec.Kind {
|
||||||
|
case trace.SpanKindLLM:
|
||||||
|
m.llm++
|
||||||
|
case trace.SpanKindSubagent:
|
||||||
|
m.subagent++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune drops request times older than metricsWindow so recent stays small. The
|
||||||
|
// caller must hold m.mu.
|
||||||
|
func (m *MetricsCollector) prune(now time.Time) {
|
||||||
|
min := now.Add(-metricsWindow)
|
||||||
|
i := 0
|
||||||
|
for i < len(m.recent) && m.recent[i].Before(min) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
m.recent = append([]time.Time(nil), m.recent[i:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a point-in-time copy of the metrics. qps counts the requests
|
||||||
|
// started within metricsQPSSpan of now.
|
||||||
|
func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
statuses := make(map[string]int64, len(m.statuses))
|
||||||
|
for k, v := range m.statuses {
|
||||||
|
statuses[strconv.Itoa(k)] = v
|
||||||
|
}
|
||||||
|
var qps int64
|
||||||
|
for _, t := range m.recent {
|
||||||
|
if now.Sub(t) <= metricsQPSSpan {
|
||||||
|
qps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avg := int64(0)
|
||||||
|
if m.total > 0 {
|
||||||
|
avg = m.sumDur / m.total
|
||||||
|
}
|
||||||
|
return AdminMetrics{
|
||||||
|
TotalRequests: m.total,
|
||||||
|
QPS: float64(qps),
|
||||||
|
AvgLatencyMs: float64(avg),
|
||||||
|
MaxLatencyMs: m.maxDur,
|
||||||
|
PerMethod: m.perMethod,
|
||||||
|
PerPath: m.perPath,
|
||||||
|
StatusCounts: statuses,
|
||||||
|
LLMCalls: m.llm,
|
||||||
|
SubagentEvents: m.subagent,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultTraceBufCap is how many completed spans the ring buffer holds before the
|
||||||
|
// oldest is evicted. Compile-time constant.
|
||||||
|
const defaultTraceBufCap = 512
|
||||||
|
|
||||||
|
// AdminTraceSpan is the camelCase wire shape of one traced span. It decouples the
|
||||||
|
// frontend from the trace package's snake_case SpanRecord.
|
||||||
|
type AdminTraceSpan struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentID string `json:"parentId,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
EndedAt string `json:"endedAt,omitempty"`
|
||||||
|
DurationNs int64 `json:"durationNs"`
|
||||||
|
Props map[string]any `json:"props,omitempty"`
|
||||||
|
Events []trace.Event `json:"events,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RingBuffer is a concurrency-safe bounded buffer of completed trace spans. The
|
||||||
|
// oldest span is evicted when the buffer is full, so the admin panel always sees
|
||||||
|
// the most recent work without unbounded growth.
|
||||||
|
type RingBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
buf []trace.SpanRecord
|
||||||
|
next int
|
||||||
|
full bool
|
||||||
|
cap int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRingBuffer builds a ring buffer holding up to cap spans (default when cap<=0).
|
||||||
|
func newRingBuffer(cap int) *RingBuffer {
|
||||||
|
if cap <= 0 {
|
||||||
|
cap = defaultTraceBufCap
|
||||||
|
}
|
||||||
|
return &RingBuffer{buf: make([]trace.SpanRecord, cap), cap: cap}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append stores rec, evicting the oldest when the buffer is full.
|
||||||
|
func (r *RingBuffer) Append(rec trace.SpanRecord) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.buf[r.next] = rec
|
||||||
|
r.next = (r.next + 1) % r.cap
|
||||||
|
if r.next == 0 {
|
||||||
|
r.full = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns up to n most-recent spans, newest first. A non-positive n
|
||||||
|
// selects defaultObsLimit; n is clamped to maxObsLimit.
|
||||||
|
func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord {
|
||||||
|
if n <= 0 {
|
||||||
|
n = defaultObsLimit
|
||||||
|
}
|
||||||
|
if n > maxObsLimit {
|
||||||
|
n = maxObsLimit
|
||||||
|
}
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
count := r.next
|
||||||
|
if r.full {
|
||||||
|
count = r.cap
|
||||||
|
}
|
||||||
|
if count > n {
|
||||||
|
count = n
|
||||||
|
}
|
||||||
|
out := make([]trace.SpanRecord, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
idx := (r.next - 1 - i + r.cap*2) % r.cap
|
||||||
|
out = append(out, r.buf[idx])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceSink adapts a *RingBuffer to trace.Writer so a completed span is appended
|
||||||
|
// to the gateway's trace buffer.
|
||||||
|
type traceSink struct{ b *RingBuffer }
|
||||||
|
|
||||||
|
// WriteSpan appends rec to the ring buffer.
|
||||||
|
func (s traceSink) WriteSpan(rec trace.SpanRecord) error {
|
||||||
|
if s.b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.b.Append(rec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiTraceWriter fans a completed span out to several trace.Writers, so a tracer
|
||||||
|
// can record to both the gateway's ring buffer and a JSONL file in one pass. A nil
|
||||||
|
// Writer is skipped. It is the composition-root helper the main session uses to
|
||||||
|
// route the loop tracer's output into the gateway without dropping stderr.
|
||||||
|
type MultiTraceWriter struct {
|
||||||
|
// Writers are the sinks a span is written to. A nil Writer is skipped.
|
||||||
|
Writers []trace.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSpan writes rec to every configured Writer.
|
||||||
|
func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error {
|
||||||
|
for _, w := range m.Writers {
|
||||||
|
if w == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := w.WriteSpan(rec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSpanOf maps a trace.SpanRecord to the camelCase admin wire shape.
|
||||||
|
func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan {
|
||||||
|
sp := AdminTraceSpan{
|
||||||
|
ID: rec.ID,
|
||||||
|
ParentID: rec.ParentID,
|
||||||
|
Name: rec.Name,
|
||||||
|
Kind: string(rec.Kind),
|
||||||
|
Status: string(rec.Status),
|
||||||
|
StartedAt: rec.StartedAt.UTC().Format(time.RFC3339),
|
||||||
|
DurationNs: rec.Duration.Nanoseconds(),
|
||||||
|
Props: rec.Props,
|
||||||
|
Events: rec.Events,
|
||||||
|
}
|
||||||
|
if !rec.EndedAt.IsZero() {
|
||||||
|
sp.EndedAt = rec.EndedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return sp
|
||||||
|
}
|
||||||
@@ -191,6 +191,11 @@ type Deps struct {
|
|||||||
// RAG backs the admin knowledge-base RAG module. Optional; list/search
|
// RAG backs the admin knowledge-base RAG module. Optional; list/search
|
||||||
// return empty when unset, and write endpoints report a clear 501.
|
// return empty when unset, and write endpoints report a clear 501.
|
||||||
RAG RagStore
|
RAG RagStore
|
||||||
|
// AuditStore persists the admin audit trail (observability → audit). When nil
|
||||||
|
// the gateway degrades to an in-memory store so the endpoint still returns
|
||||||
|
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
||||||
|
// persistence. Optional.
|
||||||
|
AuditStore AuditStore
|
||||||
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
||||||
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
||||||
// final stream_chunk emission, since deltas already stream through OnStream.
|
// final stream_chunk emission, since deltas already stream through OnStream.
|
||||||
@@ -225,6 +230,7 @@ type Server struct {
|
|||||||
users UserStore
|
users UserStore
|
||||||
backthink BackthinkStore
|
backthink BackthinkStore
|
||||||
rag RagStore
|
rag RagStore
|
||||||
|
obs *observability
|
||||||
voice VoiceService
|
voice VoiceService
|
||||||
streaming bool
|
streaming bool
|
||||||
frontend fs.FS
|
frontend fs.FS
|
||||||
@@ -259,6 +265,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
|||||||
users: deps.Users,
|
users: deps.Users,
|
||||||
backthink: deps.Backthink,
|
backthink: deps.Backthink,
|
||||||
rag: deps.RAG,
|
rag: deps.RAG,
|
||||||
|
obs: newObservability(deps),
|
||||||
streaming: deps.Streaming,
|
streaming: deps.Streaming,
|
||||||
frontend: deps.Frontend,
|
frontend: deps.Frontend,
|
||||||
webhooks: deps.Webhooks,
|
webhooks: deps.Webhooks,
|
||||||
@@ -303,6 +310,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /api/v1/admin/rag", adminAuth(http.HandlerFunc(s.adminListRag)))
|
mux.Handle("GET /api/v1/admin/rag", adminAuth(http.HandlerFunc(s.adminListRag)))
|
||||||
mux.Handle("POST /api/v1/admin/rag/query", adminAuth(http.HandlerFunc(s.adminSearchRag)))
|
mux.Handle("POST /api/v1/admin/rag/query", adminAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||||
mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag)))
|
mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag)))
|
||||||
|
mux.Handle("GET /api/v1/admin/observability/metrics", adminAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
|
||||||
|
mux.Handle("GET /api/v1/admin/observability/trace", adminAuth(http.HandlerFunc(s.adminObservabilityTrace)))
|
||||||
|
mux.Handle("GET /api/v1/admin/observability/audit", adminAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||||
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
||||||
|
|
||||||
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
||||||
|
|||||||
Reference in new issue
Block a user