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 }