From 9a47a876ebbd7943e9c95f4708e398c4e6f458b5 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Tue, 18 Nov 2025 00:18:32 +0100 Subject: [PATCH] feat(logs): by default, log server activity to a file. --- .gitignore | 3 ++ README.md | 19 ++++++++ config.example.json | 8 ++++ config/config.go | 20 +++++++++ go.mod | 1 + go.sum | 2 + main.go | 77 +++++++++++++++++++++++++++++--- tools/loganalyzer/connections.go | 4 +- tools/loganalyzer/errors.go | 4 +- tools/loganalyzer/filter.go | 2 +- tools/loganalyzer/stats.go | 4 +- tools/loganalyzer/tail.go | 4 +- 12 files changed, 132 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index a5f4690d4..2558122ce 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ erupe-ce *.bin savedata/*/ config.json + +# Logs +logs/ diff --git a/README.md b/README.md index 7fc58a38c..ea8b14e68 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,25 @@ Channel servers are configured under `Entrance.Entries[].Channels[]` with indivi } ``` +### Logging + +Erupe supports automatic file-based logging with rotation for production environments: + +```json +{ + "Logging": { + "LogToFile": true, // Enable file logging (default: true) + "LogFilePath": "logs/erupe.log", // Log file path (default: "logs/erupe.log") + "LogMaxSize": 100, // Max file size in MB before rotation (default: 100) + "LogMaxBackups": 3, // Number of old log files to keep (default: 3) + "LogMaxAge": 28, // Max days to retain old logs (default: 28) + "LogCompress": true // Compress rotated logs (default: true) + } +} +``` + +Logs are written to both console and file simultaneously. The log analyzer tool in `tools/loganalyzer/` provides commands to filter, analyze errors, track connections, and generate statistics from log files. + ### In-game Commands Configure available commands and their prefixes: diff --git a/config.example.json b/config.example.json index 03dca44e5..f2e429900 100644 --- a/config.example.json +++ b/config.example.json @@ -42,6 +42,14 @@ "BonusQuestAllowance": 3, "DailyQuestAllowance": 1 }, + "Logging": { + "LogToFile": true, + "LogFilePath": "logs/erupe.log", + "LogMaxSize": 100, + "LogMaxBackups": 3, + "LogMaxAge": 28, + "LogCompress": true + }, "Discord": { "Enabled": false, "BotToken": "", diff --git a/config/config.go b/config/config.go index 32193080e..8f0e15916 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,7 @@ type Config struct { DevModeOptions DevModeOptions GameplayOptions GameplayOptions + Logging Logging Discord Discord Commands []Command Courses []Course @@ -73,6 +74,16 @@ type GameplayOptions struct { DailyQuestAllowance uint32 // Number of Daily Quests to allow daily } +// Logging holds the logging configuration. +type Logging struct { + LogToFile bool // Enable/disable file logging (default: true) + LogFilePath string // File path for logs (default: "logs/erupe.log") + LogMaxSize int // Max size in MB before rotation (default: 100) + LogMaxBackups int // Number of old log files to keep (default: 3) + LogMaxAge int // Max days to retain old logs (default: 28) + LogCompress bool // Compress rotated logs (default: true) +} + // Discord holds the discord integration config. type Discord struct { Enabled bool @@ -197,6 +208,15 @@ func LoadConfig() (*Config, error) { OutputDir: "savedata", }) + viper.SetDefault("Logging", Logging{ + LogToFile: true, + LogFilePath: "logs/erupe.log", + LogMaxSize: 100, + LogMaxBackups: 3, + LogMaxAge: 28, + LogCompress: true, + }) + err := viper.ReadInConfig() if err != nil { return nil, err diff --git a/go.mod b/go.mod index 7da2e0970..3a0a41316 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( golang.org/x/crypto v0.1.0 golang.org/x/exp v0.0.0-20221028150844-83b7d23a625f golang.org/x/text v0.7.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( diff --git a/go.sum b/go.sum index 3c8e4d29e..6e23e4686 100644 --- a/go.sum +++ b/go.sum @@ -607,6 +607,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index a93d532fa..aadc12ff8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "net" "os" "os/signal" + "path/filepath" "runtime/debug" "syscall" "time" @@ -19,6 +20,8 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" ) // Temporary DB auto clean on startup for quick development & testing. @@ -41,16 +44,76 @@ var Commit = func() string { return "unknown" } +// initLogger initializes the zap logger with file logging support +func initLogger(cfg *config.Config) *zap.Logger { + var zapConfig zap.Config + + // Base configuration based on DevMode + if cfg.DevMode { + zapConfig = zap.NewDevelopmentConfig() + } else { + zapConfig = zap.NewProductionConfig() + } + + // Configure output paths + if cfg.Logging.LogToFile { + // Ensure the log directory exists + logDir := filepath.Dir(cfg.Logging.LogFilePath) + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Printf("Failed to create log directory: %s\n", err) + os.Exit(1) + } + + // Create lumberjack logger for file rotation + fileWriter := &lumberjack.Logger{ + Filename: cfg.Logging.LogFilePath, + MaxSize: cfg.Logging.LogMaxSize, + MaxBackups: cfg.Logging.LogMaxBackups, + MaxAge: cfg.Logging.LogMaxAge, + Compress: cfg.Logging.LogCompress, + } + + // Create encoder + encoderConfig := zapConfig.EncoderConfig + var encoder zapcore.Encoder + if cfg.DevMode { + encoder = zapcore.NewConsoleEncoder(encoderConfig) + } else { + encoder = zapcore.NewJSONEncoder(encoderConfig) + } + + // Create cores for both console and file output + consoleCore := zapcore.NewCore( + encoder, + zapcore.AddSync(os.Stderr), + zapConfig.Level, + ) + fileCore := zapcore.NewCore( + encoder, + zapcore.AddSync(fileWriter), + zapConfig.Level, + ) + + // Combine cores + core := zapcore.NewTee(consoleCore, fileCore) + logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) + return logger + } + + // File logging disabled, use default configuration + logger, err := zapConfig.Build() + if err != nil { + fmt.Printf("Failed to initialize logger: %s\n", err) + os.Exit(1) + } + return logger +} + func main() { var err error - var zapLogger *zap.Logger - if config.ErupeConfig.DevMode { - zapLogger, _ = zap.NewDevelopment() - } else { - zapLogger, _ = zap.NewProduction() - } - + // Initialize logger with file support + zapLogger := initLogger(config.ErupeConfig) defer zapLogger.Sync() logger := zapLogger.Named("main") diff --git a/tools/loganalyzer/connections.go b/tools/loganalyzer/connections.go index ef493b82a..d32400faa 100644 --- a/tools/loganalyzer/connections.go +++ b/tools/loganalyzer/connections.go @@ -54,7 +54,7 @@ type ConnectionStats struct { // - Provides verbose session details including objects and stage changes // // Options: -// - f: Path to log file (default: "erupe.log") +// - f: Path to log file (default: "logs/erupe.log") // - player: Filter sessions by player name (case-insensitive substring match) // - sessions: Show individual player sessions // - stats: Show connection statistics (default: true) @@ -67,7 +67,7 @@ type ConnectionStats struct { func runConnections(args []string) { fs := flag.NewFlagSet("connections", flag.ExitOnError) - logFile := fs.String("f", "erupe.log", "Path to log file") + logFile := fs.String("f", "logs/erupe.log", "Path to log file") player := fs.String("player", "", "Filter by player name") showSessions := fs.Bool("sessions", false, "Show individual player sessions") showStats := fs.Bool("stats", true, "Show connection statistics") diff --git a/tools/loganalyzer/errors.go b/tools/loganalyzer/errors.go index de128e0d5..4392b9779 100644 --- a/tools/loganalyzer/errors.go +++ b/tools/loganalyzer/errors.go @@ -36,7 +36,7 @@ type ErrorGroup struct { // - Tracks which callers produced each error // // Options: -// - f: Path to log file (default: "erupe.log") +// - f: Path to log file (default: "logs/erupe.log") // - group: Group errors by "message", "caller", or "logger" (default: "message") // - stack: Show stack traces in detailed view // - limit: Maximum number of example entries per group (default: 10) @@ -50,7 +50,7 @@ type ErrorGroup struct { func runErrors(args []string) { fs := flag.NewFlagSet("errors", flag.ExitOnError) - logFile := fs.String("f", "erupe.log", "Path to log file") + logFile := fs.String("f", "logs/erupe.log", "Path to log file") groupBy := fs.String("group", "message", "Group errors by: message, caller, or logger") showStack := fs.Bool("stack", false, "Show stack traces") limit := fs.Int("limit", 10, "Limit number of examples per error group") diff --git a/tools/loganalyzer/filter.go b/tools/loganalyzer/filter.go index 069b8e5c3..ed48f057a 100644 --- a/tools/loganalyzer/filter.go +++ b/tools/loganalyzer/filter.go @@ -29,7 +29,7 @@ import ( func runFilter(args []string) { fs := flag.NewFlagSet("filter", flag.ExitOnError) - logFile := fs.String("f", "erupe.log", "Path to log file") + logFile := fs.String("f", "logs/erupe.log", "Path to log file") level := fs.String("level", "", "Filter by log level (info, warn, error, fatal)") logger := fs.String("logger", "", "Filter by logger name (supports wildcards)") message := fs.String("msg", "", "Filter by message content (case-insensitive)") diff --git a/tools/loganalyzer/stats.go b/tools/loganalyzer/stats.go index b60d1d12a..1a3b5899f 100644 --- a/tools/loganalyzer/stats.go +++ b/tools/loganalyzer/stats.go @@ -44,7 +44,7 @@ type LogStats struct { // patterns, peak usage times, and potential issues. // // Options: -// - f: Path to log file (default: "erupe.log") +// - f: Path to log file (default: "logs/erupe.log") // - top: Number of top items to show in detailed view (default: 10) // - detailed: Show detailed statistics including temporal patterns and top messages // @@ -55,7 +55,7 @@ type LogStats struct { func runStats(args []string) { fs := flag.NewFlagSet("stats", flag.ExitOnError) - logFile := fs.String("f", "erupe.log", "Path to log file") + logFile := fs.String("f", "logs/erupe.log", "Path to log file") topN := fs.Int("top", 10, "Show top N messages/loggers") detailed := fs.Bool("detailed", false, "Show detailed statistics") diff --git a/tools/loganalyzer/tail.go b/tools/loganalyzer/tail.go index 5d8b566c0..9ca944c89 100644 --- a/tools/loganalyzer/tail.go +++ b/tools/loganalyzer/tail.go @@ -21,7 +21,7 @@ import ( // Both phases support filtering by log level and colorized output. // // Options: -// - f: Path to log file (default: "erupe.log") +// - f: Path to log file (default: "logs/erupe.log") // - n: Number of initial lines to show (default: 10) // - follow: Whether to continue following the file (default: true) // - level: Filter by log level (info, warn, error, fatal) @@ -37,7 +37,7 @@ import ( func runTail(args []string) { fs := flag.NewFlagSet("tail", flag.ExitOnError) - logFile := fs.String("f", "erupe.log", "Path to log file") + logFile := fs.String("f", "logs/erupe.log", "Path to log file") lines := fs.Int("n", 10, "Number of initial lines to show") follow := fs.Bool("follow", true, "Follow the log file (like tail -f)") level := fs.String("level", "", "Filter by log level")