// Package logrusbridge routes a logrus logger's records into log/slog.
//
// It exists for a shape of dependency that keeps recurring and has no good
// answer at the call site: a third-party library logs, and the only way it will
// accept a logger is as a *logrus.Entry. dolt's remotesrv is this service's
// example — its ServerArgs.Logger field is typed *logrus.Entry and nothing
// else — but the shape is not dolt's and not SourceHut's, which is why this is
// a general adapter rather than a line in the daemon's wiring.
//
// The reason to bridge rather than to leave the library logging on its own is
// not tidiness of format. A process that has configured one handler — a level,
// a destination, and above all a set of masks over the fields that may carry a
// credential — has configured it for the records it emits itself. Every record
// the third-party library writes goes around all of that: its own format, its
// own stream, its own idea of what is worth printing, and no mask between a
// field named "token" and the operator's journal. In a library that serves
// remote requests, the paths most likely to put a credential in a log line are
// exactly the ones this process did not write. Routing them through the same
// handler is what makes a masking rule a property of the process instead of a
// property of the code that remembered to use it.
//
// The bridge is a logrus.Hook, which is the seam logrus ships for precisely
// this, plus two settings that make the hook the *only* exit: the output goes
// to io.Discard and the formatter produces nothing, so no record is formatted
// on its way to being thrown away.
//
// The logrus logger is left at its most permissive level and the slog handler
// does the filtering. That is deliberate: it puts both halves of the process's
// output under one level, so raising the service's log level to debug reveals
// the bridged library's debug records too, from the same config key, instead of
// requiring a second knob nobody remembers exists.
//
// # Candidate for auxilia
//
// This belongs beside scribe in go.bigb.es/auxilia rather than in a service: it
// depends on nothing but logrus and the standard library, and any Go program
// with a logrus-shaped dependency wants it. It is here because it was needed
// here first. It does not belong in sr-ht-ecore — that is a SourceHut library,
// and this has nothing to do with SourceHut.
package logrusbridge
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"slices"
"sync"
"github.com/sirupsen/logrus"
)
// An Option configures Entry and Hook.
type Option func(*config)
type config struct {
target *slog.Logger
}
// WithLogger sends the bridged records to l instead of to slog's default
// logger.
//
// The default is resolved at the moment a record is fired, not when the bridge
// is built, so a bridge constructed before slog.SetDefault still lands in the
// handler the process ends up with. Pass this only when the destination is not
// the process default — a test capturing records is the usual reason.
func WithLogger(l *slog.Logger) Option {
return func(c *config) { c.target = l }
}
// Hook returns a logrus.Hook that forwards every record it is given to slog.
//
// Use it when the caller already holds a *logrus.Logger it wants redirected.
// Adding the hook does not stop that logger writing through its own formatter
// and output as well; silencing those is the caller's to do, and Entry is the
// version that has done it.
func Hook(opts ...Option) logrus.Hook {
var cfg config
for _, opt := range opts {
opt(&cfg)
}
return &hook{cfg: cfg}
}
// Entry returns a *logrus.Entry whose every record goes to slog and nowhere
// else — the value to hand to a library that will accept nothing but one.
//
// The logger behind it is fresh rather than logrus' standard one: redirecting
// the standard logger would capture every other user of it in the process,
// which is a decision for that process and not for the library being handed
// this entry.
func Entry(opts ...Option) *logrus.Entry {
l := logrus.New()
// The two halves of "the hook is the only exit". Discarding the output
// alone would still pay for formatting every record on its way to nowhere,
// and the formatter is the expensive half.
l.SetOutput(io.Discard)
l.SetFormatter(noopFormatter{})
// Everything reaches the hook; the slog handler decides what survives.
l.SetLevel(logrus.TraceLevel)
// Off, and worth saying why: logrus computes the caller by walking the
// stack on every record, and a library handed this entry may log per
// request. See hook.Fire for what happens if a caller turns it on anyway.
l.SetReportCaller(false)
l.AddHook(Hook(opts...))
return logrus.NewEntry(l)
}
// noopFormatter formats a record into nothing. A nil slice is a valid return:
// logrus writes it to the output, and io.Discard accepts it.
type noopFormatter struct{}
func (noopFormatter) Format(*logrus.Entry) ([]byte, error) { return nil, nil }
type hook struct {
cfg config
// reported bounds the complaint about a panicking destination handler to
// one line for the life of the process.
reported sync.Once
}
// Levels asks for every record. The filtering belongs to the slog handler, for
// the reason given in the package comment.
func (h *hook) Levels() []logrus.Level { return logrus.AllLevels }
// Fire hands one logrus record to slog.
//
// It always returns nil. A non-nil error makes logrus print "Failed to fire
// hook" to os.Stderr, which is the one destination this bridge exists to keep
// records away from, and there is nothing a logging call can usefully do about
// its own failure anyway.
//
// It also never panics. This runs inside the logging path of a library that
// may be serving a request, so a panicking destination handler would otherwise
// take the request with it; losing a log line is the better of the two, and the
// once-only notice on stderr keeps a permanently broken handler from being
// silent forever.
func (h *hook) Fire(e *logrus.Entry) (err error) {
defer func() {
if r := recover(); r != nil {
h.reported.Do(func() {
fmt.Fprintf(os.Stderr,
"logrusbridge: the destination slog handler panicked, dropping bridged records: %v\n", r)
})
}
err = nil
}()
logger := h.cfg.target
if logger == nil {
logger = slog.Default()
}
ctx := e.Context
if ctx == nil {
ctx = context.Background()
}
level := Level(e.Level)
if !logger.Enabled(ctx, level) {
// Checked before the record is built rather than left to the handler:
// the logrus side is wide open, so a library's per-chunk debug line
// reaches here on every chunk and must cost an interface call, not an
// allocation and a sort.
return nil
}
// The record's time is the entry's, which logrus stamps before it fires
// hooks. Taking time.Now() here would date every bridged record to when the
// bridge got round to it.
//
// The PC is zero, so a handler with source locations enabled prints none
// for a bridged record. That is the honest answer: the only PC this
// function could name is its own, inside logrus, which tells the reader
// nothing about where the line came from. When the caller has enabled
// ReportCaller the real frame is attached below as attributes instead — a
// runtime.Frame's PC is not the return address slog.Record expects, and a
// source location that is quietly one line off is worse than none.
rec := slog.NewRecord(e.Time, level, e.Message, 0)
// Fields become attributes rather than a formatted blob. That is the whole
// point of routing through a structured handler: a mask keyed on the
// attribute path can only fire if the field is still an attribute when it
// gets there.
//
// Sorted, because a logrus.Fields is a map and its iteration order is
// random: unsorted, one library's records would shuffle their columns from
// line to line.
if len(e.Data) > 0 {
keys := make([]string, 0, len(e.Data))
for k := range e.Data {
keys = append(keys, k)
}
slices.Sort(keys)
attrs := make([]slog.Attr, 0, len(keys))
for _, k := range keys {
attrs = append(attrs, slog.Any(k, e.Data[k]))
}
rec.AddAttrs(attrs...)
}
if e.Caller != nil {
rec.AddAttrs(slog.Group("source",
slog.String("file", e.Caller.File),
slog.Int("line", e.Caller.Line),
slog.String("function", e.Caller.Function),
))
}
// Handed to the handler rather than to logger.Log: the Logger methods build
// their own record, stamping it with the current time and with a PC walked
// out of this function's stack — the two things this bridge is carrying
// from somewhere else. Attributes and groups the caller put on the logger
// are on the handler it returns, so nothing is lost by going round it.
//
//nolint:errcheck // Handle's error has no reader; see the doc comment.
_ = logger.Handler().Handle(ctx, rec)
return nil
}
// Level maps a logrus level onto the slog level that means the same thing.
//
// logrus has three levels above Error and slog has none: Fatal and Panic
// describe what logrus does *after* the record — exit, or panic — rather than
// how bad the record is, and both arrive here as errors because that is what
// they are. Trace and Debug both land on Debug for the same reason in reverse:
// slog draws no line there, and inventing one with a negative custom level
// would make "debug" in a config file mean different things on the two sides of
// the bridge.
//
// Both are delivered. logrus fires its hooks before it writes, before
// Logger.Exit and before the panic (verified against logrus v1.9.3,
// Entry.log), so a record that ends the process still reaches slog first.
func Level(l logrus.Level) slog.Level {
switch l {
case logrus.TraceLevel, logrus.DebugLevel:
return slog.LevelDebug
case logrus.InfoLevel:
return slog.LevelInfo
case logrus.WarnLevel:
return slog.LevelWarn
case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel:
return slog.LevelError
default:
// logrus defines no other level. An unknown one is treated as the most
// serious rather than the least, so a level added upstream shows up
// instead of disappearing.
return slog.LevelError
}
}