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