Files
x/logger/logger.go
T
ginuerzh 219dd51548 refactor(logger): replace logrus with stdlib log/slog
Drop the external github.com/sirupsen/logrus dependency in favor of Go's
standard log/slog package (available since Go 1.21). The core/logger.Logger
interface is preserved unchanged — this is a pure implementation swap.

Key changes:
- slogLogger replaces logrusLogger, backed by slog.Logger + slog.LevelVar
- Custom levelTrace (-8) and levelFatal (+12) constants extend slog's
  built-in 4 levels to cover the 6-level logrus-compatible range
- replaceAttr normalises output: lowercase level names and RFC 3339
  timestamps with milliseconds (matches previous logrus JSON format)
- Shared *slog.LevelVar ensures WithFields clones inherit parent level
- Level guard (early return) in log/logf avoids wasted fmt.Sprint/Sprintf
  when the message level is below the configured threshold
- Documented the stack depth assumption for the caller skip=3 frame

Test coverage (14 tests, all passing with -race):
- convertLevel/revertLevel round-trip for all 6 levels
- levelString for all levels including custom trace/fatal
- replaceAttr timestamp format, level name, groups passthrough
- caller format, level guard, WithFields, IsLevelEnabled, GetLevel
2026-06-17 21:51:06 +08:00

322 lines
8.0 KiB
Go

