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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user