package logrusbridge
import (
"bytes"
"context"
"log/slog"
"sync"
"testing"
"time"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.bigb.es/auxilia/scribe"
)
// capture is a slog.Handler that keeps the records it is given, so a test can
// assert on the record itself — its time above all — rather than on a
// handler's rendering of it.
type capture struct {
mu sync.Mutex
level slog.Level
records []slog.Record
}
func (c *capture) Enabled(_ context.Context, l slog.Level) bool { return l >= c.level }
func (c *capture) Handle(_ context.Context, r slog.Record) error {
c.mu.Lock()
defer c.mu.Unlock()
c.records = append(c.records, r.Clone())
return nil
}
func (c *capture) WithAttrs([]slog.Attr) slog.Handler { return c }
func (c *capture) WithGroup(string) slog.Handler { return c }
func (c *capture) only(t *testing.T) slog.Record {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
require.Len(t, c.records, 1, "expected exactly one bridged record")
return c.records[0]
}
// attrs flattens a record's attributes into a map for assertions.
func attrs(r slog.Record) map[string]any {
m := make(map[string]any, r.NumAttrs())
r.Attrs(func(a slog.Attr) bool {
m[a.Key] = a.Value.Any()
return true
})
return m
}
// The record that comes out the slog side is the one that went in the logrus
// side: same level, same message, same fields as attributes, and the time the
// record was made rather than the time the bridge got to it.
func TestBridgedRecordKeepsLevelMessageFieldsAndTime(t *testing.T) {
c := &capture{level: slog.LevelDebug}
e := Entry(WithLogger(slog.New(c)))
made := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
e.WithTime(made).
WithFields(logrus.Fields{"repo": "~alice/widgets", "chunks": 3}).
Warn("chunk transfer stalled")
rec := c.only(t)
assert.Equal(t, slog.LevelWarn, rec.Level)
assert.Equal(t, "chunk transfer stalled", rec.Message)
assert.True(t, rec.Time.Equal(made), "record time %s, want %s", rec.Time, made)
got := attrs(rec)
assert.Equal(t, "~alice/widgets", got["repo"])
assert.EqualValues(t, 3, got["chunks"])
assert.Len(t, got, 2, "fields must arrive as attributes and nothing else")
}
// A field is an attribute by the time it reaches the handler, which is the only
// reason a mask can fire on it. This is the property the bridge exists for: a
// credential in a third-party library's log field is masked by the process's
// own rules rather than printed around them.
func TestASensitiveFieldIsMaskedByTheDestinationHandler(t *testing.T) {
var buf bytes.Buffer
dest := slog.New(scribe.NewTintHandler(
scribe.WithWriter(&buf),
scribe.WithLevel(slog.LevelDebug),
scribe.WithNoColor(true),
scribe.WithMaskKeys("token", "authorization"),
))
e := Entry(WithLogger(dest))
e.WithFields(logrus.Fields{
"token": "s3cret-working-token",
"authorization": "Bearer s3cret-jwt",
"repo": "~alice/widgets",
}).Error("upload refused")
out := buf.String()
assert.NotContains(t, out, "s3cret-working-token")
assert.NotContains(t, out, "s3cret-jwt")
assert.Contains(t, out, "***")
assert.Contains(t, out, "~alice/widgets", "an unmasked field must still be readable")
}
func TestLevelMapping(t *testing.T) {
for _, tc := range []struct {
in logrus.Level
want slog.Level
}{
{logrus.TraceLevel, slog.LevelDebug},
{logrus.DebugLevel, slog.LevelDebug},
{logrus.InfoLevel, slog.LevelInfo},
{logrus.WarnLevel, slog.LevelWarn},
{logrus.ErrorLevel, slog.LevelError},
{logrus.FatalLevel, slog.LevelError},
{logrus.PanicLevel, slog.LevelError},
} {
assert.Equal(t, tc.want, Level(tc.in), "logrus %s", tc.in)
}
}
// The logrus side is left wide open so that the slog handler decides what
// survives: raising the service's log level must reveal the bridged library's
// debug records too, from the same setting.
func TestTheSlogHandlerDoesTheFiltering(t *testing.T) {
quiet := &capture{level: slog.LevelWarn}
e := Entry(WithLogger(slog.New(quiet)))
e.Debug("per-chunk noise")
e.Info("also below the bar")
quiet.mu.Lock()
assert.Empty(t, quiet.records, "the destination's level must gate bridged records")
quiet.mu.Unlock()
e.Warn("this one counts")
assert.Equal(t, "this one counts", quiet.only(t).Message)
// And the logrus logger itself must not be the one filtering, or the two
// halves of the process would need two settings.
assert.Equal(t, logrus.TraceLevel, e.Logger.GetLevel())
}
// The hook is the only exit: nothing is formatted and nothing is written the
// logrus way, so the destination handler is the whole of the output.
func TestNothingLeavesThroughLogrus(t *testing.T) {
c := &capture{level: slog.LevelDebug}
e := Entry(WithLogger(slog.New(c)))
var buf bytes.Buffer
e.Logger.SetOutput(&buf) // stand in for io.Discard so we can look at it
e.Info("hello")
assert.Empty(t, buf.String(), "the formatter must produce nothing")
assert.Equal(t, "hello", c.only(t).Message)
}
// logrus fires its hooks before it writes, before Logger.Exit and before the
// panic, so a record that ends the process still reaches slog first. Verified
// here rather than trusted, because it is the arm nobody would notice missing
// until they were reading a crash without its last line.
func TestFatalAndPanicRecordsReachTheHook(t *testing.T) {
t.Run("fatal", func(t *testing.T) {
c := &capture{level: slog.LevelDebug}
e := Entry(WithLogger(slog.New(c)))
exited := false
e.Logger.ExitFunc = func(int) { exited = true }
e.Fatal("cannot serve")
assert.True(t, exited, "logrus must still exit after the hook")
rec := c.only(t)
assert.Equal(t, slog.LevelError, rec.Level)
assert.Equal(t, "cannot serve", rec.Message)
})
t.Run("panic", func(t *testing.T) {
c := &capture{level: slog.LevelDebug}
e := Entry(WithLogger(slog.New(c)))
assert.Panics(t, func() { e.Panic("unrecoverable") })
rec := c.only(t)
assert.Equal(t, slog.LevelError, rec.Level)
assert.Equal(t, "unrecoverable", rec.Message)
})
}
// The default logger is resolved when the record fires and not when the bridge
// is built, so a bridge handed to a library during startup still lands in the
// handler the process installs.
func TestTheDefaultLoggerIsResolvedWhenTheRecordFires(t *testing.T) {
previous := slog.Default()
t.Cleanup(func() { slog.SetDefault(previous) })
e := Entry() // built before the default is installed
c := &capture{level: slog.LevelDebug}
slog.SetDefault(slog.New(c))
e.Info("late binding")
assert.Equal(t, "late binding", c.only(t).Message)
}
// A destination that panics costs a log line and not the request it was logging.
func TestAPanickingDestinationDoesNotEscape(t *testing.T) {
e := Entry(WithLogger(slog.New(panicHandler{})))
assert.NotPanics(t, func() { e.Info("into the void") })
}
type panicHandler struct{}
func (panicHandler) Enabled(context.Context, slog.Level) bool { return true }
func (panicHandler) Handle(context.Context, slog.Record) error {
panic("the handler is broken")
}
func (panicHandler) WithAttrs([]slog.Attr) slog.Handler { return panicHandler{} }
func (panicHandler) WithGroup(string) slog.Handler { return panicHandler{} }