-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Add slog hook #1407
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
base: master
Are you sure you want to change the base?
Add slog hook #1407
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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..., | ||
| ) | ||
|
Comment on lines
+73
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Logger.Log() does not expose handler errors to the caller, and there are some situations where the handler can error (file permissions, disk full...), so I think this is silently discarding errors, even if the logging failed. If you use a slog.Handler in the SlogHook instead of a logger, and build a record here, you can handle those errors here. Please noticed I added an optional errorHandler, that could be configured in the constructor or via functional options.... or simply completely skip it and always print 🤷 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))
}
record := slog.NewRecord(entry.Time, h.toSlogLevel(entry.Level).Level(),
entry.Message, 0)
for _, attr := range attrs {
record.AddAttrs(attr.(slog.Attr))
}
err := h.handler.Handle(entry.Context, record)
if err != nil {
if h.errorHandler != nil {
h.errorHandler(err, entry)
} else {
// print to stderr if no custom error handler is set
fmt.Fprintf(os.Stderr, "slog handler error: %v\n", err)
}
return err
}
return nil
}If this is of your interest, we should also extend the tests, and include a README. Thoughts? |
||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: is it possible to change the levelMapper? Else, I'd keep it private