From 123ec9a35483322f8e7aa9029dec4ace63d4483c Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Tue, 5 May 2026 16:38:19 +1200 Subject: [PATCH] Chore: Remove logrus dependency and implement slog-based logging --- go.mod | 1 - go.sum | 2 - internal/logger/logger.go | 162 +++++++++++++++++++++++++++++++------- 3 files changed, 133 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index 5e15175..9e83765 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 - github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/tg123/go-htpasswd v1.2.4 diff --git a/go.sum b/go.sum index 7aafa73..3caa984 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,6 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 0132be2..ee95ba6 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,73 +1,177 @@ // Package logger handles the logging +// Mailpit now uses slog for logging, but this package provides a logrus-compatible API and formatting to avoid changing all existing log calls +// and provide backwards compatibility with logrus formatting and features like log levels and file output. package logger import ( + "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "regexp" - - "github.com/sirupsen/logrus" + "sync" ) +// Logger wraps slog.Logger providing a logrus-compatible API +type Logger struct { + sl *slog.Logger +} + var ( - log *logrus.Logger + log *Logger // VerboseLogging for verbose logging VerboseLogging bool // QuietLogging shows only errors QuietLogging bool - // NoLogging shows only fatal errors + // NoLogging disables all logging (tests) NoLogging bool // LogFile sets a log file LogFile string ) -// Log returns the logger instance -func Log() *logrus.Logger { +// Log returns the logger instance, initialising it on first call. The level and +// output destination are determined once from VerboseLogging, QuietLogging, +// NoLogging, and LogFile at the time of first use. +func Log() *Logger { if log == nil { - log = logrus.New() - log.SetLevel(logrus.InfoLevel) - if VerboseLogging { - // verbose logging (debug) - log.SetLevel(logrus.DebugLevel) - } else if QuietLogging { - // show errors only - log.SetLevel(logrus.ErrorLevel) - } else if NoLogging { - // disable all logging (tests) - log.SetLevel(logrus.PanicLevel) + level := slog.LevelInfo + switch { + case VerboseLogging: + level = slog.LevelDebug + case QuietLogging: + level = slog.LevelError + case NoLogging: + level = slog.Level(100) // above all real levels — silences all output } + out := os.Stdout if LogFile != "" { file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec if err == nil { - log.Out = file + out = file } else { - log.Out = os.Stdout - log.Warn("Failed to log to file, using default stderr") + fmt.Fprintln(os.Stderr, "failed to log to file, using default stdout") } - } else { - log.Out = os.Stdout } - log.SetFormatter(&logrus.TextFormatter{ - FullTimestamp: true, - TimestampFormat: "2006/01/02 15:04:05", - }) + log = &Logger{ + sl: slog.New(&logrusHandler{ + out: out, + level: level, + color: isTerminal(out), + }), + } } return log } -// PrettyPrint for debugging +// logrusHandler is a slog.Handler that formats output to match logrus TextFormatter. +// TTY output: INFO[2006/01/02 15:04:05] message +// File output: time="2006/01/02 15:04:05" level=info msg="message" +type logrusHandler struct { + mu sync.Mutex + out *os.File + level slog.Level + color bool +} + +// Enabled reports whether the handler will emit a record at the given level. +func (h *logrusHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle formats and writes a log record. TTY output is coloured; file output +// uses the logrus key=value text format. +func (h *logrusHandler) Handle(_ context.Context, r slog.Record) error { + label, name, code := logrusLevel(r.Level) + ts := r.Time.Format("2006/01/02 15:04:05") + + var line string + if h.color { + line = fmt.Sprintf("\x1b[%dm%s\x1b[0m[%s] %s\n", code, label, ts, r.Message) + } else { + line = fmt.Sprintf("time=%q level=%s msg=%q\n", ts, name, r.Message) + } + + h.mu.Lock() + defer h.mu.Unlock() + _, err := fmt.Fprint(h.out, line) + return err +} + +// WithAttrs returns the handler unchanged; structured attributes are not used. +func (h *logrusHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } + +// WithGroup returns the handler unchanged; groups are not used. +func (h *logrusHandler) WithGroup(_ string) slog.Handler { return h } + +// logrusLevel maps slog levels to the 4-char TTY label, lowercase file label, and ANSI colour code. +func logrusLevel(level slog.Level) (string, string, int) { + switch { + case level < slog.LevelInfo: + return "DEBU", "debug", 37 // gray + case level < slog.LevelWarn: + return "INFO", "info", 36 // cyan + case level < slog.LevelError: + return "WARN", "warning", 33 // yellow + default: + return "ERRO", "error", 31 // red + } +} + +// isTerminal reports whether f is connected to a terminal. +func isTerminal(f *os.File) bool { + info, err := f.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +// Info logs a message at INFO level. +func (l *Logger) Info(args ...any) { l.sl.Info(fmt.Sprint(args...)) } + +// Infof logs a formatted message at INFO level. +func (l *Logger) Infof(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) } + +// Debug logs a message at DEBUG level. +func (l *Logger) Debug(args ...any) { l.sl.Debug(fmt.Sprint(args...)) } + +// Debugf logs a formatted message at DEBUG level. +func (l *Logger) Debugf(format string, args ...any) { l.sl.Debug(fmt.Sprintf(format, args...)) } + +// Warn logs a message at WARN level. +func (l *Logger) Warn(args ...any) { l.sl.Warn(fmt.Sprint(args...)) } + +// Warnf logs a formatted message at WARN level. +func (l *Logger) Warnf(format string, args ...any) { l.sl.Warn(fmt.Sprintf(format, args...)) } + +// Error logs a message at ERROR level. +func (l *Logger) Error(args ...any) { l.sl.Error(fmt.Sprint(args...)) } + +// Errorf logs a formatted message at ERROR level. +func (l *Logger) Errorf(format string, args ...any) { l.sl.Error(fmt.Sprintf(format, args...)) } + +// Printf logs a formatted message at INFO level. +func (l *Logger) Printf(format string, args ...any) { l.sl.Info(fmt.Sprintf(format, args...)) } + +// Fatal logs a message at ERROR level then exits with status 1. +func (l *Logger) Fatal(args ...any) { l.sl.Error(fmt.Sprint(args...)); os.Exit(1) } + +// Fatalf logs a formatted message at ERROR level then exits with status 1. +func (l *Logger) Fatalf(format string, args ...any) { + l.sl.Error(fmt.Sprintf(format, args...)) + os.Exit(1) +} + +// PrettyPrint prints any value as indented JSON to stdout, for debugging. func PrettyPrint(i any) { s, _ := json.MarshalIndent(i, "", "\t") fmt.Println(string(s)) } -// CleanHTTPIP returns a human-readable IP for the logging interface -// when starting services. It translates [::]: to "localhost:" +// CleanHTTPIP returns a human-readable address for log output. +// It translates [::]: to localhost:. func CleanHTTPIP(s string) string { re := regexp.MustCompile(`^\[\:\:\]\:\d+`) if re.MatchString(s) {