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
@@ -31,6 +31,10 @@ type Options struct {
|
||||
Format string
|
||||
// Out is the destination writer. Defaults to os.Stderr when nil.
|
||||
Out io.Writer
|
||||
// Ring, when non-nil, additionally captures every emitted record into a
|
||||
// bounded ring buffer served by the admin observability log tail. The
|
||||
// console/file output through Out is unchanged.
|
||||
Ring *RingBuffer
|
||||
}
|
||||
|
||||
// New builds a slog.Logger from opts.
|
||||
@@ -49,7 +53,7 @@ func New(opts Options) *slog.Logger {
|
||||
handler = slog.NewTextHandler(out, handlerOpts)
|
||||
}
|
||||
|
||||
return slog.New(handler)
|
||||
return slog.New(newRingHandler(handler, opts.Ring))
|
||||
}
|
||||
|
||||
// ParseLevel maps a string level name to a slog.Level. Unknown or empty values
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ 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"
|
||||
)
|
||||
|
||||
@@ -17,12 +19,17 @@ const (
|
||||
)
|
||||
|
||||
// observability bundles the gateway's live admin instrumentation: the request
|
||||
// metrics counter, the trace span ring buffer and the audit store. It is built in
|
||||
// New and is safe for concurrent use.
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// logEnvelope decodes the {data:[...]} envelope of the log-tail endpoint.
|
||||
type logEnvelope struct {
|
||||
Data []AdminLogEntry `json:"data"`
|
||||
}
|
||||
|
||||
// logsServer builds a gateway with an injected log ring buffer already fed by a
|
||||
// real logger, so the endpoint is exercised against the production capture path.
|
||||
func logsServer(t *testing.T) (*Server, string, string) {
|
||||
t.Helper()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
ring := logger.NewRingBuffer(16)
|
||||
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
|
||||
log.Info("boot ok")
|
||||
logger.WithSource(log, "gateway").Warn("slow request")
|
||||
logger.WithSource(log, "runtime/loop").Error("loop crashed")
|
||||
srv.SetLogBuffer(ring)
|
||||
return srv, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_NewestFirstWithFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, base, tok := logsServer(t)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env logEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
want := []AdminLogEntry{
|
||||
{Level: "error", Source: "runtime/loop", Msg: "loop crashed"},
|
||||
{Level: "warn", Source: "gateway", Msg: "slow request"},
|
||||
{Level: "info", Source: "", Msg: "boot ok"},
|
||||
}
|
||||
if len(env.Data) != len(want) {
|
||||
t.Fatalf("got %d entries, want %d (%+v)", len(env.Data), len(want), env.Data)
|
||||
}
|
||||
for i, w := range want {
|
||||
got := env.Data[i]
|
||||
if got.Level != w.Level || got.Source != w.Source || got.Msg != w.Msg {
|
||||
t.Fatalf("data[%d] = %+v, want level=%s source=%q msg=%q", i, got, w.Level, w.Source, w.Msg)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, got.Time); err != nil {
|
||||
t.Fatalf("data[%d].Time = %q, want RFC3339: %v", i, got.Time, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_LevelFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, base, tok := logsServer(t)
|
||||
|
||||
cases := []struct {
|
||||
level string
|
||||
want []string
|
||||
}{
|
||||
{"debug", []string{"error", "warn", "info"}},
|
||||
{"info", []string{"error", "warn", "info"}},
|
||||
{"warn", []string{"error", "warn"}},
|
||||
{"error", []string{"error"}},
|
||||
{"ERROR", []string{"error"}}, // case-insensitive
|
||||
{"", []string{"error", "warn", "info"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+tc.level, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env logEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if len(env.Data) != len(tc.want) {
|
||||
t.Fatalf("level=%q got %d entries, want %d (%+v)", tc.level, len(env.Data), len(tc.want), env.Data)
|
||||
}
|
||||
for i, lvl := range tc.want {
|
||||
if env.Data[i].Level != lvl {
|
||||
t.Fatalf("level=%q data[%d].Level = %q, want %q", tc.level, i, env.Data[i].Level, lvl)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_UnknownLevelIs400(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, base, tok := logsServer(t)
|
||||
|
||||
for _, bad := range []string{"trace", "warning", "verbose"} {
|
||||
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+bad, nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("level=%q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
if !strings.Contains(string(body), "invalid_level") {
|
||||
t.Fatalf("level=%q body = %s, want invalid_level code", bad, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_LimitClamped(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, base, tok := logsServer(t)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=2", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env logEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(env.Data) != 2 || env.Data[0].Msg != "loop crashed" || env.Data[1].Msg != "slow request" {
|
||||
t.Fatalf("limit=2 data = %+v, want the two newest", env.Data)
|
||||
}
|
||||
|
||||
// An invalid/absent limit falls back to the default (all 3 here < 200).
|
||||
resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=abc", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bad limit status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env2 logEnvelope
|
||||
if err := json.Unmarshal(body, &env2); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(env2.Data) != 3 {
|
||||
t.Fatalf("bad limit returned %d entries, want the default (3)", len(env2.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogLimit_ClampBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mk := func(raw string) int {
|
||||
r, err := http.NewRequest(http.MethodGet, "/?limit="+raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
return parseLogLimit(r)
|
||||
}
|
||||
if got := mk("99999"); got != maxLogLimit {
|
||||
t.Fatalf("parseLogLimit(99999) = %d, want max %d", got, maxLogLimit)
|
||||
}
|
||||
if got := mk("0"); got != defaultLogLimit {
|
||||
t.Fatalf("parseLogLimit(0) = %d, want default %d", got, defaultLogLimit)
|
||||
}
|
||||
if got := mk(""); got != defaultLogLimit {
|
||||
t.Fatalf("parseLogLimit(empty) = %d, want default %d", got, defaultLogLimit)
|
||||
}
|
||||
if got := mk("7"); got != 7 {
|
||||
t.Fatalf("parseLogLimit(7) = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_NoBufferIs501(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
if !strings.Contains(string(body), "not_implemented") {
|
||||
t.Fatalf("body = %s, want not_implemented code", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
srv.SetLogBuffer(logger.NewRingBuffer(4))
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"data":[]`) {
|
||||
t.Fatalf("body = %s, want an empty data list", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_ViewerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
srv.SetLogBuffer(logger.NewRingBuffer(4))
|
||||
tok := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,9 @@ func (s *Server) registerAdminRoutes(
|
||||
mux.Handle("GET /api/v1/admin/observability/metrics", writeAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
|
||||
mux.Handle("GET /api/v1/admin/observability/trace", writeAuth(http.HandlerFunc(s.adminObservabilityTrace)))
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
// Backend log tail: the logger's own bounded ring buffer, injected by the
|
||||
// composition root (501 until then). Newest first, `?level=`/`?limit=` filtered.
|
||||
mux.Handle("GET /api/v1/admin/observability/logs", writeAuth(http.HandlerFunc(s.adminObservabilityLogs)))
|
||||
// Unified notification feed (design/10 §3 #16): a read-only projection of the
|
||||
// audit trail + backthink pushes, so the panel's notification page needs no
|
||||
// new persisted model.
|
||||
|
||||
Reference in new issue
Block a user