Files
AskaEth 025dd1796b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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交叉编译
2026-10-03 15:22:03 +08:00

203 lines
5.2 KiB
Go

package logger
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultRingCapacity is how many recent log records the observability ring
// buffer retains before the oldest is evicted. Compile-time constant.
const DefaultRingCapacity = 2000
// Entry is one normalized log record as the admin observability log tail serves
// it: {time, level, source, msg}. Time is the record's own timestamp; Source is
// the provenance tag ("module/subfeature") when the record carried one.
type Entry struct {
Time time.Time
Level string
Source string
Msg string
}
// RingBuffer is a concurrency-safe bounded buffer of recent log entries. When
// full the oldest entry is overwritten, so it never grows past its capacity and
// Append never blocks or fails. It is the storage behind
// GET /api/v1/admin/observability/logs.
type RingBuffer struct {
mu sync.RWMutex
buf []Entry
next int
full bool
cap int
}
// NewRingBuffer builds a ring buffer holding up to capacity entries, falling
// back to DefaultRingCapacity when capacity is not positive.
func NewRingBuffer(capacity int) *RingBuffer {
if capacity <= 0 {
capacity = DefaultRingCapacity
}
return &RingBuffer{buf: make([]Entry, capacity), cap: capacity}
}
// Append stores e, overwriting the oldest entry when the buffer is full.
func (r *RingBuffer) Append(e Entry) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.buf[r.next] = e
r.next = (r.next + 1) % r.cap
if r.next == 0 {
r.full = true
}
}
// Len returns how many entries are currently retained (at most the capacity).
func (r *RingBuffer) Len() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
if r.full {
return r.cap
}
return r.next
}
// Snapshot returns up to limit most-recent entries, newest first. A non-positive
// limit returns every retained entry.
func (r *RingBuffer) Snapshot(limit int) []Entry {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
count := r.next
if r.full {
count = r.cap
}
if limit > 0 && limit < count {
count = limit
}
out := make([]Entry, 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
}
// ringHandler wraps another slog.Handler: every record the inner handler emits
// is also normalized into buf. The inner handler's own output (console/file) is
// untouched, so installing the ring changes nothing downstream.
type ringHandler struct {
inner slog.Handler
buf *RingBuffer
attrs []slog.Attr
}
// newRingHandler wraps inner so emitted records are captured into buf. A nil
// buffer returns inner unchanged.
func newRingHandler(inner slog.Handler, buf *RingBuffer) slog.Handler {
if buf == nil {
return inner
}
return &ringHandler{inner: inner, buf: buf}
}
// Enabled delegates to the inner handler: the ring captures exactly the records
// the configured level lets through, never more.
func (h *ringHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle captures rec into the ring buffer and forwards it to the inner handler.
func (h *ringHandler) Handle(ctx context.Context, rec slog.Record) error {
h.buf.Append(Entry{
Time: rec.Time,
Level: levelName(rec.Level),
Source: h.sourceOf(rec),
Msg: rec.Message,
})
return h.inner.Handle(ctx, rec)
}
// WithAttrs keeps the captured attributes in sync with the child logger, so a
// source tagged with logger.WithSource is visible to Handle.
func (h *ringHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return &ringHandler{
inner: h.inner.WithAttrs(attrs),
buf: h.buf,
attrs: append(append([]slog.Attr{}, h.attrs...), attrs...),
}
}
// WithGroup keeps the group nesting in sync with the child logger.
func (h *ringHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return &ringHandler{inner: h.inner.WithGroup(name), buf: h.buf, attrs: h.attrs}
}
// sourceOf resolves a record's provenance: the "source" attribute tagged by
// logger.WithSource when present, else the "module" (+"/"+"sub") pair tagged by
// logger.Scoped, else "".
func (h *ringHandler) sourceOf(rec slog.Record) string {
attrs := make([]slog.Attr, 0, len(h.attrs)+rec.NumAttrs())
attrs = append(attrs, h.attrs...)
rec.Attrs(func(a slog.Attr) bool {
attrs = append(attrs, a)
return true
})
if v, ok := attrValue(attrs, "source"); ok {
return v
}
module, ok := attrValue(attrs, "module")
if !ok {
return ""
}
if sub, ok := attrValue(attrs, "sub"); ok && sub != "" {
return module + "/" + sub
}
return module
}
// attrValue returns the value of key in attrs, recursing into groups.
func attrValue(attrs []slog.Attr, key string) (string, bool) {
for _, a := range attrs {
if a.Value.Kind() == slog.KindGroup {
if v, ok := attrValue(a.Value.Group(), key); ok {
return v, true
}
continue
}
if a.Key == key {
return a.Value.String(), true
}
}
return "", false
}
// levelName maps a slog level to the wire name used by the log tail.
func levelName(l slog.Level) string {
switch {
case l >= slog.LevelError:
return "error"
case l >= slog.LevelWarn:
return "warn"
case l >= slog.LevelInfo:
return "info"
default:
return "debug"
}
}