5 files changed, 7 insertions(+), 480 deletions(-)
M cmd/doltsrht/main.go
M go.mod
M go.sum
D internal/logrusbridge/logrusbridge.go
D internal/logrusbridge/logrusbridge_test.go
M cmd/doltsrht/main.go => cmd/doltsrht/main.go +1 -1
@@ 31,6 31,7 @@ import (
"github.com/vaughan0/go-ini"
"go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/logrusbridge"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-core/config"
@@ 38,7 39,6 @@ import (
"sourcecraft.dev/bigbes/sr-ht-core/server"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
- "sourcecraft.dev/bigbes/sr-ht-dolt/internal/logrusbridge"
"sourcecraft.dev/bigbes/sr-ht-dolt/remoteapi"
"sourcecraft.dev/bigbes/sr-ht-dolt/storage"
"sourcecraft.dev/bigbes/sr-ht-dolt/web"
M go.mod => go.mod +2 -2
@@ 9,10 9,10 @@ require (
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee
github.com/go-chi/chi/v5 v5.3.1
github.com/lib/pq v1.10.9
- github.com/sirupsen/logrus v1.9.3
+ github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
- go.bigb.es/auxilia v0.5.0
+ go.bigb.es/auxilia v0.6.2-0.20260808203729-6d4c5ff0b513
google.golang.org/grpc v1.79.3
gopkg.in/go-jose/go-jose.v2 v2.6.3
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
M go.sum => go.sum +4 -5
@@ 416,8 416,8 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
@@ 469,8 469,8 @@ github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.bigb.es/auxilia v0.5.0 h1:S5+btW6++4CQDOfAEZe1UxrXRl6nxtmu0rI3uIXwCaQ=
-go.bigb.es/auxilia v0.5.0/go.mod h1:hBkJvydQRfmgSTR2U4PvYgJcZnh7Bj/otukrk14iJU4=
+go.bigb.es/auxilia v0.6.2-0.20260808203729-6d4c5ff0b513 h1:XbfQqibynw3gIIrBCPJ19pNz3vdi+b8+mMRfKvpzr0E=
+go.bigb.es/auxilia v0.6.2-0.20260808203729-6d4c5ff0b513/go.mod h1:MexCwc01Pv015P4H4U0MOr+wwCqWqsApmYGO4PrELzs=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
@@ 562,7 562,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
D internal/logrusbridge/logrusbridge.go => internal/logrusbridge/logrusbridge.go +0 -252
@@ 1,252 0,0 @@
-// 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
- }
-}
D internal/logrusbridge/logrusbridge_test.go => internal/logrusbridge/logrusbridge_test.go +0 -220
@@ 1,220 0,0 @@
-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{} }