From 6db8d2a28e2fb6eee74e8837fa1325e411f55d18 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sat, 31 Jan 2026 17:48:40 +0800 Subject: [PATCH] feat(logging): make error-logs-max-files configurable - Add ErrorLogsMaxFiles config field with default value 10 - Support hot-reload via config file changes - Add Management API: GET/PUT/PATCH /v0/management/error-logs-max-files - Maintain SDK backward compatibility with NewFileRequestLogger (3 params) - Add NewFileRequestLoggerWithOptions for custom error log retention When request logging is disabled, forced error logs are retained up to the configured limit. Set to 0 to disable cleanup. --- config.example.yaml | 4 +++ examples/custom-provider/main.go | 2 +- .../api/handlers/management/config_basic.go | 20 ++++++++++++++ internal/api/server.go | 17 ++++++++++-- internal/config/config.go | 9 +++++++ internal/logging/request_logger.go | 26 ++++++++++++++----- sdk/logging/request_logger.go | 11 ++++++-- 7 files changed, 78 insertions(+), 11 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 83e92627..1547aab3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -50,6 +50,10 @@ logging-to-file: false # files are deleted until within the limit. Set to 0 to disable. logs-max-total-size-mb: 0 +# Maximum number of error log files retained when request logging is disabled. +# When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup. +error-logs-max-files: 10 + # When false, disable in-memory usage statistics aggregation usage-statistics-enabled: false diff --git a/examples/custom-provider/main.go b/examples/custom-provider/main.go index 9dab183e..2f530d7c 100644 --- a/examples/custom-provider/main.go +++ b/examples/custom-provider/main.go @@ -205,7 +205,7 @@ func main() { // Optional: add a simple middleware + custom request logger api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }), api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger { - return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath)) + return logging.NewFileRequestLoggerWithOptions(true, "logs", filepath.Dir(cfgPath), cfg.ErrorLogsMaxFiles) }), ). WithHooks(hooks). diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index 2d3cd1fb..ee2d5c35 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -222,6 +222,26 @@ func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) { h.persist(c) } +// ErrorLogsMaxFiles +func (h *Handler) GetErrorLogsMaxFiles(c *gin.Context) { + c.JSON(200, gin.H{"error-logs-max-files": h.cfg.ErrorLogsMaxFiles}) +} +func (h *Handler) PutErrorLogsMaxFiles(c *gin.Context) { + var body struct { + Value *int `json:"value"` + } + if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + value := *body.Value + if value < 0 { + value = 10 + } + h.cfg.ErrorLogsMaxFiles = value + h.persist(c) +} + // Request log func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) } func (h *Handler) PutRequestLog(c *gin.Context) { diff --git a/internal/api/server.go b/internal/api/server.go index 0a5566ff..fa77abca 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -60,9 +60,9 @@ type ServerOption func(*serverOptionConfig) func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger { configDir := filepath.Dir(configPath) if base := util.WritablePath(); base != "" { - return logging.NewFileRequestLogger(cfg.RequestLog, filepath.Join(base, "logs"), configDir) + return logging.NewFileRequestLogger(cfg.RequestLog, filepath.Join(base, "logs"), configDir, cfg.ErrorLogsMaxFiles) } - return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir) + return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir, cfg.ErrorLogsMaxFiles) } // WithMiddleware appends additional Gin middleware during server construction. @@ -497,6 +497,10 @@ func (s *Server) registerManagementRoutes() { mgmt.PUT("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB) mgmt.PATCH("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB) + mgmt.GET("/error-logs-max-files", s.mgmt.GetErrorLogsMaxFiles) + mgmt.PUT("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles) + mgmt.PATCH("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles) + mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled) mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) @@ -907,6 +911,15 @@ func (s *Server) UpdateClients(cfg *config.Config) { } } + if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) { + if setter, ok := s.requestLogger.(interface{ SetErrorLogsMaxFiles(int) }); ok { + setter.SetErrorLogsMaxFiles(cfg.ErrorLogsMaxFiles) + } + if oldCfg != nil { + log.Debugf("error_logs_max_files updated from %d to %d", oldCfg.ErrorLogsMaxFiles, cfg.ErrorLogsMaxFiles) + } + } + if oldCfg == nil || oldCfg.DisableCooling != cfg.DisableCooling { auth.SetQuotaCooldownDisabled(cfg.DisableCooling) if oldCfg != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 63d04aa4..8567f5a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,10 @@ type Config struct { // When exceeded, the oldest log files are deleted until within the limit. Set to 0 to disable. LogsMaxTotalSizeMB int `yaml:"logs-max-total-size-mb" json:"logs-max-total-size-mb"` + // ErrorLogsMaxFiles limits the number of error log files retained when request logging is disabled. + // When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup. + ErrorLogsMaxFiles int `yaml:"error-logs-max-files" json:"error-logs-max-files"` + // UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded. UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"` @@ -502,6 +506,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6) cfg.LoggingToFile = false cfg.LogsMaxTotalSizeMB = 0 + cfg.ErrorLogsMaxFiles = 10 cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient @@ -550,6 +555,10 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.LogsMaxTotalSizeMB = 0 } + if cfg.ErrorLogsMaxFiles < 0 { + cfg.ErrorLogsMaxFiles = 10 + } + // Sync request authentication providers with inline API keys for backwards compatibility. syncInlineAccessProvider(&cfg) diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go index cf9b4d5c..ad7b03c1 100644 --- a/internal/logging/request_logger.go +++ b/internal/logging/request_logger.go @@ -132,6 +132,9 @@ type FileRequestLogger struct { // logsDir is the directory where log files are stored. logsDir string + + // errorLogsMaxFiles limits the number of error log files retained. + errorLogsMaxFiles int } // NewFileRequestLogger creates a new file-based request logger. @@ -141,10 +144,11 @@ type FileRequestLogger struct { // - logsDir: The directory where log files should be stored (can be relative) // - configDir: The directory of the configuration file; when logsDir is // relative, it will be resolved relative to this directory +// - errorLogsMaxFiles: Maximum number of error log files to retain (0 = no cleanup) // // Returns: // - *FileRequestLogger: A new file-based request logger instance -func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger { +func NewFileRequestLogger(enabled bool, logsDir string, configDir string, errorLogsMaxFiles int) *FileRequestLogger { // Resolve logsDir relative to the configuration file directory when it's not absolute. if !filepath.IsAbs(logsDir) { // If configDir is provided, resolve logsDir relative to it. @@ -153,8 +157,9 @@ func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileR } } return &FileRequestLogger{ - enabled: enabled, - logsDir: logsDir, + enabled: enabled, + logsDir: logsDir, + errorLogsMaxFiles: errorLogsMaxFiles, } } @@ -175,6 +180,11 @@ func (l *FileRequestLogger) SetEnabled(enabled bool) { l.enabled = enabled } +// SetErrorLogsMaxFiles updates the maximum number of error log files to retain. +func (l *FileRequestLogger) SetErrorLogsMaxFiles(maxFiles int) { + l.errorLogsMaxFiles = maxFiles +} + // LogRequest logs a complete non-streaming request/response cycle to a file. // // Parameters: @@ -433,8 +443,12 @@ func (l *FileRequestLogger) sanitizeForFilename(path string) string { return sanitized } -// cleanupOldErrorLogs keeps only the newest 10 forced error log files. +// cleanupOldErrorLogs keeps only the newest errorLogsMaxFiles forced error log files. func (l *FileRequestLogger) cleanupOldErrorLogs() error { + if l.errorLogsMaxFiles <= 0 { + return nil + } + entries, errRead := os.ReadDir(l.logsDir) if errRead != nil { return errRead @@ -462,7 +476,7 @@ func (l *FileRequestLogger) cleanupOldErrorLogs() error { files = append(files, logFile{name: name, modTime: info.ModTime()}) } - if len(files) <= 10 { + if len(files) <= l.errorLogsMaxFiles { return nil } @@ -470,7 +484,7 @@ func (l *FileRequestLogger) cleanupOldErrorLogs() error { return files[i].modTime.After(files[j].modTime) }) - for _, file := range files[10:] { + for _, file := range files[l.errorLogsMaxFiles:] { if errRemove := os.Remove(filepath.Join(l.logsDir, file.name)); errRemove != nil { log.WithError(errRemove).Warnf("failed to remove old error log: %s", file.name) } diff --git a/sdk/logging/request_logger.go b/sdk/logging/request_logger.go index 39ff5ba8..ddbda6b8 100644 --- a/sdk/logging/request_logger.go +++ b/sdk/logging/request_logger.go @@ -3,6 +3,8 @@ package logging import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" +const defaultErrorLogsMaxFiles = 10 + // RequestLogger defines the interface for logging HTTP requests and responses. type RequestLogger = internallogging.RequestLogger @@ -12,7 +14,12 @@ type StreamingLogWriter = internallogging.StreamingLogWriter // FileRequestLogger implements RequestLogger using file-based storage. type FileRequestLogger = internallogging.FileRequestLogger -// NewFileRequestLogger creates a new file-based request logger. +// NewFileRequestLogger creates a new file-based request logger with default error log retention (10 files). func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger { - return internallogging.NewFileRequestLogger(enabled, logsDir, configDir) + return internallogging.NewFileRequestLogger(enabled, logsDir, configDir, defaultErrorLogsMaxFiles) +} + +// NewFileRequestLoggerWithOptions creates a new file-based request logger with configurable error log retention. +func NewFileRequestLoggerWithOptions(enabled bool, logsDir string, configDir string, errorLogsMaxFiles int) *FileRequestLogger { + return internallogging.NewFileRequestLogger(enabled, logsDir, configDir, errorLogsMaxFiles) }