feat(observability): 日志聚合后端 — logger环形缓冲 + GET /admin/observability/logs
- 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交叉编译
This commit is contained in:
1 parent
79589b5c9f
commit
025dd1796b
11 files changed
+850
-6
No files matched your search
@@ -0,0 +1,129 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Log-tail endpoint tuning. Compile-time constants (project rule §10).
|
||||
const (
|
||||
defaultLogLimit = 200
|
||||
maxLogLimit = 1000
|
||||
)
|
||||
|
||||
// AdminLogEntry is the wire shape of one entry in
|
||||
// GET /api/v1/admin/observability/logs: {time,level,source,msg}. `time` is
|
||||
// RFC3339 UTC (matching trace/audit); `level` ∈ debug|info|warn|error; `source`
|
||||
// is the record's provenance tag, empty when the record carried none.
|
||||
type AdminLogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Source string `json:"source"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// SetLogBuffer injects the process-wide log ring buffer behind
|
||||
// GET /api/v1/admin/observability/logs. The composition root calls it once the
|
||||
// gateway exists (the logger is built before the gateway); until then the
|
||||
// endpoint answers 501. It is safe to call concurrently.
|
||||
func (s *Server) SetLogBuffer(buf *logger.RingBuffer) {
|
||||
if s == nil || s.obs == nil {
|
||||
return
|
||||
}
|
||||
s.obs.logBuf.Store(buf)
|
||||
}
|
||||
|
||||
// logBuffer returns the injected log ring buffer, or nil when none was injected.
|
||||
func (s *Server) logBuffer() *logger.RingBuffer {
|
||||
if s == nil || s.obs == nil {
|
||||
return nil
|
||||
}
|
||||
return s.obs.logBuf.Load()
|
||||
}
|
||||
|
||||
// adminObservabilityLogs returns the most recent backend log records, newest
|
||||
// first, optionally filtered to a minimum level (admin read). It is the backend
|
||||
// behind the panel's log tail: the records come from the logger's own ring
|
||||
// buffer and nothing is re-rendered or fabricated.
|
||||
func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
minLevel, filtered, ok := parseLogLevel(r.URL.Query().Get("level"))
|
||||
if !ok {
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "invalid_level",
|
||||
Message: "level 只能为 debug/info/warn/error 或留空",
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
buf := s.logBuffer()
|
||||
if buf == nil {
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "not_implemented",
|
||||
Message: "日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)",
|
||||
HTTPCode: http.StatusNotImplemented,
|
||||
})
|
||||
return
|
||||
}
|
||||
limit := parseLogLimit(r)
|
||||
entries := buf.Snapshot(0)
|
||||
out := make([]AdminLogEntry, 0, min(len(entries), limit))
|
||||
for _, e := range entries {
|
||||
if filtered && logger.ParseLevel(e.Level) < minLevel {
|
||||
continue
|
||||
}
|
||||
out = append(out, adminLogOf(e))
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminLogOf maps a captured log entry to the camelCase admin wire shape.
|
||||
func adminLogOf(e logger.Entry) AdminLogEntry {
|
||||
return AdminLogEntry{
|
||||
Time: e.Time.UTC().Format(time.RFC3339),
|
||||
Level: e.Level,
|
||||
Source: e.Source,
|
||||
Msg: e.Msg,
|
||||
}
|
||||
}
|
||||
|
||||
// parseLogLevel resolves the ?level= filter. An empty value disables filtering
|
||||
// (filtered=false); debug/info/warn/error select that level and above; anything
|
||||
// else is rejected with ok=false so the handler answers 400.
|
||||
func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "":
|
||||
return 0, false, true
|
||||
case "debug":
|
||||
return slog.LevelDebug, true, true
|
||||
case "info":
|
||||
return slog.LevelInfo, true, true
|
||||
case "warn":
|
||||
return slog.LevelWarn, true, true
|
||||
case "error":
|
||||
return slog.LevelError, true, true
|
||||
default:
|
||||
return 0, false, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseLogLimit parses the ?limit= query param, clamping to [1, maxLogLimit] and
|
||||
// selecting defaultLogLimit when absent or invalid.
|
||||
func parseLogLimit(r *http.Request) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil || n <= 0 {
|
||||
return defaultLogLimit
|
||||
}
|
||||
if n > maxLogLimit {
|
||||
return maxLogLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in new issue
Block a user