Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add slog hook #1407

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
80 changes: 80 additions & 0 deletions hooks/slog/slog.go
@@ -0,0 +1,80 @@
//go:build go1.21
// +build go1.21

package slog

import (
"log/slog"

"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
LevelMapper LevelMapper
}

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 (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
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).Level(),
entry.Message,
attrs...,
)
return nil
}
89 changes: 89 additions & 0 deletions hooks/slog/slog_test.go
@@ -0,0 +1,89 @@
//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
mapper LevelMapper
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",
},
},
{
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 {
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
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) {
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])
}
}
})
}
}