package logger
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"time"
"github.com/go-gost/core/logger"
)
// Custom slog levels to cover the full logrus-compatible range.
// slog built-in: Debug=-4, Info=0, Warn=4, Error=8.
const (
levelTrace slog.Level = -8
levelFatal slog.Level = 12
)
// Options holds the configuration for a logger.
type Options struct {
Name string
Output io.Writer
Format logger.LogFormat
Level logger.LogLevel
}
// Option is a functional option for configuring a logger.
type Option func(opts *Options)
// NameOption sets the logger name. When set, every log entry carries a
// "logger" field with this value.
func NameOption(name string) Option {
return func(opts *Options) {
opts.Name = name
}
}
// OutputOption sets the output destination. Defaults to os.Stderr.
func OutputOption(out io.Writer) Option {
return func(opts *Options) {
opts.Output = out
}
}
// FormatOption sets the log format (TextFormat or JSONFormat). Defaults to JSON.
func FormatOption(format logger.LogFormat) Option {
return func(opts *Options) {
opts.Format = format
}
}
// LevelOption sets the minimum log level. Defaults to InfoLevel.
func LevelOption(level logger.LogLevel) Option {
return func(opts *Options) {
opts.Level = level
}
}
// slogLogger implements logger.Logger backed by log/slog.
type slogLogger struct {
logger *slog.Logger
level *slog.LevelVar
}
// NewLogger creates a new logger.Logger backed by log/slog.
// The returned logger implements the core/logger.Logger interface.
func NewLogger(opts ...Option) logger.Logger {
var options Options
for _, opt := range opts {
opt(&options)
}
out := options.Output
if out == nil {
out = os.Stderr
}
levelVar := new(slog.LevelVar)
levelVar.Set(convertLevel(options.Level))
handler := newHandler(out, levelVar, options.Format)
sl := slog.New(handler)
if options.Name != "" {
sl = sl.With("logger", options.Name)
}
return &slogLogger{
logger: sl,
level: levelVar,
}
}
// newHandler creates the appropriate slog.Handler based on the format.
func newHandler(w io.Writer, levelVar *slog.LevelVar, format logger.LogFormat) slog.Handler {
ho := &slog.HandlerOptions{
Level: levelVar,
ReplaceAttr: replaceAttr,
}
switch format {
case logger.TextFormat:
return slog.NewTextHandler(w, ho)
default:
return slog.NewJSONHandler(w, ho)
}
}
// replaceAttr normalises slog output to match the logrus format:
// - level names are lowercased (info, debug, warn, error, fatal, trace)
// - timestamps use RFC 3339 with milliseconds and numeric timezone offset
func replaceAttr(groups []string, a slog.Attr) slog.Attr {
if len(groups) > 0 {
return a
}
switch a.Key {
case slog.TimeKey:
if t, ok := a.Value.Any().(time.Time); ok {
a.Value = slog.StringValue(t.Format("2006-01-02T15:04:05.000Z07:00"))
}
case slog.LevelKey:
if level, ok := a.Value.Any().(slog.Level); ok {
a.Value = slog.StringValue(levelString(level))
}
}
return a
}
// levelString returns a logrus-style lowercase level name for the given slog level.
func levelString(level slog.Level) string {
switch {
case level <= levelTrace:
return "trace"
case level <= slog.LevelDebug:
return "debug"
case level <= slog.LevelInfo:
return "info"
case level <= slog.LevelWarn:
return "warn"
case level <= slog.LevelError:
return "error"
default:
return "fatal"
}
}
// convertLevel maps a core logger.LogLevel to the corresponding slog.Level.
func convertLevel(lvl logger.LogLevel) slog.Level {
switch lvl {
case logger.TraceLevel:
return levelTrace
case logger.DebugLevel:
return slog.LevelDebug
case logger.InfoLevel:
return slog.LevelInfo
case logger.WarnLevel:
return slog.LevelWarn
case logger.ErrorLevel:
return slog.LevelError
case logger.FatalLevel:
return levelFatal
default:
return slog.LevelInfo
}
}
// revertLevel maps a slog.Level back to a core logger.LogLevel.
func revertLevel(lvl slog.Level) logger.LogLevel {
switch {
case lvl <= levelTrace:
return logger.TraceLevel
case lvl <= slog.LevelDebug:
return logger.DebugLevel
case lvl <= slog.LevelInfo:
return logger.InfoLevel
case lvl <= slog.LevelWarn:
return logger.WarnLevel
case lvl <= slog.LevelError:
return logger.ErrorLevel
default:
return logger.FatalLevel
}
}
// WithFields returns a new Logger with the given fields attached to every
// subsequent log entry.
func (l *slogLogger) WithFields(fields map[string]any) logger.Logger {
attrs := make([]any, 0, len(fields)*2)
for k, v := range fields {
attrs = append(attrs, k, v)
}
return &slogLogger{
logger: l.logger.With(attrs...),
level: l.level,
}
}
// Trace logs at Trace level.
func (l *slogLogger) Trace(args ...any) {
l.log(levelTrace, args...)
}
// Tracef logs a formatted message at Trace level.
func (l *slogLogger) Tracef(format string, args ...any) {
l.logf(levelTrace, format, args...)
}
// Debug logs at Debug level.
func (l *slogLogger) Debug(args ...any) {
l.log(slog.LevelDebug, args...)
}
// Debugf logs a formatted message at Debug level.
func (l *slogLogger) Debugf(format string, args ...any) {
l.logf(slog.LevelDebug, format, args...)
}
// Info logs at Info level.
func (l *slogLogger) Info(args ...any) {
l.log(slog.LevelInfo, args...)
}
// Infof logs a formatted message at Info level.
func (l *slogLogger) Infof(format string, args ...any) {
l.logf(slog.LevelInfo, format, args...)
}
// Warn logs at Warn level.
func (l *slogLogger) Warn(args ...any) {
l.log(slog.LevelWarn, args...)
}
// Warnf logs a formatted message at Warn level.
func (l *slogLogger) Warnf(format string, args ...any) {
l.logf(slog.LevelWarn, format, args...)
}
// Error logs at Error level.
func (l *slogLogger) Error(args ...any) {
l.log(slog.LevelError, args...)
}
// Errorf logs a formatted message at Error level.
func (l *slogLogger) Errorf(format string, args ...any) {
l.logf(slog.LevelError, format, args...)
}
// Fatal logs at Fatal level and then calls os.Exit(1).
func (l *slogLogger) Fatal(args ...any) {
l.log(levelFatal, args...)
os.Exit(1)
}
// Fatalf logs a formatted message at Fatal level and then calls os.Exit(1).
func (l *slogLogger) Fatalf(format string, args ...any) {
l.logf(levelFatal, format, args...)
os.Exit(1)
}
// GetLevel returns the current log level.
func (l *slogLogger) GetLevel() logger.LogLevel {
return revertLevel(l.level.Level())
}
// IsLevelEnabled reports whether messages at the given level will be logged.
func (l *slogLogger) IsLevelEnabled(level logger.LogLevel) bool {
return l.level.Level() <= convertLevel(level)
}
// log emits a log entry. When the configured level is Debug or lower it
// attaches a "caller" field with the source file and line.
//
// Stack depth assumption for the skip=3 argument to caller:
//
// 0: runtime.Caller
// 1: caller
// 2: log (this method)
// 3: level method (Trace, Debug, Info, etc.)
// 4: user's call site
func (l *slogLogger) log(level slog.Level, args ...any) {
if l.level.Level() > level {
return
}
msg := fmt.Sprint(args...)
if l.level.Level() <= slog.LevelDebug {
l.logger.LogAttrs(context.Background(), level, msg,
slog.String("caller", l.caller(3)))
} else {
l.logger.LogAttrs(context.Background(), level, msg)
}
}
// logf emits a formatted log entry. When the configured level is Debug or
// lower it attaches a "caller" field with the source file and line.
func (l *slogLogger) logf(level slog.Level, format string, args ...any) {
if l.level.Level() > level {
return
}
msg := fmt.Sprintf(format, args...)
if l.level.Level() <= slog.LevelDebug {
l.logger.LogAttrs(context.Background(), level, msg,
slog.String("caller", l.caller(3)))
} else {
l.logger.LogAttrs(context.Background(), level, msg)
}
}
// caller returns a "dir/file.go:line" description of the caller, skipping
// the given number of stack frames.
func (l *slogLogger) caller(skip int) string {
_, file, line, ok := runtime.Caller(skip)
if !ok {
file = "<???>"
} else {
file = filepath.Join(filepath.Base(filepath.Dir(file)), filepath.Base(file))
}
return fmt.Sprintf("%s:%d", file, line)
}