- internal/logger/ring.go: Entry{time,level,source,msg} + RingBuffer(2000,满覆盖最旧,不阻塞)+ringHandler(slog旁路,Enabled委托内层,sourceOf提取source=再回落Scoped); Options.Ring非nil才包
- gateway/observability_logs.go: GET /admin/observability/logs?level=&limit= (writeAuth owner/admin) → {data:[{time(RFC3339 UTC),level,source,msg}]} 最新在前; level阈值过滤(warn=warn+error,已标注取舍); limit缺省200上限1000; 未知level400/未注入501
- routes.go注册 + observability.go SetLogBuffer(atomic.Pointer) + cmd/cyrene main.go/wireGatewayLogBuffer接线(logger先于gateway)
- 控制台/文件输出零变化; 记录只源自已产出日志,不额外打印敏感
- Docs/api-client v0.35; 测试(ring 10 + observability_logs 8 + cmd端到端2); build/vet/test绿(56)+win/darwin交叉编译
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"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, the audit store and the
|
|
// process-wide log ring buffer. It is built in New and is safe for concurrent
|
|
// use.
|
|
type observability struct {
|
|
metrics *MetricsCollector
|
|
traceBuf *RingBuffer
|
|
audit AuditStore
|
|
// logBuf is the logger's log ring buffer behind the admin log-tail endpoint.
|
|
// The composition root injects it via SetLogBuffer once the gateway exists;
|
|
// until then the endpoint answers 501 rather than an empty-but-fake tail.
|
|
logBuf atomic.Pointer[logger.RingBuffer]
|
|
}
|
|
|
|
// 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
|
|
}
|