From e2b5ca47abcd9cd0bd47ae68fa8ef4b194bcba8c Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sun, 8 Mar 2026 23:10:07 -0600 Subject: [PATCH] new logging configuration (untested) --- config/config.go | 14 +++ config/default.yaml | 6 + logging.go | 283 ++++++++++++++++++++++++++++++++++++++++++-- main.go | 2 + 4 files changed, 296 insertions(+), 9 deletions(-) diff --git a/config/config.go b/config/config.go index bf65d98..5efb8ed 100644 --- a/config/config.go +++ b/config/config.go @@ -41,6 +41,7 @@ const CONFIGFILE_NAME = "amsterdam.yaml" type AmCLI struct { ConfigFile string `arg:"-C,--config,env:AMSTERDAM_CONFIG" help:"Location of the configuration file."` Debug bool `arg:"-D,--debug,env:AMSTERDAM_DEBUG" help:"Force Amsterdam to run in debug mode."` + LogLevel string `arg:"-L,--level,--loglevel,env:AMSTERDAM_LOG_LEVEL" help:"Set the log level for the server."` Production bool `arg:"-P,--prod,--production,env:AMSTERDAM_PROD" help:"Force Amsterdam to run in production mode."` DatabaseURL string `arg:"-d,--database,env:AMSTERDAM_DATABASE_URL" help:"Database URL for Amsterdam to connect to."` Listen string `arg:"-l,--listen,env:AMSTERDAM_LISTEN" help:"Specifies the local address and port for Amsterdam to listen on."` @@ -112,6 +113,13 @@ type AmConfig struct { Signature string `yaml:"signature"` Disclaimer string `yaml:"disclaimer"` } `yaml:"email"` + Logging struct { + LogFile string `yaml:"logFile"` + MaxLogSize string `yaml:"maxLogSize"` + KeepLogFiles int `yaml:"keepLogFiles"` + KeepCompressedLogFiles int `yaml:"keepCompressedLogFiles"` + LogLevel string `yaml:"logLevel"` + } `yaml:"logging"` Rendering struct { CookieKey string `yaml:"cookiekey"` CountryList struct { @@ -173,6 +181,7 @@ func (c *AmConfig) ExPath(path string) string { // AmConfigComputed is the configuration values which are "computed" based only on values in AmConfig. type AmConfigComputed struct { DebugMode bool // are we in debug mode? + LogLevel string // the logging level Listen string // listen address DatabaseDriver string // name of database driver DatabaseDSN string // DSN for the database @@ -362,6 +371,11 @@ func SetupConfig() { } else { GlobalComputedConfig.DebugMode = !GlobalConfig.Site.Production } + if CommandLine.LogLevel != "" { + GlobalComputedConfig.LogLevel = CommandLine.LogLevel + } else { + GlobalComputedConfig.LogLevel = GlobalConfig.Logging.LogLevel + } if CommandLine.Listen != "" { GlobalComputedConfig.Listen = CommandLine.Listen } else { diff --git a/config/default.yaml b/config/default.yaml index b9cb88d..adb6d1f 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -51,6 +51,12 @@ email: Message sent via Amsterdam Web Communities System - The Amsterdam Project is not responsible for the contents of this message Report abuses to: +logging: + logFile: "" + maxLogSize: "16 MiB" + keepLogFiles: 3 + keepCompressedLogFiles: 3 + logLevel: "debug" rendering: cookiekey: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz countryList: diff --git a/logging.go b/logging.go index 5fd4d71..eff9c3a 100644 --- a/logging.go +++ b/logging.go @@ -1,6 +1,6 @@ /* * Amsterdam Web Communities System - * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -13,23 +13,28 @@ package main import ( "bufio" "bytes" + "compress/gzip" + "context" + "errors" + "fmt" "io" + "os" + "path/filepath" "strings" + "sync" "time" + "git.erbosoft.com/amy/amsterdam/config" + "github.com/dustin/go-humanize" "github.com/labstack/echo/v4" glog "github.com/labstack/gommon/log" log "github.com/sirupsen/logrus" ) -// init sets up the initial configuration for Logrus logging. -func init() { - log.SetFormatter(&log.TextFormatter{ - FullTimestamp: true, - TimestampFormat: "2006-01-02 15:04:05", - }) - log.SetLevel(log.DebugLevel) -} +/*---------------------------------------------------------------------------- + * Gommon-log to logrus adapter + *---------------------------------------------------------------------------- + */ /* toglog converts a Logrus logging level to a glog one. * Parameters: @@ -105,6 +110,11 @@ func (l *EchoLogrusAdapter) Panicf(format string, args ...interface{}) { log.Pan func (l *EchoLogrusAdapter) Panicj(j glog.JSON) { log.WithFields(log.Fields(j)).Panic() } func (l *EchoLogrusAdapter) SetHeader(h string) {} +/*---------------------------------------------------------------------------- + * Echo middleware adapters + *---------------------------------------------------------------------------- + */ + // LogrusMiddleware installs Logrus logging into the Echo middleware chain. func LogrusMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -137,3 +147,258 @@ func LogrusPanicLogging(c echo.Context, err error, stack []byte) error { } return scanner.Err() } + +/*---------------------------------------------------------------------------- + * Log output file implementation + *---------------------------------------------------------------------------- + */ + +// amLogFile represents the log output file. +type amLogFile struct { + mutex sync.Mutex + wr io.WriteCloser + curSize int64 + maxSize int64 + logPath string + keep int + keepCompressed int +} + +// Write (from io.Writer) writes to the log file. +func (lf *amLogFile) Write(p []byte) (int, error) { + lf.mutex.Lock() + defer lf.mutex.Unlock() + n, err := lf.wr.Write(p) + lf.curSize += int64(n) + return n, err +} + +// Close (from io.Closer) closes the log file. +func (lf *amLogFile) Close() error { + lf.mutex.Lock() + defer lf.mutex.Unlock() + err := lf.wr.Close() + lf.wr = nil + return err +} + +// rotate closes the log file and moves it to a new name, shuffling the previously stored log files by the same amount. +func (lf *amLogFile) rotate() error { + if lf.keep == 0 && lf.keepCompressed == 0 { + return nil // degenerate case, keep the log file the same + } + lf.mutex.Lock() + defer lf.mutex.Unlock() + // Close existing logfile if it's open. + reopen := lf.wr != nil + if reopen { + lf.wr.Close() + } + // First loop: shuffle down all the uncompressed files + for i := lf.keep; i >= 1; i-- { + oldpath := fmt.Sprintf("%s.%d", lf.logPath, i) + _, err := os.Stat(oldpath) + if err == nil { + newpath := fmt.Sprintf("%s.%d", lf.logPath, i+1) + err = os.Rename(oldpath, newpath) + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return err + } + } + // Move the original logfile into the 1 slot. + target := fmt.Sprintf("%s.1", lf.logPath) + err := os.Rename(lf.logPath, target) + if err != nil { + return err + } + lastUncompressed := fmt.Sprintf("%s.%d", lf.logPath, lf.keep+1) + if lf.keepCompressed > 0 { + // Second loop: shuffle down all the compressed files + for i := lf.keep + lf.keepCompressed; i >= lf.keep+1; i-- { + oldpath := fmt.Sprintf("%s.%d.gz", lf.logPath, i) + _, err := os.Stat(oldpath) + if err == nil { + newpath := fmt.Sprintf("%s.%d.gz", lf.logPath, i+1) + err = os.Rename(oldpath, newpath) + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return err + } + } + // Remove the last compressed file, it "fell off the end." + oldpath := fmt.Sprintf("%s.%d.gz", lf.logPath, lf.keep+lf.keepCompressed+1) + _, err := os.Stat(oldpath) + if err == nil { + os.Remove(oldpath) + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return err + } + } + _, err = os.Stat(lastUncompressed) + if err == nil { + if lf.keepCompressed > 0 { + // Compress that last uncompressed file. + target := fmt.Sprintf("%s.%d.gz", lf.logPath, lf.keep+1) + var rd io.ReadCloser + rd, err = os.Open(lastUncompressed) + if err == nil { + var xwr io.WriteCloser + xwr, err = os.OpenFile(target, os.O_WRONLY|os.O_CREATE, 0o600) + if err == nil { + wr := gzip.NewWriter(xwr) + _, err = io.Copy(wr, rd) + wr.Close() + xwr.Close() + } + rd.Close() + } + } + // Now remove the last uncompressed file (leaving the compressed copy behind, where applicable) + os.Remove(lastUncompressed) + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + return err + } + // Reopen the (now empty) log file. + if reopen { + var err error + lf.wr, err = os.OpenFile(lf.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + lf.curSize = 0 + } + return nil +} + +// tryRotate sees if the log file needs to be rotated and does so. +func (lf *amLogFile) tryRotate() { + lf.mutex.Lock() + defer lf.mutex.Unlock() + if lf.curSize >= lf.maxSize { + err := lf.rotate() + if err != nil { + log.Error("log rotation failed") + } + } +} + +// open opens the log file and sets up the structure for use. +func (lf *amLogFile) open(path string) error { + lf.mutex.Lock() + defer lf.mutex.Unlock() + lf.logPath = path + lf.wr = nil + fi, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // The file doesn't exist, so check the directory and make sure it does. + dirname := filepath.Dir(path) + fi, err = os.Stat(dirname) + if err != nil || !fi.IsDir() { + return os.ErrNotExist + } + lf.curSize = 0 + } else { + return err + } + } else { + lf.curSize = fi.Size() + } + if lf.curSize >= lf.maxSize { + err = lf.rotate() + if err != nil { + return err + } + } + lf.wr, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + return err +} + +// logScanner is a goroutine that monitors the log file to see when it needs rotating. +func logScanner(ctx context.Context, lf *amLogFile, done chan bool) { + d, _ := time.ParseDuration("10s") + t := time.NewTicker(d) + for { + select { + case <-ctx.Done(): + t.Stop() + done <- true + return + case <-t.C: + lf.tryRotate() + } + } +} + +// SetupLogging sets up the log file based on the configuration data. +func SetupLogging() func() { + loglevel, err := log.ParseLevel(config.GlobalComputedConfig.LogLevel) + if err != nil { + loglevel = log.ErrorLevel + } + if config.GlobalComputedConfig.DebugMode && loglevel != log.TraceLevel { + loglevel = log.DebugLevel + } + var ctx context.Context = nil + var cancelfunc context.CancelFunc = nil + var done chan bool + var logfile io.WriteCloser = nil + if !config.GlobalComputedConfig.DebugMode && config.GlobalConfig.Logging.LogFile != "" { + amlog := new(amLogFile) + maxlog, err := humanize.ParseBytes(config.GlobalConfig.Logging.MaxLogSize) + if err != nil { + maxlog = 16 * 1024 * 1024 // default to 16 megabytes + } + amlog.maxSize = int64(maxlog) + amlog.keep = config.GlobalConfig.Logging.KeepLogFiles + amlog.keepCompressed = config.GlobalConfig.Logging.KeepCompressedLogFiles + err = amlog.open(config.GlobalConfig.Logging.LogFile) + if err == nil { + logfile = amlog + ctx, cancelfunc = context.WithCancel(context.Background()) + done = make(chan bool) + go logScanner(ctx, amlog, done) + } + } + if logfile == nil { + log.SetOutput(os.Stdout) + } else { + log.SetOutput(logfile) + + } + log.SetLevel(loglevel) + + return func() { + if logfile != nil { + log.SetOutput(os.Stdout) + cancelfunc() + <-done + logfile.Close() + } + } +} + +/*---------------------------------------------------------------------------- + * Initialization + *---------------------------------------------------------------------------- + */ + +// init sets up the initial configuration for Logrus logging. +func init() { + log.SetFormatter(&log.TextFormatter{ + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + log.SetLevel(log.DebugLevel) +} diff --git a/main.go b/main.go index f1208bb..538f6cc 100644 --- a/main.go +++ b/main.go @@ -215,6 +215,8 @@ func main() { SystemStartTime = time.Now() // Configure the system. config.SetupConfig() + closer := SetupLogging() + defer closer() closer, err := database.SetupDb() if err != nil { panic(fmt.Sprintf("Database open failure: %v", err))