From e3f17e55eb5e4fc7d5d877f16a11bc54f8af73af Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Thu, 9 Nov 2023 09:59:13 +0100 Subject: [PATCH 1/2] Basic slog hook --- hooks/slog/slog.go | 70 +++++++++++++++++++++++++++++++++++++++ hooks/slog/slog_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 hooks/slog/slog.go create mode 100644 hooks/slog/slog_test.go diff --git a/hooks/slog/slog.go b/hooks/slog/slog.go new file mode 100644 index 00000000..4c8aac5a --- /dev/null +++ b/hooks/slog/slog.go @@ -0,0 +1,70 @@ +//go:build go1.21 +// +build go1.21 + +package slog + +import ( + "log/slog" + + "github.com/sirupsen/logrus" +) + +// SlogHook sends logs to slog. +type SlogHook struct { + logger *slog.Logger +} + +var _ logrus.Hook = (*SlogHook)(nil) + +// NewSlogHook creates a hook that sends logs to an existing slog Logger. +// This hook is intended to be used during transition from Logrus to slog, +// or as a shim between different parts of your application or different +// libraries that depend on different loggers. +// +// Example usage: +// +// logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) +// hook := NewSlogHook(logger) +func NewSlogHook(logger *slog.Logger) *SlogHook { + return &SlogHook{ + logger: logger, + } +} + +func (*SlogHook) toSlogLevel(level logrus.Level) slog.Level { + switch level { + case logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel: + return slog.LevelError + case logrus.WarnLevel: + return slog.LevelWarn + case logrus.InfoLevel: + return slog.LevelInfo + case logrus.DebugLevel, logrus.TraceLevel: + return slog.LevelDebug + default: + // Treat all unknown levels as errors + return slog.LevelError + } +} + +// Levels always returns all levels, since slog allows controlling level +// enabling based on context. +func (h *SlogHook) Levels() []logrus.Level { + return logrus.AllLevels +} + +// Fire sends entry to the underlying slog logger. The Time and Caller fields +// of entry are ignored. +func (h *SlogHook) Fire(entry *logrus.Entry) error { + attrs := make([]interface{}, 0, len(entry.Data)) + for k, v := range entry.Data { + attrs = append(attrs, slog.Any(k, v)) + } + h.logger.Log( + entry.Context, + h.toSlogLevel(entry.Level), + entry.Message, + attrs..., + ) + return nil +} diff --git a/hooks/slog/slog_test.go b/hooks/slog/slog_test.go new file mode 100644 index 00000000..71219faa --- /dev/null +++ b/hooks/slog/slog_test.go @@ -0,0 +1,72 @@ +//go:build go1.21 +// +build go1.21 + +package slog + +import ( + "bytes" + "io" + "log/slog" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +func TestSlogHook(t *testing.T) { + tests := []struct { + name string + fn func(*logrus.Logger) + want []string + }{ + { + name: "defaults", + fn: func(log *logrus.Logger) { + log.Info("info") + }, + want: []string{ + "level=INFO msg=info", + }, + }, + { + name: "with fields", + fn: func(log *logrus.Logger) { + log.WithFields(logrus.Fields{ + "chicken": "cluck", + }).Error("error") + }, + want: []string{ + "level=ERROR msg=error chicken=cluck", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + slogLogger := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{ + // Remove timestamps from logs, for easier comparison + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a + }, + })) + log := logrus.New() + log.Out = io.Discard + log.AddHook(NewSlogHook(slogLogger)) + tt.fn(log) + got := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(got) != len(tt.want) { + t.Errorf("Got %d log lines, expected %d", len(got), len(tt.want)) + return + } + for i, line := range got { + if line != tt.want[i] { + t.Errorf("line %d differs from expectation.\n Got: %s\nWant: %s", i, line, tt.want[i]) + } + } + }) + } +} From 486d0ecfdd300e62e756fe5c37a79b5755607e3f Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Thu, 9 Nov 2023 09:59:33 +0100 Subject: [PATCH 2/2] Allow for custom level mapping between logrus and slog --- hooks/slog/slog.go | 16 +++++++++++++--- hooks/slog/slog_test.go | 25 +++++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/hooks/slog/slog.go b/hooks/slog/slog.go index 4c8aac5a..3cc584ab 100644 --- a/hooks/slog/slog.go +++ b/hooks/slog/slog.go @@ -9,9 +9,16 @@ import ( "github.com/sirupsen/logrus" ) +// LevelMapper maps a [github.com/sirupsen/logrus.Level] value to a +// [slog.Leveler] value. To change the default level mapping, for instance +// to allow mapping to custom or dynamic slog levels in your application, set +// [SlogHook.LevelMapper] to your own implementation of this function. +type LevelMapper func(logrus.Level) slog.Leveler + // SlogHook sends logs to slog. type SlogHook struct { - logger *slog.Logger + logger *slog.Logger + LevelMapper LevelMapper } var _ logrus.Hook = (*SlogHook)(nil) @@ -31,7 +38,10 @@ func NewSlogHook(logger *slog.Logger) *SlogHook { } } -func (*SlogHook) toSlogLevel(level logrus.Level) slog.Level { +func (h *SlogHook) toSlogLevel(level logrus.Level) slog.Leveler { + if h.LevelMapper != nil { + return h.LevelMapper(level) + } switch level { case logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel: return slog.LevelError @@ -62,7 +72,7 @@ func (h *SlogHook) Fire(entry *logrus.Entry) error { } h.logger.Log( entry.Context, - h.toSlogLevel(entry.Level), + h.toSlogLevel(entry.Level).Level(), entry.Message, attrs..., ) diff --git a/hooks/slog/slog_test.go b/hooks/slog/slog_test.go index 71219faa..ed56cee6 100644 --- a/hooks/slog/slog_test.go +++ b/hooks/slog/slog_test.go @@ -15,9 +15,10 @@ import ( func TestSlogHook(t *testing.T) { tests := []struct { - name string - fn func(*logrus.Logger) - want []string + name string + mapper LevelMapper + fn func(*logrus.Logger) + want []string }{ { name: "defaults", @@ -39,6 +40,20 @@ func TestSlogHook(t *testing.T) { "level=ERROR msg=error chicken=cluck", }, }, + { + name: "level mapper", + mapper: func(logrus.Level) slog.Leveler { + return slog.LevelInfo + }, + fn: func(log *logrus.Logger) { + log.WithFields(logrus.Fields{ + "chicken": "cluck", + }).Error("error") + }, + want: []string{ + "level=INFO msg=error chicken=cluck", + }, + }, } for _, tt := range tests { @@ -55,7 +70,9 @@ func TestSlogHook(t *testing.T) { })) log := logrus.New() log.Out = io.Discard - log.AddHook(NewSlogHook(slogLogger)) + hook := NewSlogHook(slogLogger) + hook.LevelMapper = tt.mapper + log.AddHook(hook) tt.fn(log) got := strings.Split(strings.TrimSpace(buf.String()), "\n") if len(got) != len(tt.want) {