mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36: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"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/database"
|
"github.com/ShukeBta/MediaStationGo/internal/database"
|
||||||
@@ -315,18 +316,68 @@ func newLogger(cfg *config.Config) (*zap.Logger, error) {
|
|||||||
if cfg.App.Debug {
|
if cfg.App.Debug {
|
||||||
return zap.NewDevelopment()
|
return zap.NewDevelopment()
|
||||||
}
|
}
|
||||||
zapCfg := zap.NewProductionConfig()
|
level := productionLogLevel(cfg.Logging.Level)
|
||||||
if level, err := zap.ParseAtomicLevel(strings.TrimSpace(cfg.Logging.Level)); err == nil && cfg.Logging.Level != "" {
|
encoderCfg := zap.NewProductionEncoderConfig()
|
||||||
zapCfg.Level = level
|
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
}
|
var encoder zapcore.Encoder
|
||||||
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
|
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 != "" {
|
cores := []zapcore.Core{
|
||||||
zapCfg.OutputPaths = append(zapCfg.OutputPaths, out)
|
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
|
||||||
zapCfg.ErrorOutputPaths = append(zapCfg.ErrorOutputPaths, out)
|
|
||||||
}
|
}
|
||||||
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.
|
// getLocalIP returns the first non-loopback IPv4 address of the machine.
|
||||||
|
|||||||
+4
-4
@@ -49,11 +49,11 @@ secrets:
|
|||||||
fanart_tv_api_key: ""
|
fanart_tv_api_key: ""
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: info # debug | info | warn | error
|
level: warn # production clamps file/stdout logs to warn or above
|
||||||
format: console # console | json
|
format: console # console | json
|
||||||
output_path: "" # empty = stdout
|
output_path: "" # empty = {data_dir}/logs; use stdout/stderr to disable file logs
|
||||||
enable_rotation: false
|
enable_rotation: true
|
||||||
max_size_mb: 100
|
max_size_mb: 20
|
||||||
max_age_days: 30
|
max_age_days: 30
|
||||||
max_backups: 10
|
max_backups: 10
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,12 @@ services:
|
|||||||
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads}
|
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads}
|
||||||
|
|
||||||
# 日志级别:debug / info / warn / error。
|
# 日志级别: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 示例。
|
# 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。
|
||||||
MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true}
|
MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true}
|
||||||
|
|||||||
@@ -255,9 +255,10 @@ func setDefaults(v *viper.Viper) {
|
|||||||
|
|
||||||
v.SetDefault("secrets.jwt_secret", "")
|
v.SetDefault("secrets.jwt_secret", "")
|
||||||
|
|
||||||
v.SetDefault("logging.level", "info")
|
v.SetDefault("logging.level", "warn")
|
||||||
v.SetDefault("logging.format", "console")
|
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_age_days", 30)
|
||||||
v.SetDefault("logging.max_backups", 10)
|
v.SetDefault("logging.max_backups", 10)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
if cfg.Database.Type != "auto" {
|
if cfg.Database.Type != "auto" {
|
||||||
t.Fatalf("expected default database type auto, got %q", cfg.Database.Type)
|
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 {
|
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||||
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user