Repository: go-logr/logr
Pull request #205 (2023-08-07 09:31:34)

slogr: add glue code for logging to slog.Handler and with slog.Logger
Interoperability with log/slog from Go 1.21 includes the ability to use a slog.Handler as backend with logr.Logger as frontend and vice versa.

This is only the initial step. In particular writing with slog.Logger to a logr.LogSink is not working well (time stamp and call site from record get ignored). Further work is needed to improve this.

pohly: Tests cover 100% of the new code lines. I also tried to consider edge cases.


pohly: > I like having runnable examples for quick-testing. Worthing including?

Yes. I merged it with the example for the other direction https://github.com/go-logr/logr/pull/205#discussion_r1294771434 because some parts are common (error) and this way it's easier to see both approaches without having to open two files side-by-side.

Initially, the `slog.Logger` calls recorded the wrong `logger.go` from the slog package as caller. This is inevitable, but at least for the current Go 1.21.0 and simple usage (i.e. slog.Logger directly calls the handler) it can be compensated through `WithCallDepth` calls - I've added that.

> I like your tests, too, and maybe we don't need both?

Both = example and tests? I think we want (example) and need (tests) both.

thockin: Did you forget to add the example?
pohly: > Did you forget to add the example?

Yes :cry: 

Fixed.

Patch:
diff --git a/README.md b/README.md
index 72aefa3..6731d50 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,29 @@ received:
 If the Go standard library had defined an interface for logging, this project
 probably would not be needed.  Alas, here we are.
 
