Files
amsterdam/logging.go
T

380 lines
10 KiB
Go

/*
* Amsterdam Web Communities System
* 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
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*/
// Package main contains the high-level Amsterdam logic.
package main
import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"os"
"path/filepath"
"sync"
"time"
"git.erbosoft.com/amy/amsterdam/config"
"github.com/dustin/go-humanize"
"github.com/labstack/echo/v5"
log "github.com/sirupsen/logrus"
)
/*----------------------------------------------------------------------------
* slog handler that outputs to Logrus
*----------------------------------------------------------------------------
*/
// slog2logrus converts slog levels to Logrus levels.
var slog2logrus = map[slog.Level]log.Level{
slog.LevelDebug: log.DebugLevel,
slog.LevelInfo: log.InfoLevel,
slog.LevelWarn: log.WarnLevel,
slog.LevelError: log.ErrorLevel,
}
// SlogLogrusHandler implements slog.Handler and routes to Logrus.
type SlogLogrusHandler struct {
fields log.Fields // fields defined in this handler
groupPrefix string // group prefix
}
// NewSlogLogrusHandler creates a SlogLogrusHandler with base information.
func NewSlogLogrusHandler() *SlogLogrusHandler {
rc := new(SlogLogrusHandler{
fields: make(log.Fields),
groupPrefix: "",
})
return rc
}
// Enabled returns true if the specified log level is handled.
func (h *SlogLogrusHandler) Enabled(ctx context.Context, lvl slog.Level) bool {
return log.IsLevelEnabled(slog2logrus[lvl])
}
// Handle sends a slog.Record to the log output.
func (h *SlogLogrusHandler) Handle(ctx context.Context, r slog.Record) error {
flds := make(log.Fields)
for k, v := range h.fields {
flds[h.groupPrefix+k] = v
}
r.Attrs(func(a slog.Attr) bool {
flds[h.groupPrefix+a.Key] = a.Value.Any()
return true
})
ntry := log.NewEntry(log.StandardLogger()).WithTime(r.Time).WithFields(flds)
ntry.Log(slog2logrus[r.Level], r.Message)
return nil
}
// WithAttrs creates a new Handler from this one, with extra attributes.
func (h *SlogLogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newh := new(SlogLogrusHandler{fields: make(log.Fields)})
maps.Copy(newh.fields, h.fields)
for _, a := range attrs {
newh.fields[a.Key] = a.Value.Any()
}
newh.groupPrefix = h.groupPrefix
return newh
}
// WithGroup creates a new Handler from this one, with an extra group prefix.
func (h *SlogLogrusHandler) WithGroup(name string) slog.Handler {
newh := new(SlogLogrusHandler{fields: make(log.Fields)})
maps.Copy(newh.fields, h.fields)
newh.groupPrefix = h.groupPrefix + name + "."
return newh
}
/*----------------------------------------------------------------------------
* 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 {
start := time.Now()
err := next(c)
stop := time.Now()
req := c.Request()
res := c.Response().(*echo.Response)
log.WithFields(log.Fields{
"remote_ip": c.RealIP(),
"host": req.Host,
"method": req.Method,
"uri": req.RequestURI,
"status": res.Status,
"latency_ms": stop.Sub(start).Milliseconds(),
}).Info("handled request")
return err
}
}
/*----------------------------------------------------------------------------
* Log output file implementation
*----------------------------------------------------------------------------
*/
// amLogFile represents the log output file.
type amLogFile struct {
mutex sync.Mutex // mutex for this object
wr io.WriteCloser // underlying log file
curSize int64 // current size of log file
maxSize int64 // maximum size of log file
logPath string // log file path
keep int // number of uncompressed files to keep
keepCompressed int // number of compressed files to keep
}
// Write (from io.Writer) writes to the log file.
func (lf *amLogFile) Write(p []byte) (int, error) {
lf.mutex.Lock()
n, err := lf.wr.Write(p)
lf.curSize += int64(n)
lf.mutex.Unlock()
return n, err
}
// Close (from io.Closer) closes the log file.
func (lf *amLogFile) Close() error {
lf.mutex.Lock()
err := lf.wr.Close()
lf.wr = nil
lf.mutex.Unlock()
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
}
// 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()
if lf.curSize >= lf.maxSize {
err := lf.rotate()
if err != nil {
//log.Error("log rotation failed")
}
}
lf.mutex.Unlock()
}
// 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)
}