Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Fixes:
Features:

* Basic `slog` hook for bridging Logrus entries to `log/slog`.
* Add `slog.Handler` (`hooks/slog.NewHandler`) for bridging `log/slog` records
into a Logrus logger (levels, fields, groups, context, and time).
* Add minimal, composable logging interfaces for each log level. This enables
consumers to depend on narrower interfaces, making it easier to substitute
or adapt logging implementations.
Expand Down
209 changes: 209 additions & 0 deletions hooks/slog/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package slog

import (
"context"
"log/slog"
"maps"
"slices"
"strings"

"github.com/sirupsen/logrus"
)

// Handler is a [slog.Handler] that writes records to a [logrus.Logger].
//
// It is intended for bridging libraries or application code that log via slog
// into an existing Logrus logger, for example during a gradual migration.
//
// By default, slog levels map to their corresponding Logrus levels. Trace,
// Fatal, and Panic use custom slog levels below Debug and above Error,
// respectively, preserving their relative severity:
//
// - [slog.LevelDebug] - 4 -> [logrus.TraceLevel]
// - [slog.LevelDebug] -> [logrus.DebugLevel]
// - [slog.LevelInfo] -> [logrus.InfoLevel]
// - [slog.LevelWarn] -> [logrus.WarnLevel]
// - [slog.LevelError] -> [logrus.ErrorLevel]
// - [slog.LevelError] + 2 -> [logrus.FatalLevel]
// - [slog.LevelError] + 4 -> [logrus.PanicLevel]
//
// Levels between these boundaries map to the next lower Logrus severity.
// Levels below Debug map to [logrus.TraceLevel], and levels above Panic map
// to [logrus.PanicLevel].
//
// Mapping to [logrus.FatalLevel] or [logrus.PanicLevel] preserves the level
// only; handling a record does not exit or panic.
//
// Example usage:
//
// logger := logrus.New()
// slog.SetDefault(slog.New(NewHandler(logger)))
// slog.Info("hello", "key", "value")
type Handler struct {
logger *logrus.Logger

// LevelMapper maps slog levels to Logrus levels. If nil, the default
// mapping is used. Set it to customize level mapping, for example to map
// custom slog levels to specific Logrus levels.
LevelMapper func(slog.Level) logrus.Level

// fields holds attributes from prior WithAttrs calls, already resolved
// under the group prefix that applied when they were added.
fields logrus.Fields

// groups is the current group name stack for attributes added later.
groups []string
}

var _ slog.Handler = (*Handler)(nil)

// NewHandler creates a [slog.Handler] that writes to the provided
// [logrus.Logger].
//
// The provided logger must not be nil. NewHandler panics if logger is nil.
func NewHandler(logger *logrus.Logger) *Handler {
if logger == nil {
panic("cannot create handler from nil logger")
}
return &Handler{
logger: logger,
}
}

// toLogrusLevel maps a slog level using LevelMapper or the default mapping.
func (h *Handler) toLogrusLevel(level slog.Level) logrus.Level {
if h.LevelMapper != nil {
return h.LevelMapper(level)
}
switch {
case level >= slogLevelPanic:
return logrus.PanicLevel
case level >= slogLevelFatal:
return logrus.FatalLevel
case level >= slogLevelError:
return logrus.ErrorLevel
case level >= slogLevelWarn:
return logrus.WarnLevel
case level >= slogLevelInfo:
return logrus.InfoLevel
case level >= slogLevelDebug:
return logrus.DebugLevel
case level >= slogLevelTrace:
return logrus.TraceLevel
default:
return logrus.TraceLevel
}
}

// Enabled reports whether the handler handles records at the given level.
// It maps the slog level to a logrus level and consults the underlying logger.
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
return h.logger.IsLevelEnabled(h.toLogrusLevel(level))
}

// Handle converts the slog record into a logrus entry and logs it.
// Record time, context, message, and attributes (including those from
// [Handler.WithAttrs]/[Handler.WithGroup]) are preserved. Attributes are
// attached as logrus fields; group names are joined with "." as a key prefix
// (similar to [slog.TextHandler]).
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
level := h.toLogrusLevel(record.Level)
if !h.logger.IsLevelEnabled(level) {
return nil
}

Comment on lines +110 to +114

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the extra processing needed to do the conversion, perhaps it's worth taking advantage of the level here and return early if the log-level is not enabled;

	level := h.toLogrusLevel(record.Level)
	if !h.logger.IsLevelEnabled(level) {
		return nil
	}

entry := &logrus.Entry{
Logger: h.logger,
Data: h.fields,
Time: record.Time,
Context: ctx,
}

if n := record.NumAttrs(); n > 0 {
// Clone before mutating the handler's fields.
entry.Data = maps.Clone(h.fields)
if entry.Data == nil {
entry.Data = make(logrus.Fields, n)
}
record.Attrs(func(a slog.Attr) bool {
appendAttr(entry.Data, h.groups, a)
return true
})
}

entry.Log(level, record.Message)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll probably pay the price of duplicating the entry 2 - 3 times here (each WithXXX creates a copy of the entry, and clones the Field.Data map); as all the fields we need to set on the entry are exported, we can use a struct-literal to create the entry;

   entry := &logrus.Entry{
   	Logger:  h.logger,
   	Data:    fields,
   	Time:    record.Time,
   	Context: ctx,
   }
   entry.Log(level, record.Message)

return nil
}

// WithAttrs returns a new Handler whose attributes consist of h's attributes
// followed by attrs.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := h.clone()
h2.fields = maps.Clone(h.fields)
if h2.fields == nil {
h2.fields = make(logrus.Fields, len(attrs))
}
for _, a := range attrs {
appendAttr(h2.fields, h.groups, a)
}
return h2
}

// WithGroup returns a new Handler with a group appended to the receiver's
// existing groups. All attributes added to the returned handler (via WithAttrs
// or Handle) are nested under the combined group names.
// If name is empty, WithGroup returns h unchanged.
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(slices.Clip(h.groups), name)
return h2
}

// clone returns a shallow copy of h. Callers must clone fields or groups
// before modifying them.
func (h *Handler) clone() *Handler {
return &Handler{
logger: h.logger,
LevelMapper: h.LevelMapper,
fields: h.fields,
groups: h.groups,
}
}

// appendAttr adds attr to fields, resolving it and applying group prefixes.
// Group attributes are flattened with "."-separated keys.
func appendAttr(fields logrus.Fields, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return
}
if attr.Value.Kind() == slog.KindGroup {
gs := attr.Value.Group()
if len(gs) == 0 {
return
}
g := groups
if attr.Key != "" {
g = append(slices.Clip(groups), attr.Key)
}
for _, a := range gs {
appendAttr(fields, g, a)
}
return
}

key := attr.Key
if len(groups) > 0 {
key = strings.Join(groups, ".")
if attr.Key != "" {
key += "." + attr.Key
}
}
fields[key] = attr.Value.Any()
}
Loading