+When the Go developers started developing such an interface with
+[slog](https://github.com/golang/go/issues/56345), they adopted some of the
+logr design but also left out some parts and changed others:
+
+| Feature | logr | slog |
+|---------|------|------|
+| High-level API | `Logger` (passed by value) | `Logger` (passed by [pointer](https://github.com/golang/go/issues/59126)) |
+| Low-level API | `LogSink` | `Handler` |
+| Stack unwinding | done by `LogSink` | done by `Logger` |
+| Skipping helper functions | `WithCallDepth`, `WithCallStackHelper` | [not supported by Logger](https://github.com/golang/go/issues/59145) |
+| Generating a value for logging on demand | `Marshaler` | `LogValuer` |
+| Log levels | >= 0, higher meaning "less important" | positive and negative, with 0 for "info" and higher meaning "more important" |
+| Error log entries | always logged, don't have a verbosity level | normal log entries with level >= `LevelError` |
+| Passing logger via context | `NewContext`, `FromContext` | no API |
+| Adding a name to a logger | `WithName` | no API |
+| Modify verbosity of log entries in a call chain | `V` | no API |
+| Grouping of key/value pairs | not supported | `WithGroup`, `GroupValue` |
+
+The high-level slog API is explicitly meant to be one of many different APIs
+that can be layered on top of a shared `slog.Handler`. logr is one such
+alternative API, with interoperability provided by the [`slogr`](slogr)
+package.
+
 ### Inspiration
 
 Before you consider this package, please read [this blog post by the
@@ -242,7 +265,9 @@ Otherwise, you can start out with `0` as "you always want to see this",
 
 Then gradually choose levels in between as you need them, working your way
 down from 10 (for debug and trace style logs) and up from 1 (for chattier
-info-type logs.)
+info-type logs). For reference, slog pre-defines -4 for debug logs
+(corresponds to 4 in logr), which matches what is
+[recommended for Kubernetes](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#what-method-to-use).
 
 #### How do I choose my keys?
 
diff --git a/logr.go b/logr.go
index 7dd08a6..b6f4049 100644
--- a/logr.go
+++ b/logr.go
@@ -314,6 +314,13 @@ func (l Logger) V(level int) Logger {
 	return l
 }
 
+// GetV returns the verbosity level of the logger. If the logger's LogSink is
+// nil as in the Discard logger, this will always return 0.
+func (l Logger) GetV() int {
+	// 0 if l.sink nil because of the if check in V above.
+	return l.level
+}
+
 // WithValues returns a new Logger instance with additional key/value pairs.
 // See Info for documentation on how key/value pairs work.
 func (l Logger) WithValues(keysAndValues ...any) Logger {
diff --git a/slogr/example/main.go b/slogr/example/main.go
new file mode 100644
index 0000000..c6379d2
--- /dev/null
+++ b/slogr/example/main.go
@@ -0,0 +1,103 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2023 The logr Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package main is an example of using slogr.
+package main
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/go-logr/logr"
+	"github.com/go-logr/logr/funcr"
+	"github.com/go-logr/logr/slogr"
+)
+
+type e struct {
+	str string
+}
+
+func (e e) Error() string {
+	return e.str
+}
+
+func logrHelper(log logr.Logger, msg string) {
+	logrHelper2(log, msg)
+}
+
+func logrHelper2(log logr.Logger, msg string) {
+	log.WithCallDepth(2).Info(msg)
+}
+
+func slogHelper(log *slog.Logger, msg string) {
+	slogHelper2(log, msg)
+}
+
+func slogHelper2(log *slog.Logger, msg string) {
+	// slog.Logger has no API for skipping helper functions, so this gets logged as call location.
+	log.Info(msg)
+}
+
+func main() {
+	opts := slog.HandlerOptions{
+		AddSource: true,
+		Level:     slog.Level(-1),
+	}
+	handler := slog.NewJSONHandler(os.Stderr, &opts)
+	logrLogger := slogr.NewLogr(handler)
+	logrExample(logrLogger)
+
+	logrLogger = funcr.NewJSON(
+		func(obj string) { fmt.Println(obj) },
+		funcr.Options{
+			LogCaller:    funcr.All,
+			LogTimestamp: true,
+			Verbosity:    1,
+		})
+	slogLogger := slog.New(slogr.NewSlogHandler(logrLogger))
+	slogExample(slogLogger)
+}
+
+func logrExample(log logr.Logger) {
+	log = log.WithName("my")
+	log = log.WithName("logger")
+	log = log.WithName("name")
+	log = log.WithValues("saved", "value")
+	log.Info("1) hello", "val1", 1, "val2", map[string]int{"k": 1})
+	log.V(1).Info("2) you should see this")
+	log.V(1).V(1).Info("you should NOT see this")
+	log.Error(nil, "3) uh oh", "trouble", true, "reasons", []float64{0.1, 0.11, 3.14})
+	log.Error(e{"an error occurred"}, "4) goodbye", "code", -1)
+	logrHelper(log, "5) thru a helper")
+}
+
+func slogExample(log *slog.Logger) {
+	// There's no guarantee that this logs the right source code location.
+	// It works for Go 1.21.0 by compensating in slogr.NewSlogHandler
+	// for the additional callers, but those might change.
+	log = log.With("saved", "value")
+	log.Info("1) hello", "val1", 1, "val2", map[string]int{"k": 1})
+	log.Log(context.TODO(), slog.Level(-1), "2) you should see this")
+	log.Log(context.TODO(), slog.Level(-2), "you should NOT see this")
+	log.Error("3) uh oh", "trouble", true, "reasons", []float64{0.1, 0.11, 3.14})
+	log.Error("4) goodbye", "code", -1, "err", e{"an error occurred"})
+	slogHelper(log, "5) thru a helper")
+}
diff --git a/slogr/sloghandler.go b/slogr/sloghandler.go
new file mode 100644
index 0000000..adc5ad9
--- /dev/null
+++ b/slogr/sloghandler.go
@@ -0,0 +1,123 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2023 The logr Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package slogr
+
+import (
+	"context"
+	"log/slog"
+
+	"github.com/go-logr/logr"
+)
+
+type slogHandler struct {
+	sink logr.LogSink
+
+	// groupPrefix collects values from WithGroup calls. It gets added as
+	// prefix to value keys when handling a log record.
+	groupPrefix string
+
+	// levelBias can be set when constructing the handler to influence the
+	// slog.Level of log records. A positive levelBias reduces the
+	// slog.Level value. slog has no API to influence this value after the
+	// handler got created, so it can only be set indirectly through
+	// Logger.V.
+	levelBias slog.Level
+}
+
+var _ slog.Handler = &slogHandler{}
+
+// groupSeparator is used to concatenate WithGroup names and attribute keys.
+const groupSeparator = "."
+
+func (l *slogHandler) Enabled(ctx context.Context, level slog.Level) bool {
+	return l.sink != nil && (level >= slog.LevelError || l.sink.Enabled(l.levelFromSlog(level)))
+}
+
+func (l *slogHandler) Handle(ctx context.Context, record slog.Record) error {
+	// No need to check for nil sink here because Handle will only be called
+	// when Enabled returned true.
+
+	kvList := make([]any, 0, 2*record.NumAttrs())
+	record.Attrs(func(attr slog.Attr) bool {
+		if attr.Key != "" {
+			kvList = append(kvList, l.addGroupPrefix(attr.Key), attr.Value.Resolve().Any())
+		}
+		return true
+	})
+	if record.Level >= slog.LevelError {
+		l.sink.Error(nil, record.Message, kvList...)
+	} else {
+		level := l.levelFromSlog(record.Level)
+		l.sink.Info(level, record.Message, kvList...)
+	}
+	return nil
+}
+
+func (l *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	if l.sink == nil || len(attrs) == 0 {
+		return l
+	}
+	kvList := make([]any, 0, 2*len(attrs))
+	for _, attr := range attrs {
+		if attr.Key != "" {
+			kvList = append(kvList, l.addGroupPrefix(attr.Key), attr.Value.Resolve().Any())
+		}
+	}
+	copy := *l
+	copy.sink = l.sink.WithValues(kvList...)
+	return &copy
+}
+
+func (l *slogHandler) WithGroup(name string) slog.Handler {
+	if l.sink == nil {
+		return l
+	}
+	copy := *l
+	copy.groupPrefix = copy.addGroupPrefix(name)
+	return &copy
+}
+
+func (l *slogHandler) addGroupPrefix(name string) string {
+	if l.groupPrefix == "" {
+		return name
+	}
+	return l.groupPrefix + groupSeparator + name
+}
+
+// levelFromSlog adjusts the level by the logger's verbosity and negates it.
+// It ensures that the result is >= 0. This is necessary because the result is
+// passed to a logr.LogSink and that API did not historically document whether
+// levels could be negative or what that meant.
+//
+// Some example usage:
+//     logrV0 := getMyLogger()
+//     logrV2 := logrV0.V(2)
+//     slogV2 := slog.New(slogr.NewSlogHandler(logrV2))
+//     slogV2.Debug("msg") // =~ logrV2.V(4) =~ logrV0.V(6)
+//     slogV2.Info("msg")  // =~  logrV2.V(0) =~ logrV0.V(2)
+//     slogv2.Warn("msg")  // =~ logrV2.V(-4) =~ logrV0.V(0)
+func (l *slogHandler) levelFromSlog(level slog.Level) int {
+	result := -level
+	result += l.levelBias // in case the original logr.Logger had a V level
+	if result < 0 {
+		result = 0 // because logr.LogSink doesn't expect negative V levels
+	}
+	return int(result)
+}
diff --git a/slogr/slogr.go b/slogr/slogr.go
new file mode 100644
index 0000000..2f835cd
--- /dev/null
+++ b/slogr/slogr.go
@@ -0,0 +1,66 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2023 The logr Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package slogr enables usage of a slog.Handler with logr.Logger as front-end
+// API and of a logr.LogSink through the slog.Handler and thus slog.Logger
+// APIs.
+//
+// Both approaches are currently experimental and need further work.
+package slogr
+
+import (
+	"log/slog"
+
+	"github.com/go-logr/logr"
+)
+
+// NewLogr returns a logr.Logger which writes to the slog.Handler.
+//
+// The logr verbosity level is mapped to slog levels such that V(0) becomes
+// slog.LevelInfo and V(4) becomes slog.LevelDebug.
+func NewLogr(handler slog.Handler) logr.Logger {
+	return logr.New(&slogSink{handler: handler})
+}
+
+// NewSlogHandler returns a slog.Handler which writes to the same sink as the logr.Logger.
+//
+// The returned logger writes all records with level >= slog.LevelError as
+// error log entries with LogSink.Error, regardless of the verbosity level of
+// the logr.Logger:
+//
+//	logger := <some logr.Logger with 0 as verbosity level>
+//	slog.New(NewSlogHandler(logger.V(10))).Error(...) -> logSink.Error(...)
+//
+// The level of all other records gets reduced by the verbosity
+// level of the logr.Logger and the result is negated. If it happens
+// to be negative, then it gets replaced by zero because a LogSink
+// is not expected to handled negative levels:
+//
+//	slog.New(NewSlogHandler(logger)).Debug(...) -> logger.GetSink().Info(level=4, ...)
+//	slog.New(NewSlogHandler(logger)).Warning(...) -> logger.GetSink().Info(level=0, ...)
+//	slog.New(NewSlogHandler(logger)).Info(...) -> logger.GetSink().Info(level=0, ...)
+//	slog.New(NewSlogHandler(logger.V(4))).Info(...) -> logger.GetSink().Info(level=4, ...)
+func NewSlogHandler(logger logr.Logger) slog.Handler {
+	// This offset currently (Go 1.21.0) works for slog.New(NewSlogHandler(...)).Info.
+	// There's no guarantee that the call chain won't change and wrapping
+	// the handler will also break unwinding, but it's still better than not
+	// adjusting at all.
+	logger = logger.WithCallDepth(2)
+	return &slogHandler{sink: logger.GetSink(), levelBias: slog.Level(logger.GetV())}
+}
diff --git a/slogr/slogsink.go b/slogr/slogsink.go
new file mode 100644
index 0000000..6fbac56
--- /dev/null
+++ b/slogr/slogsink.go
@@ -0,0 +1,122 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2023 The logr Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package slogr
+
+import (
+	"context"
+	"log/slog"
+	"runtime"
+	"time"
+
+	"github.com/go-logr/logr"
+)
+
+var (
+	_ logr.LogSink          = &slogSink{}
+	_ logr.CallDepthLogSink = &slogSink{}
+	_ Underlier             = &slogSink{}
+)
+
+// Underlier is implemented by the LogSink returned by NewLogr.
+type Underlier interface {
+	// GetUnderlying returns the Handler used by the LogSink.
+	GetUnderlying() slog.Handler
+}
+
+const (
+	// nameKey is used to log the `WithName` values as an additional attribute.
+	nameKey = "logger"
+
+	// errKey is used to log the error parameter of Error as an additional attribute.
+	errKey = "err"
+)
+
+type slogSink struct {
+	callDepth int
+	name      string
+	handler   slog.Handler
+}
+
+func (l *slogSink) Init(info logr.RuntimeInfo) {
+	l.callDepth = info.CallDepth
+}
+
+func (l *slogSink) GetUnderlying() slog.Handler {
+	return l.handler
+}
+
+func (l *slogSink) WithCallDepth(depth int) logr.LogSink {
+	newLogger := *l
+	newLogger.callDepth += depth
+	return &newLogger
+}
+
+func (l *slogSink) Enabled(level int) bool {
+	return l.handler.Enabled(context.Background(), slog.Level(-level))
+}
+
+func (l *slogSink) Info(level int, msg string, kvList ...interface{}) {
+	l.log(nil, msg, slog.Level(-level), kvList...)
+}
+
+func (l *slogSink) Error(err error, msg string, kvList ...interface{}) {
+	l.log(err, msg, slog.LevelError, kvList...)
+}
+
+func (l *slogSink) log(err error, msg string, level slog.Level, kvList ...interface{}) {
+	var pcs [1]uintptr
+	// skip runtime.Callers, this function, Info/Error, and all helper functions above that.
+	runtime.Callers(3+l.callDepth, pcs[:])
+
+	record := slog.NewRecord(time.Now(), level, msg, pcs[0])
+	if l.name != "" {
+		record.AddAttrs(slog.String(nameKey, l.name))
+	}
+	if err != nil {
+		record.AddAttrs(slog.Any(errKey, err))
+	}
+	record.Add(kvList...)
+	l.handler.Handle(context.Background(), record)
+}
+
+func (l slogSink) WithName(name string) logr.LogSink {
+	if l.name != "" {
+		l.name = l.name + "/"
+	}
+	l.name += name
+	return &l
+}
+
+func (l slogSink) WithValues(kvList ...interface{}) logr.LogSink {
+	l.handler = l.handler.WithAttrs(kvListToAttrs(kvList...))
+	return &l
+}
+
+func kvListToAttrs(kvList ...interface{}) []slog.Attr {
+	// We don't need the record itself, only its Add method.
+	record := slog.NewRecord(time.Time{}, 0, "", 0)
+	record.Add(kvList...)
+	attrs := make([]slog.Attr, 0, record.NumAttrs())
+	record.Attrs(func(attr slog.Attr) bool {
+		attrs = append(attrs, attr)
+		return true
+	})
+	return attrs
+}


Test patch:
diff --git a/logr_test.go b/logr_test.go
index 5613ae6..af5b2be 100644
--- a/logr_test.go
+++ b/logr_test.go
@@ -172,23 +172,26 @@ func TestError(t *testing.T) {
 }
 
 func TestV(t *testing.T) {
-	sink := &testLogSink{}
-	logger := New(sink)
-
-	if l := logger.V(0); l.level != 0 {
-		t.Errorf("expected level 0, got %d", l.level)
-	}
-	if l := logger.V(93); l.level != 93 {
-		t.Errorf("expected level 93, got %d", l.level)
-	}
-	if l := logger.V(70).V(6); l.level != 76 {
-		t.Errorf("expected level 76, got %d", l.level)
-	}
-	if l := logger.V(-1); l.level != 0 {
-		t.Errorf("expected level 0, got %d", l.level)
-	}
-	if l := logger.V(1).V(-1); l.level != 1 {
-		t.Errorf("expected level 1, got %d", l.level)
+	for name, logger := range map[string]Logger{
+		"testLogSink": New(&testLogSink{}),
+		"Discard":     Discard(),
+		"Zero":        {},
+	} {
+		t.Run(name, func(t *testing.T) {
+			adjust := func(level int) int {
+				if logger.GetSink() == nil {
+					// The Discard and the zero Logger short-cut the V call and don't
+					// change the verbosity level.
+					return 0
+				}
+				return level
+			}
+			expectEqual(t, "V(0)", 0, logger.V(0).GetV())
+			expectEqual(t, "V(93)", adjust(93), logger.V(93).GetV())
+			expectEqual(t, "V(70).V(6)", adjust(76), logger.V(70).V(6).GetV())
+			expectEqual(t, "V(-1)", 0, logger.V(-1).GetV())
+			expectEqual(t, "V(1).V(-1)", adjust(1), logger.V(1).V(-1).GetV())
+		})
 	}
 }
 
@@ -439,3 +442,12 @@ func TestZeroValue(t *testing.T) {
 	l2.Error(errors.New("bar"), "some error")
 	_, _ = l.WithCallStackHelper()
 }
+
+func expectEqual[T comparable](tb testing.TB, msg string, expected, actual T) bool {
+	if expected == actual {
+		return true
+	}
+	tb.Helper()
+	tb.Errorf("Failure: %s: expected to get %v, got %v instead", msg, expected, actual)
+	return false
+}
diff --git a/slogr/slogr_test.go b/slogr/slogr_test.go
new file mode 100644
index 0000000..bc66468
--- /dev/null
+++ b/slogr/slogr_test.go
@@ -0,0 +1,186 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2023 The logr Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package slogr_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"path"
+	"runtime"
+	"strings"
+	"testing"
+	"testing/slogtest"
+
+	"github.com/go-logr/logr"
+	"github.com/go-logr/logr/funcr"
+	"github.com/go-logr/logr/slogr"
+)
+
+var debugWithoutTime = &slog.HandlerOptions{
+	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
+		if a.Key == "time" {
+			return slog.Attr{}
+		}
+		return a
+	},
+	Level: slog.LevelDebug,
+}
+
+func ExampleNew() {
+	logger := slogr.NewLogr(slog.NewTextHandler(os.Stdout, debugWithoutTime))
+
+	logger.Info("hello world")
+	logger.Error(errors.New("fake error"), "ignore me")
+	logger.WithValues("x", 1, "y", 2).WithValues("str", "abc").WithName("foo").WithName("bar").V(4).Info("with values, verbosity and name")
+
+	// Output:
+	// level=INFO msg="hello world"
+	// level=ERROR msg="ignore me" err="fake error"
+	// level=DEBUG msg="with values, verbosity and name" x=1 y=2 str=abc logger=foo/bar
+}
+
+func ExampleNewSlogLogger() {
+	funcrLogger := funcr.New(func(prefix, args string) {
+		if prefix != "" {
+			fmt.Fprintln(os.Stdout, prefix, args)
+		} else {
+			fmt.Fprintln(os.Stdout, args)
+		}
+	}, funcr.Options{
+		Verbosity: 10,
+	})
+
+	logger := slog.New(slogr.NewSlogHandler(funcrLogger))
+	logger.Info("hello world")
+	logger.Error("ignore me", "err", errors.New("fake error"))
+	logger.With("x", 1, "y", 2).WithGroup("group").With("str", "abc").Warn("with values and group")
+
+	logger = slog.New(slogr.NewSlogHandler(funcrLogger.V(int(-slog.LevelDebug))))
+	logger.Info("info message reduced to debug level")
+
+	// Output:
+	// "level"=0 "msg"="hello world"
+	// "msg"="ignore me" "error"=null "err"="fake error"
+	// "level"=0 "msg"="with values and group" "x"=1 "y"=2 "group.str"="abc"
+	// "level"=4 "msg"="info message reduced to debug level"
+}
+
+func TestWithCallDepth(t *testing.T) {
+	debugWithCaller := *debugWithoutTime
+	debugWithCaller.AddSource = true
+	var buffer bytes.Buffer
+	logger := slogr.NewLogr(slog.NewTextHandler(&buffer, &debugWithCaller))
+
+	logHelper(logger)
+	_, file, line, _ := runtime.Caller(0)
+	expectedSource := fmt.Sprintf("%s:%d", path.Base(file), line-1)
+	actual := buffer.String()
+	if !strings.Contains(actual, expectedSource) {
+		t.Errorf("expected log entry with %s as caller source code location, got instead:\n%s", expectedSource, actual)
+	}
+}
+
+func logHelper(logger logr.Logger) {
+	logger.WithCallDepth(1).Info("hello")
+}
+
+func TestSlogHandler(t *testing.T) {
+	var buffer bytes.Buffer
+	funcrLogger := funcr.NewJSON(func(obj string) {
+		fmt.Fprintln(&buffer, obj)
+	}, funcr.Options{
+		LogTimestamp: true,
+		Verbosity:    10,
+		RenderBuiltinsHook: func(kvList []any) []any {
+			mappedKVList := make([]any, len(kvList))
+			for i := 0; i < len(kvList); i += 2 {
+				key := kvList[i]
+				switch key {
+				case "ts":
+					mappedKVList[i] = "time"
+				default:
+					mappedKVList[i] = key
+				}
+				mappedKVList[i+1] = kvList[i+1]
+			}
+			return mappedKVList
+		},
+	})
+	handler := slogr.NewSlogHandler(funcrLogger)
+
+	err := slogtest.TestHandler(handler, func() []map[string]any {
+		var ms []map[string]any
+		for _, line := range bytes.Split(buffer.Bytes(), []byte{'\n'}) {
+			if len(line) == 0 {
+				continue
+			}
+			var m map[string]any
+			if err := json.Unmarshal(line, &m); err != nil {
+				t.Fatal(err)
+			}
+			ms = append(ms, m)
+		}
+		return ms
+	})
+
+	// Correlating failures with individual test cases is hard with the current API.
+	// See https://github.com/golang/go/issues/61758
+	t.Logf("Output:\n%s", buffer.String())
+	if err != nil {
+		if err, ok := err.(interface {
+			Unwrap() []error
+		}); ok {
+			for _, err := range err.Unwrap() {
+				if !containsOne(err.Error(),
+					"a Handler should ignore a zero Record.Time",                     // Time is generated by sink.
+					"a Handler should handle Group attributes",                       // funcr doesn't.
+					"a Handler should inline the Attrs of a group with an empty key", // funcr doesn't know about groups.
+					"a Handler should not output groups for an empty Record",         // Relies on WithGroup. Text may change, see https://go.dev/cl/516155
+					"a Handler should handle the WithGroup method",                   // logHandler does by prefixing keys, which is not what the test expects.
+					"a Handler should handle multiple WithGroup and WithAttr calls",  // Same.
+					"a Handler should call Resolve on attribute values in groups",    // funcr doesn't do that and slogHandler can't do it for it.
+				) {
+					t.Errorf("Unexpected error: %v", err)
+				}
+			}
+		} else {
+			// Shouldn't be reached, errors from errors.Join can be split up.
+			t.Errorf("Unexpected errors:\n%v", err)
+		}
+	}
+}
+
+func containsOne(hay string, needles ...string) bool {
+	for _, needle := range needles {
+		if strings.Contains(hay, needle) {
+			return true
+		}
+	}
+	return false
+}
+
+func TestDiscard(t *testing.T) {
+	logger := slog.New(slogr.NewSlogHandler(logr.Discard()))
+	logger.WithGroup("foo").With("x", 1).Info("hello")
+}