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

185 lines
4.7 KiB
Go

package logger
import (
"bytes"
"io"
"log/slog"
"strings"
"testing"
"time"
)
func TestRingBuffer_BoundedDropsOldest(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(3)
for i := 0; i < 5; i++ {
ring.Append(Entry{Time: time.Unix(int64(i), 0), Level: "info", Msg: string(rune('a' + i))})
}
if got := ring.Len(); got != 3 {
t.Fatalf("Len() = %d, want 3 (bounded)", got)
}
got := ring.Snapshot(0)
if len(got) != 3 {
t.Fatalf("Snapshot len = %d, want 3", len(got))
}
want := []string{"e", "d", "c"} // newest first, a/b evicted
for i, msg := range want {
if got[i].Msg != msg {
t.Fatalf("Snapshot[%d].Msg = %q, want %q (got %v)", i, got[i].Msg, msg, got)
}
}
}
func TestRingBuffer_SnapshotLimit(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(5)
for i := 0; i < 5; i++ {
ring.Append(Entry{Level: "info", Msg: string(rune('a' + i))})
}
if got := ring.Snapshot(2); len(got) != 2 || got[0].Msg != "e" || got[1].Msg != "d" {
t.Fatalf("Snapshot(2) = %v, want newest two [e d]", got)
}
if got := ring.Snapshot(-1); len(got) != 5 {
t.Fatalf("Snapshot(-1) len = %d, want all 5", len(got))
}
}
func TestRingBuffer_DefaultCapacityOnNonPositive(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(0)
if got := cap(ring.buf); got != DefaultRingCapacity {
t.Fatalf("capacity = %d, want default %d", got, DefaultRingCapacity)
}
}
func TestNew_RingCapturesSourceLevelAndMsg(t *testing.T) {
t.Parallel()
var out bytes.Buffer
ring := NewRingBuffer(8)
log := New(Options{Level: "debug", Format: "text", Out: &out, Ring: ring})
WithSource(log, "runtime/loop").Warn("agent stalled", "step", 3)
entries := ring.Snapshot(0)
if len(entries) != 1 {
t.Fatalf("captured %d entries, want 1", len(entries))
}
e := entries[0]
if e.Level != "warn" {
t.Fatalf("level = %q, want warn", e.Level)
}
if e.Source != "runtime/loop" {
t.Fatalf("source = %q, want runtime/loop", e.Source)
}
if e.Msg != "agent stalled" {
t.Fatalf("msg = %q, want %q", e.Msg, "agent stalled")
}
if e.Time.IsZero() {
t.Fatal("time is zero, want the record timestamp")
}
// The wrapped output is unchanged: the ring is a pure side channel.
if !strings.Contains(out.String(), "level=WARN") || !strings.Contains(out.String(), "msg=\"agent stalled\"") {
t.Fatalf("inner output changed, got %q", out.String())
}
}
func TestNew_RingExtractsScopedSource(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(8)
log := New(Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
Scoped(log, "channel", "adapter").Info("connected")
entries := ring.Snapshot(0)
if len(entries) != 1 || entries[0].Source != "channel/adapter" {
t.Fatalf("entries = %v, want one with source channel/adapter", entries)
}
}
func TestNew_RingCapturesLevelsNewestFirst(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(8)
log := New(Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
log.Debug("d")
log.Info("i")
log.Warn("w")
log.Error("e")
entries := ring.Snapshot(0)
if len(entries) != 4 {
t.Fatalf("captured %d entries, want 4", len(entries))
}
wantLevels := []string{"error", "warn", "info", "debug"}
wantMsgs := []string{"e", "w", "i", "d"}
for i := range wantLevels {
if entries[i].Level != wantLevels[i] || entries[i].Msg != wantMsgs[i] {
t.Fatalf("entries[%d] = %+v, want level=%s msg=%s", i, entries[i], wantLevels[i], wantMsgs[i])
}
}
}
func TestNew_RingHonorsConfiguredLevel(t *testing.T) {
t.Parallel()
ring := NewRingBuffer(8)
log := New(Options{Level: "warn", Format: "text", Out: io.Discard, Ring: ring})
log.Info("dropped")
log.Warn("kept")
entries := ring.Snapshot(0)
if len(entries) != 1 || entries[0].Msg != "kept" {
t.Fatalf("entries = %v, want only the warn record", entries)
}
}
func TestNew_NilRingIsTransparent(t *testing.T) {
t.Parallel()
var out bytes.Buffer
log := New(Options{Level: "debug", Format: "text", Out: &out})
log.Info("hello")
if !strings.Contains(out.String(), "msg=hello") {
t.Fatalf("logger without a ring stopped emitting, got %q", out.String())
}
}
func TestRingBuffer_NilReceiverIsSafe(t *testing.T) {
t.Parallel()
var ring *RingBuffer
ring.Append(Entry{Msg: "x"})
if got := ring.Snapshot(0); got != nil {
t.Fatalf("Snapshot on nil = %v, want nil", got)
}
if got := ring.Len(); got != 0 {
t.Fatalf("Len on nil = %d, want 0", got)
}
}
func TestLevelName(t *testing.T) {
t.Parallel()
tests := []struct {
in slog.Level
want string
}{
{slog.LevelDebug, "debug"},
{slog.LevelInfo, "info"},
{slog.LevelWarn, "warn"},
{slog.LevelError, "error"},
{slog.LevelError + 4, "error"},
{slog.LevelDebug - 4, "debug"},
}
for _, tt := range tests {
if got := levelName(tt.in); got != tt.want {
t.Fatalf("levelName(%v) = %q, want %q", tt.in, got, tt.want)
}
}
}