Limit and split production logs

This commit is contained in:
ShukeBta
2026-06-16 12:58:27 +08:00
parent 877912fe4b
commit 3f40b18909
7 changed files with 287 additions and 16 deletions
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
const defaultLogMaxSizeMB = 20
type rotatingFileWriter struct {
mu sync.Mutex
path string
maxSize int64
maxBackups int
maxAge time.Duration
file *os.File
size int64
}
func newRotatingFileWriter(path string, cfg config.LoggingConfig) (*rotatingFileWriter, error) {
if path == "" {
return nil, fmt.Errorf("log path required")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
maxSizeMB := cfg.MaxSizeMB
if maxSizeMB <= 0 {
maxSizeMB = defaultLogMaxSizeMB
}
w := &rotatingFileWriter{
path: path,
maxBackups: cfg.MaxBackups,
}
if cfg.EnableRotation {
w.maxSize = int64(maxSizeMB) * 1024 * 1024
}
if w.maxBackups < 0 {
w.maxBackups = 0
}
if cfg.MaxAgeDays > 0 {
w.maxAge = time.Duration(cfg.MaxAgeDays) * 24 * time.Hour
}
if err := w.open(); err != nil {
return nil, err
}
return w, nil
}
func (w *rotatingFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
if err := w.open(); err != nil {
return 0, err
}
}
if w.maxSize > 0 && w.size > 0 && w.size+int64(len(p)) > w.maxSize {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rotatingFileWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Sync()
closeErr := w.file.Close()
w.file = nil
w.size = 0
if err != nil {
return err
}
return closeErr
}
func (w *rotatingFileWriter) open() error {
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
return fmt.Errorf("open log file %s: %w", w.path, err)
}
w.file = file
if stat, err := file.Stat(); err == nil {
w.size = stat.Size()
}
return nil
}
func (w *rotatingFileWriter) rotate() error {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
if w.maxBackups == 0 {
_ = os.Remove(w.path)
return w.open()
}
for i := w.maxBackups - 1; i >= 1; i-- {
oldPath := fmt.Sprintf("%s.%d", w.path, i)
newPath := fmt.Sprintf("%s.%d", w.path, i+1)
if _, err := os.Stat(oldPath); err == nil {
_ = os.Rename(oldPath, newPath)
}
}
if _, err := os.Stat(w.path); err == nil {
_ = os.Rename(w.path, fmt.Sprintf("%s.1", w.path))
}
w.pruneByAge()
return w.open()
}
func (w *rotatingFileWriter) pruneByAge() {
if w.maxAge <= 0 {
return
}
cutoff := time.Now().Add(-w.maxAge)
for i := 1; i <= w.maxBackups; i++ {
path := fmt.Sprintf("%s.%d", w.path, i)
if stat, err := os.Stat(path); err == nil && stat.ModTime().Before(cutoff) {
_ = os.Remove(path)
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
func TestProductionLoggerSplitsWarnAndErrorAndDropsInfo(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Level = "info"
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, err := newLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("info should be dropped")
log.Warn("warning only", zap.String("kind", "warn"))
log.Error("error only", zap.String("kind", "error"))
_ = log.Sync()
warnBytes, err := os.ReadFile(filepath.Join(dir, "logs", "warn.log"))
if err != nil {
t.Fatal(err)
}
errorBytes, err := os.ReadFile(filepath.Join(dir, "logs", "error.log"))
if err != nil {
t.Fatal(err)
}
warnLog := string(warnBytes)
errorLog := string(errorBytes)
if strings.Contains(warnLog, "info should be dropped") || strings.Contains(errorLog, "info should be dropped") {
t.Fatal("info log should not be written in production")
}
if !strings.Contains(warnLog, "warning only") || strings.Contains(warnLog, "error only") {
t.Fatalf("warn log not isolated: %s", warnLog)
}
if !strings.Contains(errorLog, "error only") || strings.Contains(errorLog, "warning only") {
t.Fatalf("error log not isolated: %s", errorLog)
}
}
func TestRotatingFileWriterCapsFileSize(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.log")
writer, err := newRotatingFileWriter(path, config.LoggingConfig{
EnableRotation: true,
MaxSizeMB: 1,
MaxBackups: 2,
})
if err != nil {
t.Fatal(err)
}
chunk := strings.Repeat("x", 700*1024)
if _, err := writer.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path + ".1"); err != nil {
t.Fatalf("expected rotated backup: %v", err)
}
_ = writer.Sync()
}
+60 -9
View File
@@ -27,6 +27,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/database"
@@ -315,18 +316,68 @@ func newLogger(cfg *config.Config) (*zap.Logger, error) {
if cfg.App.Debug {
return zap.NewDevelopment()
}
zapCfg := zap.NewProductionConfig()
if level, err := zap.ParseAtomicLevel(strings.TrimSpace(cfg.Logging.Level)); err == nil && cfg.Logging.Level != "" {
zapCfg.Level = level
}
level := productionLogLevel(cfg.Logging.Level)
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
zapCfg.Encoding = "console"
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
if out := strings.TrimSpace(cfg.Logging.OutputPath); out != "" {
zapCfg.OutputPaths = append(zapCfg.OutputPaths, out)
zapCfg.ErrorOutputPaths = append(zapCfg.ErrorOutputPaths, out)
cores := []zapcore.Core{
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
}
return zapCfg.Build()
warnPath, errorPath := logFilePaths(cfg)
if warnPath != "" {
warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging)
if err != nil {
return nil, err
}
cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl == zapcore.WarnLevel && level.Enabled(lvl)
})))
}
if errorPath != "" {
errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging)
if err != nil {
return nil, err
}
cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl >= zapcore.ErrorLevel && level.Enabled(lvl)
})))
}
return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), nil
}
func productionLogLevel(raw string) zapcore.Level {
level := zapcore.WarnLevel
raw = strings.TrimSpace(raw)
if raw != "" {
var parsed zapcore.Level
if err := parsed.UnmarshalText([]byte(raw)); err == nil {
level = parsed
}
}
if level < zapcore.WarnLevel {
return zapcore.WarnLevel
}
return level
}
func logFilePaths(cfg *config.Config) (string, string) {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return "", ""
}
if out == "" {
out = filepath.Join(cfg.App.DataDir, "logs")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".warn" + ext, base + ".error" + ext
}
return filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
// getLocalIP returns the first non-loopback IPv4 address of the machine.
+4 -4
View File
@@ -49,11 +49,11 @@ secrets:
fanart_tv_api_key: ""
logging:
level: info # debug | info | warn | error
level: warn # production clamps file/stdout logs to warn or above
format: console # console | json
output_path: "" # empty = stdout
enable_rotation: false
max_size_mb: 100
output_path: "" # empty = {data_dir}/logs; use stdout/stderr to disable file logs
enable_rotation: true
max_size_mb: 20
max_age_days: 30
max_backups: 10
+6 -1
View File
@@ -130,7 +130,12 @@ services:
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads}
# 日志级别:debug / info / warn / error。
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info}
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-warn}
MEDIASTATION_LOGGING_OUTPUT_PATH: ${MEDIASTATION_LOGGING_OUTPUT_PATH:-/data/logs}
MEDIASTATION_LOGGING_ENABLE_ROTATION: ${MEDIASTATION_LOGGING_ENABLE_ROTATION:-true}
MEDIASTATION_LOGGING_MAX_SIZE_MB: ${MEDIASTATION_LOGGING_MAX_SIZE_MB:-20}
MEDIASTATION_LOGGING_MAX_AGE_DAYS: ${MEDIASTATION_LOGGING_MAX_AGE_DAYS:-30}
MEDIASTATION_LOGGING_MAX_BACKUPS: ${MEDIASTATION_LOGGING_MAX_BACKUPS:-10}
# 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。
MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true}
+3 -2
View File
@@ -255,9 +255,10 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("secrets.jwt_secret", "")
v.SetDefault("logging.level", "info")
v.SetDefault("logging.level", "warn")
v.SetDefault("logging.format", "console")
v.SetDefault("logging.max_size_mb", 100)
v.SetDefault("logging.enable_rotation", true)
v.SetDefault("logging.max_size_mb", 20)
v.SetDefault("logging.max_age_days", 30)
v.SetDefault("logging.max_backups", 10)
+3
View File
@@ -34,6 +34,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Database.Type != "auto" {
t.Fatalf("expected default database type auto, got %q", cfg.Database.Type)
}
if cfg.Logging.Level != "warn" || !cfg.Logging.EnableRotation || cfg.Logging.MaxSizeMB != 20 {
t.Fatalf("expected warn rotating logs by default, got level=%q rotation=%v max=%d", cfg.Logging.Level, cfg.Logging.EnableRotation, cfg.Logging.MaxSizeMB)
}
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
}