mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
Limit and split production logs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user