feat(observability): 日志聚合后端 — logger环形缓冲 + GET /admin/observability/logs
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-03 15:22:03 +08:00
1 parent 79589b5c9f
commit 025dd1796b
11 files changed
+850 -6

No files matched your search

+9 -2
View File
@@ -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)
}
}
+3
View File
@@ -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.