- 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交叉编译
203 lines
5.2 KiB
Go
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"
|
|
}
|
|
}
|