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{} }