Repository: go-logr/logr
Pull request #241 (2023-12-01 23:49:28)

Add SlogSink support to funcr
See commits for comments.  This unearthed at least 3 bugs.

funcr now passes all but one of the slog tests (that being the timestamp one).

thockin: Yeah, I don't expect you to dig into funcr too deeply.  Mostly I was interested in your take on the changes to `TestFuncrHandler()` - specifically that the exceptions list is now reduced to just 1.

It feels like that SHOULD be in the funcr subdir, but we also use `testSlog` to test slogSink ?
thockin: As I stare at slogr_test, I am a little confused - `testSlogSink` seems like a copy of `slogSink` - why?
thockin: I pushed another commit - please look at the "Get rid of testSlogSink" and tell me if it is wrong?
thockin: I pushed on more commit, moving slogtests to a helper and funcr tests to funcr.
thockin: What would you say to having an internal trivial implementation which is basically discard but not nil?  We could have one which supports SlogSink and one which does not. 
thockin: Are we generating a coverage report for PRs? 
pohly: > What would you say to having an internal trivial implementation which is basically discard but not nil?

For `slogtest.TestHandler`, that test sink would have to produce valid JSON. I can suggest a solution tomorrow.

> Are we generating a coverage report for PRs?

No. I was testing locally because I wanted to understand whether moving the testing around had an effect. There's an effect of the PR, it's just because for another reason.
thockin: I am cooking something wrt test impls and coverage.
thockin: I have an extra commit in flight, but opinion wanted.

I can make `slogHandler` do groups properly, but it would store them itself and bypass calling `sink.WithValues()` .  It would come close to passing slog tests, though.  Worthwhile?  Or just leave it with things like `a.b=<value>` ?
thockin: OK, I added a big commit.  It makes sloghandler pass almost all of the slog tests wrt `WithGroup` and `WithAttrs`, at the cost of bypassing the LogSink.WithValues.  It's not yet safe - maps get copied and then mutated nor have I really looked at performance.  It needs maps in order to get groups right, and that makes me sad (expensive, loses ordering).

I could abandon the maps part and just go back to dot-prefixes (simpler, won't pass as many slog tests but maybe OK?).  Opinion wanted.
pohly: > I can make slogHandler do groups properly, but it would store them itself and bypass calling sink.WithValues() . It would come close to passing slog tests, though. Worthwhile? Or just leave it with things like a.b=<value> ?

I prefer to keep `slogHandler` as simple as possible, i.e. use `a.b=<value>` for those sinks which don't implement `SlogSink`. There are other things that sinks should support if they care about being used with slog, like special slog values, so eventually either a sink supports slog and `SlogSink` or it should be replaced with something else. Once we are at that point, the additional complexity in `slogHandler` won't be needed.

thockin: After having done it, I think I agree.  Though the testing is satisfying :)
thockin: One last thought: as it is, with prefixes, someone who calls:

```
slogger.Info("msg", slog.Group("g", "k", "v"))
```

gets a different result than:

```
slogger.WithGroup("g", Info("msg", "k", "v"))
```

It's `{"g": { "k": "v"}}` in the first and `{"g.k": "v"}` in the second.
thockin: I pushed now with a much simpler change to sloghandler, coverage is restored, tests are cleaner (IMO).  PTAL.
thockin: pushed with small changes, will look at splitting sloghandler to a new PR or beefing up comments.

Patch:
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index 80b51b5..1a4b7bd 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -11,6 +11,11 @@ jobs:
     steps:
       - name: Checkout code
         uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      - name: Update Go
+        uses: actions/setup-go@v4
+        with:
+          go-version: '>=1.21.0'
+          cache: false
       - name: Lint
         uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc # v3.7.0
         with:
diff --git a/funcr/example/main.go b/funcr/example/main.go
index 8f8bbd8..a86ecd9 100644
--- a/funcr/example/main.go
+++ b/funcr/example/main.go
@@ -41,17 +41,21 @@ func helper2(log logr.Logger, msg string) {
 }
 
 func main() {
-	log := funcr.New(
-		func(pfx, args string) { fmt.Println(pfx, args) },
+	// logr
+	log := funcr.NewJSON(
+		func(arg string) { fmt.Println(arg) },
 		funcr.Options{
 			LogCaller:    funcr.All,
 			LogTimestamp: true,
 			Verbosity:    1,
 		})
-	example(log.WithValues("module", "example"))
+	logrExample(log.WithName("logr").WithValues("mode", "funcr"))
+
+	// slog (if possible)
+	doSlog(log)
 }
 
-func example(log logr.Logger) {
+func logrExample(log logr.Logger) {
 	log.Info("hello", "val1", 1, "val2", map[string]int{"k": 1})
 	log.V(1).Info("you should see this")
 	log.V(1).V(1).Info("you should NOT see this")
diff --git a/funcr/example/main_slog.go b/funcr/example/main_slog.go
new file mode 100644
index 0000000..90f0419
--- /dev/null
+++ b/funcr/example/main_slog.go
@@ -0,0 +1,42 @@
+//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 funcr.
+package main
+
+import (
+	"log/slog"
+
+	"github.com/go-logr/logr"
+)
+
+func doSlog(log logr.Logger) {
+	slogger := slog.New(logr.ToSlogHandler(log.WithName("slog").WithValues("mode", "slog")))
+	slogExample(slogger)
+}
+
+func slogExample(log *slog.Logger) {
+	log.Warn("hello", "val1", 1, "val2", map[string]int{"k": 1})
+	log.Info("you should see this")
+	log.Debug("you should NOT see this")
+	log.Error("uh oh", "trouble", true, "reasons", []float64{0.1, 0.11, 3.14})
+	log.With("attr1", 1, "attr2", 2).Info("with attrs")
+	log.WithGroup("groupname").Info("with group", "slog2", false)
+	log.WithGroup("group1").With("attr1", 1).WithGroup("group2").With("attr2", 2).Info("msg", "arg", "val")
+}
diff --git a/funcr/funcr.go b/funcr/funcr.go
index 12e5807..668d37a 100644
--- a/funcr/funcr.go
+++ b/funcr/funcr.go
@@ -227,12 +227,15 @@ func newFormatter(opts Options, outfmt outputFormat) Formatter {
 // implementation. It should be constructed with NewFormatter. Some of
 // its methods directly implement logr.LogSink.
 type Formatter struct {
-	outputFormat outputFormat
-	prefix       string
-	values       []any
-	valuesStr    string
-	depth        int
-	opts         *Options
+	outputFormat    outputFormat
+	prefix          string
+	values          []any
+	valuesStr       string
+	parentValuesStr string
+	depth           int
+	opts            *Options
+	group           string // for slog groups
+	groupDepth      int
 }
 
 // outputFormat indicates which outputFormat to use.
@@ -253,33 +256,62 @@ func (f Formatter) render(builtins, args []any) string {
 	// Empirically bytes.Buffer is faster than strings.Builder for this.
 	buf := bytes.NewBuffer(make([]byte, 0, 1024))
 	if f.outputFormat == outputJSON {
-		buf.WriteByte('{')
+		buf.WriteByte('{') // for the whole line
 	}
+
 	vals := builtins
 	if hook := f.opts.RenderBuiltinsHook; hook != nil {
 		vals = hook(f.sanitize(vals))
 	}
 	f.flatten(buf, vals, false, false) // keys are ours, no need to escape
 	continuing := len(builtins) > 0
-	if len(f.valuesStr) > 0 {
+
+	if f.parentValuesStr != "" {
 		if continuing {
-			if f.outputFormat == outputJSON {
-				buf.WriteByte(',')
-			} else {
-				buf.WriteByte(' ')
-			}
+			buf.WriteByte(f.comma())
 		}
+		buf.WriteString(f.parentValuesStr)
 		continuing = true
+	}
+
+	groupDepth := f.groupDepth
+	if f.group != "" {
+		if f.valuesStr != "" || len(args) != 0 {
+			if continuing {
+				buf.WriteByte(f.comma())
+			}
+			buf.WriteString(f.quoted(f.group, true)) // escape user-provided keys
+			buf.WriteByte(f.colon())
+			buf.WriteByte('{') // for the group
+			continuing = false
+		} else {
+			// The group was empty
+			groupDepth--
+		}
+	}
+
+	if f.valuesStr != "" {
+		if continuing {
+			buf.WriteByte(f.comma())
+		}
 		buf.WriteString(f.valuesStr)
+		continuing = true
 	}
+
 	vals = args
 	if hook := f.opts.RenderArgsHook; hook != nil {
 		vals = hook(f.sanitize(vals))
 	}
 	f.flatten(buf, vals, continuing, true) // escape user-provided keys
+
+	for i := 0; i < groupDepth; i++ {
+		buf.WriteByte('}') // for the groups
+	}
+
 	if f.outputFormat == outputJSON {
-		buf.WriteByte('}')
+		buf.WriteByte('}') // for the whole line
 	}
+
 	return buf.String()
 }
 
@@ -298,9 +330,16 @@ func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, continuing bool, esc
 	if len(kvList)%2 != 0 {
 		kvList = append(kvList, noValue)
 	}
+	copied := false
 	for i := 0; i < len(kvList); i += 2 {
 		k, ok := kvList[i].(string)
 		if !ok {
+			if !copied {
+				newList := make([]any, len(kvList))
+				copy(newList, kvList)
+				kvList = newList
+				copied = true
+			}
 			k = f.nonStringKey(kvList[i])
 			kvList[i] = k
 		}
@@ -308,7 +347,7 @@ func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, continuing bool, esc
 
 		if i > 0 || continuing {
 			if f.outputFormat == outputJSON {
-				buf.WriteByte(',')
+				buf.WriteByte(f.comma())
 			} else {
 				// In theory the format could be something we don't understand.  In
 				// practice, we control it, so it won't be.
@@ -316,24 +355,35 @@ func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, continuing bool, esc
 			}
 		}
 
-		if escapeKeys {
-			buf.WriteString(prettyString(k))
-		} else {
-			// this is faster
-			buf.WriteByte('"')
-			buf.WriteString(k)
-			buf.WriteByte('"')
-		}
-		if f.outputFormat == outputJSON {
-			buf.WriteByte(':')
-		} else {
-			buf.WriteByte('=')
-		}
+		buf.WriteString(f.quoted(k, escapeKeys))
+		buf.WriteByte(f.colon())
 		buf.WriteString(f.pretty(v))
 	}
 	return kvList
 }
 
+func (f Formatter) quoted(str string, escape bool) string {
+	if escape {
+		return prettyString(str)
+	}
+	// this is faster
+	return `"` + str + `"`
+}
+
+func (f Formatter) comma() byte {
+	if f.outputFormat == outputJSON {
+		return ','
+	}
+	return ' '
+}
+
+func (f Formatter) colon() byte {
+	if f.outputFormat == outputJSON {
+		return ':'
+	}
+	return '='
+}
+
 func (f Formatter) pretty(value any) string {
 	return f.prettyWithFlags(value, 0, 0)
 }
@@ -407,12 +457,12 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 		}
 		for i := 0; i < len(v); i += 2 {
 			if i > 0 {
-				buf.WriteByte(',')
+				buf.WriteByte(f.comma())
 			}
 			k, _ := v[i].(string) // sanitize() above means no need to check success
 			// arbitrary keys might need escaping
 			buf.WriteString(prettyString(k))
-			buf.WriteByte(':')
+			buf.WriteByte(f.colon())
 			buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1))
 		}
 		if flags&flagRawStruct == 0 {
@@ -481,7 +531,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 				continue
 			}
 			if printComma {
-				buf.WriteByte(',')
+				buf.WriteByte(f.comma())
 			}
 			printComma = true // if we got here, we are rendering a field
 			if fld.Anonymous && fld.Type.Kind() == reflect.Struct && name == "" {
@@ -492,10 +542,8 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 				name = fld.Name
 			}
 			// field names can't contain characters which need escaping
-			buf.WriteByte('"')
-			buf.WriteString(name)
-			buf.WriteByte('"')
-			buf.WriteByte(':')
+			buf.WriteString(f.quoted(name, false))
+			buf.WriteByte(f.colon())
 			buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1))
 		}
 		if flags&flagRawStruct == 0 {
@@ -520,7 +568,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 		buf.WriteByte('[')
 		for i := 0; i < v.Len(); i++ {
 			if i > 0 {
-				buf.WriteByte(',')
+				buf.WriteByte(f.comma())
 			}
 			e := v.Index(i)
 			buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1))
@@ -534,7 +582,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 		i := 0
 		for it.Next() {
 			if i > 0 {
-				buf.WriteByte(',')
+				buf.WriteByte(f.comma())
 			}
 			// If a map key supports TextMarshaler, use it.
 			keystr := ""
@@ -556,7 +604,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
 				}
 			}
 			buf.WriteString(keystr)
-			buf.WriteByte(':')
+			buf.WriteByte(f.colon())
 			buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1))
 			i++
 		}
@@ -706,6 +754,53 @@ func (f Formatter) sanitize(kvList []any) []any {
 	return kvList
 }
 
+// startGroup opens a new group scope (basically a sub-struct), which locks all
+// the current saved values and starts them anew.  This is needed to satisfy
+// slog.
+func (f *Formatter) startGroup(group string) {
+	// Unnamed groups are just inlined.
+	if group == "" {
+		return
+	}
+
+	// Any saved values can no longer be changed.
+	buf := bytes.NewBuffer(make([]byte, 0, 1024))
+	continuing := false
+
+	if f.parentValuesStr != "" {
+		buf.WriteString(f.parentValuesStr)
+		continuing = true
+	}
+
+	if f.group != "" && f.valuesStr != "" {
+		if continuing {
+			buf.WriteByte(f.comma())
+		}
+		buf.WriteString(f.quoted(f.group, true)) // escape user-provided keys
+		buf.WriteByte(f.colon())
+		buf.WriteByte('{') // for the group
+		continuing = false
+	}
+
+	if f.valuesStr != "" {
+		if continuing {
+			buf.WriteByte(f.comma())
+		}
+		buf.WriteString(f.valuesStr)
+	}
+
+	// NOTE: We don't close the scope here - that's done later, when a log line
+	// is actually rendered (because we have N scopes to close).
+
+	f.parentValuesStr = buf.String()
+
+	// Start collecting new values.
+	f.group = group
+	f.groupDepth++
+	f.valuesStr = ""
+	f.values = nil
+}
+
 // Init configures this Formatter from runtime info, such as the call depth
 // imposed by logr itself.
 // Note that this receiver is a pointer, so depth can be saved.
diff --git a/funcr/slogsink.go b/funcr/slogsink.go
new file mode 100644
index 0000000..7bd8476
--- /dev/null
+++ b/funcr/slogsink.go
@@ -0,0 +1,105 @@
+//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 funcr
+
+import (
+	"context"
+	"log/slog"
+
+	"github.com/go-logr/logr"
+)
+
+var _ logr.SlogSink = &fnlogger{}
+
+const extraSlogSinkDepth = 3 // 2 for slog, 1 for SlogSink
+
+func (l fnlogger) Handle(_ context.Context, record slog.Record) error {
+	kvList := make([]any, 0, 2*record.NumAttrs())
+	record.Attrs(func(attr slog.Attr) bool {
+		kvList = attrToKVs(attr, kvList)
+		return true
+	})
+
+	if record.Level >= slog.LevelError {
+		l.WithCallDepth(extraSlogSinkDepth).Error(nil, record.Message, kvList...)
+	} else {
+		level := l.levelFromSlog(record.Level)
+		l.WithCallDepth(extraSlogSinkDepth).Info(level, record.Message, kvList...)
+	}
+	return nil
+}
+
+func (l fnlogger) WithAttrs(attrs []slog.Attr) logr.SlogSink {
+	kvList := make([]any, 0, 2*len(attrs))
+	for _, attr := range attrs {
+		kvList = attrToKVs(attr, kvList)
+	}
+	l.AddValues(kvList)
+	return &l
+}
+
+func (l fnlogger) WithGroup(name string) logr.SlogSink {
+	l.startGroup(name)
+	return &l
+}
+
+// attrToKVs appends a slog.Attr to a logr-style kvList.  It handle slog Groups
+// and other details of slog.
+func attrToKVs(attr slog.Attr, kvList []any) []any {
+	attrVal := attr.Value.Resolve()
+	if attrVal.Kind() == slog.KindGroup {
+		groupVal := attrVal.Group()
+		grpKVs := make([]any, 0, 2*len(groupVal))
+		for _, attr := range groupVal {
+			grpKVs = attrToKVs(attr, grpKVs)
+		}
+		if attr.Key == "" {
+			// slog says we have to inline these
+			kvList = append(kvList, grpKVs...)
+		} else {
+			kvList = append(kvList, attr.Key, PseudoStruct(grpKVs))
+		}
+	} else if attr.Key != "" {
+		kvList = append(kvList, attr.Key, attrVal.Any())
+	}
+
+	return kvList
+}
+
+// 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 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(logr.ToSlogHandler(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 fnlogger) levelFromSlog(level slog.Level) int {
+	result := -level
+	if result < 0 {
+		result = 0 // because LogSink doesn't expect negative V levels
+	}
+	return int(result)
+}
diff --git a/sloghandler.go b/sloghandler.go
index 2bbc4f5..82d1ba4 100644
--- a/sloghandler.go
+++ b/sloghandler.go
@@ -52,7 +52,7 @@ func (l *slogHandler) GetLevel() slog.Level {
 	return l.levelBias
 }
 
-func (l *slogHandler) Enabled(ctx context.Context, level slog.Level) bool {
+func (l *slogHandler) Enabled(_ context.Context, level slog.Level) bool {
 	return l.sink != nil && (level >= slog.LevelError || l.sink.Enabled(l.levelFromSlog(level)))
 }
 
@@ -70,9 +70,7 @@ func (l *slogHandler) Handle(ctx context.Context, record slog.Record) error {
 
 	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())
-		}
+		kvList = attrToKVs(attr, l.groupPrefix, kvList)
 		return true
 	})
 	if record.Level >= slog.LevelError {
@@ -107,20 +105,18 @@ func (l *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
 		return l
 	}
 
-	copy := *l
+	clone := *l
 	if l.slogSink != nil {
-		copy.slogSink = l.slogSink.WithAttrs(attrs)
-		copy.sink = copy.slogSink
+		clone.slogSink = l.slogSink.WithAttrs(attrs)
+		clone.sink = clone.slogSink
 	} else {
 		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())
-			}
+			kvList = attrToKVs(attr, l.groupPrefix, kvList)
 		}
-		copy.sink = l.sink.WithValues(kvList...)
+		clone.sink = l.sink.WithValues(kvList...)
 	}
-	return &copy
+	return &clone
 }
 
 func (l *slogHandler) WithGroup(name string) slog.Handler {
@@ -131,21 +127,46 @@ func (l *slogHandler) WithGroup(name string) slog.Handler {
 		// slog says to inline empty groups
 		return l
 	}
-	copy := *l
+	clone := *l
 	if l.slogSink != nil {
-		copy.slogSink = l.slogSink.WithGroup(name)
-		copy.sink = copy.slogSink
+		clone.slogSink = l.slogSink.WithGroup(name)
+		clone.sink = clone.slogSink
 	} else {
-		copy.groupPrefix = copy.addGroupPrefix(name)
+		clone.groupPrefix = addPrefix(clone.groupPrefix, name)
 	}
-	return &copy
+	return &clone
 }
 
-func (l *slogHandler) addGroupPrefix(name string) string {
-	if l.groupPrefix == "" {
+// attrToKVs appends a slog.Attr to a logr-style kvList.  It handle slog Groups
+// and other details of slog.
+func attrToKVs(attr slog.Attr, groupPrefix string, kvList []any) []any {
+	attrVal := attr.Value.Resolve()
+	if attrVal.Kind() == slog.KindGroup {
+		groupVal := attrVal.Group()
+		grpKVs := make([]any, 0, 2*len(groupVal))
+		prefix := groupPrefix
+		if attr.Key != "" {
+			prefix = addPrefix(groupPrefix, attr.Key)
+		}
+		for _, attr := range groupVal {
+			grpKVs = attrToKVs(attr, prefix, grpKVs)
+		}
+		kvList = append(kvList, grpKVs...)
+	} else if attr.Key != "" {
+		kvList = append(kvList, addPrefix(groupPrefix, attr.Key), attrVal.Any())
+	}
+
+	return kvList
+}
+
+func addPrefix(prefix, name string) string {
+	if prefix == "" {
 		return name
 	}
-	return l.groupPrefix + groupSeparator + name
+	if name == "" {
+		return prefix
+	}
+	return prefix + groupSeparator + name
 }
 
 // levelFromSlog adjusts the level by the logger's verbosity and negates it.
diff --git a/slogsink.go b/slogsink.go
index 93fa4e5..4060fcb 100644
--- a/slogsink.go
+++ b/slogsink.go
@@ -91,12 +91,12 @@ func (l *slogSink) log(err error, msg string, level slog.Level, kvList ...interf
 		record.AddAttrs(slog.Any(errKey, err))
 	}
 	record.Add(kvList...)
-	l.handler.Handle(context.Background(), record)
+	_ = l.handler.Handle(context.Background(), record)
 }
 
 func (l slogSink) WithName(name string) LogSink {
 	if l.name != "" {
-		l.name = l.name + "/"
+		l.name += "/"
 	}
 	l.name += name
 	return &l


Test patch:
diff --git a/benchmark/benchmark_slog_test.go b/benchmark/benchmark_slog_test.go
new file mode 100644
index 0000000..6f115fd
--- /dev/null
+++ b/benchmark/benchmark_slog_test.go
@@ -0,0 +1,142 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2021 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 logr
+
+import (
+	"log/slog"
+	"os"
+	"testing"
+
+	"github.com/go-logr/logr"
+)
+
+//
+// slogSink wrapper of discard
+//
+
+func BenchmarkSlogSinkLogInfoOneArg(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doInfoOneArg(b, log)
+}
+
+func BenchmarkSlogSinkLogInfoSeveralArgs(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doInfoSeveralArgs(b, log)
+}
+
+func BenchmarkSlogSinkLogInfoWithValues(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doInfoWithValues(b, log)
+}
+
+func BenchmarkSlogSinkLogV0Info(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doV0Info(b, log)
+}
+
+func BenchmarkSlogSinkLogV9Info(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doV9Info(b, log)
+}
+
+func BenchmarkSlogSinkLogError(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doError(b, log)
+}
+
+func BenchmarkSlogSinkWithValues(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doWithValues(b, log)
+}
+
+func BenchmarkSlogSinkWithName(b *testing.B) {
+	var log logr.Logger = logr.FromSlogHandler(logr.ToSlogHandler(logr.Discard()))
+	doWithName(b, log)
+}
+
+//
+// slogSink wrapper of slog's JSONHandler, for comparison
+//
+
+func makeSlogJSONLogger() logr.Logger {
+	devnull, _ := os.Open("/dev/null")
+	handler := slog.NewJSONHandler(devnull, nil)
+	return logr.FromSlogHandler(handler)
+}
+
+func BenchmarkSlogJSONLogInfoOneArg(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doInfoOneArg(b, log)
+}
+
+func BenchmarkSlogJSONLogInfoSeveralArgs(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doInfoSeveralArgs(b, log)
+}
+
+func BenchmarkSlogJSONLogInfoWithValues(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doInfoWithValues(b, log)
+}
+
+func BenchmarkSlogJSONLogV0Info(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doV0Info(b, log)
+}
+
+func BenchmarkSlogJSONLogV9Info(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doV9Info(b, log)
+}
+
+func BenchmarkSlogJSONLogError(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doError(b, log)
+}
+
+func BenchmarkSlogJSONLogWithValues(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doWithValues(b, log)
+}
+
+func BenchmarkSlogJSONWithName(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doWithName(b, log)
+}
+
+func BenchmarkSlogJSONWithCallDepth(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doWithCallDepth(b, log)
+}
+
+func BenchmarkSlogJSONLogInfoStringerValue(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doStringerValue(b, log)
+}
+
+func BenchmarkSlogJSONLogInfoErrorValue(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doErrorValue(b, log)
+}
+
+func BenchmarkSlogJSONLogInfoMarshalerValue(b *testing.B) {
+	var log logr.Logger = makeSlogJSONLogger()
+	doMarshalerValue(b, log)
+}
diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go
index 090e3ce..35c7dd0 100644
--- a/benchmark/benchmark_test.go
+++ b/benchmark/benchmark_test.go
@@ -141,6 +141,10 @@ func doMarshalerValue(b *testing.B, log logr.Logger) {
 	}
 }
 
+//
+// discard
+//
+
 func BenchmarkDiscardLogInfoOneArg(b *testing.B) {
 	var log logr.Logger = logr.Discard()
 	doInfoOneArg(b, log)
@@ -181,6 +185,10 @@ func BenchmarkDiscardWithName(b *testing.B) {
 	doWithName(b, log)
 }
 
+//
+// funcr
+//
+
 func noopKV(_, _ string) {}
 func noopJSON(_ string)  {}
 
diff --git a/example_marshaler_secret_test.go b/example_marshaler_secret_test.go
index 39ac4a4..f57ecd7 100644
--- a/example_marshaler_secret_test.go
+++ b/example_marshaler_secret_test.go
@@ -43,5 +43,5 @@ func ExampleMarshaler_secret() {
 	secret := ComplexObjectRef{Namespace: "kube-system", Name: "some-secret", Secret: "do-not-log-me"}
 	l.Info("simplified", "secret", secret)
 	// Output:
-	// "level"=0 "msg"="simplified" "secret"={"Name":"some-secret","Namespace":"kube-system"}
+	// "level"=0 "msg"="simplified" "secret"={"Name"="some-secret" "Namespace"="kube-system"}
 }
diff --git a/example_marshaler_test.go b/example_marshaler_test.go
index f9cd00a..6c52b34 100644
--- a/example_marshaler_test.go
+++ b/example_marshaler_test.go
@@ -51,5 +51,5 @@ func ExampleMarshaler() {
 	l.Info("as struct", "pod", pod)
 	// Output:
 	// "level"=0 "msg"="as string" "pod"="kube-system/some-pod"
-	// "level"=0 "msg"="as struct" "pod"={"name":"some-pod","namespace":"kube-system"}
+	// "level"=0 "msg"="as struct" "pod"={"name"="some-pod" "namespace"="kube-system"}
 }
diff --git a/example_slogr_test.go b/example_slogr_test.go
new file mode 100644
index 0000000..6f568f9
--- /dev/null
+++ b/example_slogr_test.go
@@ -0,0 +1,79 @@
+//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 logr_test
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/go-logr/logr"
+	"github.com/go-logr/logr/funcr"
+)
+
+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 ExampleFromSlogHandler() {
+	logrLogger := logr.FromSlogHandler(slog.NewTextHandler(os.Stdout, debugWithoutTime))
+
+	logrLogger.Info("hello world")
+	logrLogger.Error(errors.New("fake error"), "ignore me")
+	logrLogger.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 ExampleToSlogHandler() {
+	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,
+	})
+
+	slogLogger := slog.New(logr.ToSlogHandler(funcrLogger))
+	slogLogger.Info("hello world")
+	slogLogger.Error("ignore me", "err", errors.New("fake error"))
+	slogLogger.With("x", 1, "y", 2).WithGroup("group").With("str", "abc").Warn("with values and group")
+
+	slogLogger = slog.New(logr.ToSlogHandler(funcrLogger.V(int(-slog.LevelDebug))))
+	slogLogger.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"
+}
diff --git a/logr_noslog_test.go b/funcr/example/main_noslog.go
similarity index 75%
rename from logr_noslog_test.go
rename to funcr/example/main_noslog.go
index c449810..2329882 100644
--- a/logr_noslog_test.go
+++ b/funcr/example/main_noslog.go
@@ -17,7 +17,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package logr
+// Package main is an example of using funcr.
+package main
 
-//nolint:unused
-type testSlogSink struct{}
+import (
+	"github.com/go-logr/logr"
+)
+
+func doSlog(log logr.Logger) {
+	log.Error(nil, "Sorry, slog is not supported on this version of Go")
+}
diff --git a/funcr/funcr_test.go b/funcr/funcr_test.go
index b3a6f70..6a5c39c 100644
--- a/funcr/funcr_test.go
+++ b/funcr/funcr_test.go
@@ -659,7 +659,7 @@ func TestPretty(t *testing.T) {
 		exp: `{"[{\"S\":\"\\\"quoted\\\"\"},{\"S\":\"unquoted\"}]":1}`,
 	}}
 
-	f := NewFormatter(Options{})
+	f := NewFormatterJSON(Options{})
 	for i, tc := range cases {
 		ours := f.pretty(tc.val)
 		want := ""
@@ -722,7 +722,7 @@ func TestRender(t *testing.T) {
 		builtins:   makeKV("int", PseudoStruct(makeKV("intsub", 1))),
 		values:     makeKV("str", PseudoStruct(makeKV("strsub", "2"))),
 		args:       makeKV("bool", PseudoStruct(makeKV("boolsub", true))),
-		expectKV:   `"int"={"intsub":1} "str"={"strsub":"2"} "bool"={"boolsub":true}`,
+		expectKV:   `"int"={"intsub"=1} "str"={"strsub"="2"} "bool"={"boolsub"=true}`,
 		expectJSON: `{"int":{"intsub":1},"str":{"strsub":"2"},"bool":{"boolsub":true}}`,
 	}, {
 		name:       "escapes",
@@ -759,22 +759,22 @@ func TestRender(t *testing.T) {
 			F1 string
 			F2 int
 		}{"arg", 789}, "val"),
-		expectKV:   `"<non-string-key: {"F1":"builtin",>"="val" "<non-string-key: {\"F1\":\"value\",\"F>"="val" "<non-string-key: {\"F1\":\"arg\",\"F2\">"="val"`,
+		expectKV:   `"<non-string-key: {"F1"="builtin" >"="val" "<non-string-key: {\"F1\"=\"value\" \"F>"="val" "<non-string-key: {\"F1\"=\"arg\" \"F2\">"="val"`,
 		expectJSON: `{"<non-string-key: {"F1":"builtin",>":"val","<non-string-key: {\"F1\":\"value\",\"F>":"val","<non-string-key: {\"F1\":\"arg\",\"F2\">":"val"}`,
 	}, {
 		name:       "json rendering with json.RawMessage",
 		args:       makeKV("key", raw),
-		expectKV:   `"key"={"message":[123,34,105,110,116,49,34,58,48,44,34,45,34,58,48,44,34,73,110,116,53,34,58,48,125]}`,
+		expectKV:   `"key"={"message"=[123 34 105 110 116 49 34 58 48 44 34 45 34 58 48 44 34 73 110 116 53 34 58 48 125]}`,
 		expectJSON: `{"key":{"message":{"int1":0,"-":0,"Int5":0}}}`,
 	}, {
 		name:       "byte array not json.RawMessage",
 		args:       makeKV("key", []byte{1, 2, 3, 4}),
-		expectKV:   `"key"=[1,2,3,4]`,
+		expectKV:   `"key"=[1 2 3 4]`,
 		expectJSON: `{"key":[1,2,3,4]}`,
 	}, {
 		name:       "json rendering with empty json.RawMessage",
 		args:       makeKV("key", &Trawjson{}),
-		expectKV:   `"key"={"message":[]}`,
+		expectKV:   `"key"={"message"=[]}`,
 		expectJSON: `{"key":{"message":null}}`,
 	}}
 
@@ -784,7 +784,7 @@ func TestRender(t *testing.T) {
 				formatter.AddValues(tc.values)
 				r := formatter.render(tc.builtins, tc.args)
 				if r != expect {
-					t.Errorf("wrong output:\nexpected %v\n     got %v", expect, r)
+					t.Errorf("wrong output:\nexpected %q\n     got %q", expect, r)
 				}
 			}
 			t.Run("KV", func(t *testing.T) {
@@ -918,13 +918,13 @@ func TestInfoWithCaller(t *testing.T) {
 		sink := newSink(capt.Func, NewFormatter(Options{LogCaller: All}))
 		sink.Info(0, "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "level"=0 "msg"="msg"`, filepath.Base(file), line-1)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "level"=0 "msg"="msg"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
 		sink.Error(fmt.Errorf("error"), "msg")
 		_, file, line, _ = runtime.Caller(0)
-		expect = fmt.Sprintf(`"caller"={"file":%q,"line":%d} "msg"="msg" "error"="error"`, filepath.Base(file), line-1)
+		expect = fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "msg"="msg" "error"="error"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -951,13 +951,13 @@ func TestInfoWithCaller(t *testing.T) {
 		sink := newSink(capt.Func, NewFormatter(Options{LogCaller: All, LogCallerFunc: true}))
 		sink.Info(0, "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d,"function":%q} "level"=0 "msg"="msg"`, filepath.Base(file), line-1, thisFunc)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d "function"=%q} "level"=0 "msg"="msg"`, filepath.Base(file), line-1, thisFunc)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
 		sink.Error(fmt.Errorf("error"), "msg")
 		_, file, line, _ = runtime.Caller(0)
-		expect = fmt.Sprintf(`"caller"={"file":%q,"line":%d,"function":%q} "msg"="msg" "error"="error"`, filepath.Base(file), line-1, thisFunc)
+		expect = fmt.Sprintf(`"caller"={"file"=%q "line"=%d "function"=%q} "msg"="msg" "error"="error"`, filepath.Base(file), line-1, thisFunc)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -984,7 +984,7 @@ func TestInfoWithCaller(t *testing.T) {
 		sink := newSink(capt.Func, NewFormatter(Options{LogCaller: Info}))
 		sink.Info(0, "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "level"=0 "msg"="msg"`, filepath.Base(file), line-1)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "level"=0 "msg"="msg"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -1004,7 +1004,7 @@ func TestInfoWithCaller(t *testing.T) {
 		}
 		sink.Error(fmt.Errorf("error"), "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect = fmt.Sprintf(`"caller"={"file":%q,"line":%d} "msg"="msg" "error"="error"`, filepath.Base(file), line-1)
+		expect = fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "msg"="msg" "error"="error"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -1069,7 +1069,7 @@ func TestErrorWithCaller(t *testing.T) {
 		sink := newSink(capt.Func, NewFormatter(Options{LogCaller: All}))
 		sink.Error(fmt.Errorf("err"), "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "msg"="msg" "error"="err"`, filepath.Base(file), line-1)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "msg"="msg" "error"="err"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -1089,7 +1089,7 @@ func TestErrorWithCaller(t *testing.T) {
 		sink := newSink(capt.Func, NewFormatter(Options{LogCaller: Error}))
 		sink.Error(fmt.Errorf("err"), "msg")
 		_, file, line, _ := runtime.Caller(0)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "msg"="msg" "error"="err"`, filepath.Base(file), line-1)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "msg"="msg" "error"="err"`, filepath.Base(file), line-1)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -1326,7 +1326,7 @@ func TestInfoWithCallDepth(t *testing.T) {
 		sink = dSink.WithCallDepth(1)
 		sink.Info(0, "msg")
 		_, file, line, _ := runtime.Caller(1)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "level"=0 "msg"="msg"`, filepath.Base(file), line)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "level"=0 "msg"="msg"`, filepath.Base(file), line)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
@@ -1341,7 +1341,7 @@ func TestErrorWithCallDepth(t *testing.T) {
 		sink = dSink.WithCallDepth(1)
 		sink.Error(fmt.Errorf("err"), "msg")
 		_, file, line, _ := runtime.Caller(1)
-		expect := fmt.Sprintf(`"caller"={"file":%q,"line":%d} "msg"="msg" "error"="err"`, filepath.Base(file), line)
+		expect := fmt.Sprintf(`"caller"={"file"=%q "line"=%d} "msg"="msg" "error"="err"`, filepath.Base(file), line)
 		if capt.log != expect {
 			t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
 		}
diff --git a/funcr/slogsink_test.go b/funcr/slogsink_test.go
new file mode 100644
index 0000000..209717e
--- /dev/null
+++ b/funcr/slogsink_test.go
@@ -0,0 +1,153 @@
+//go:build go1.21
+// +build go1.21
+
+/*
+Copyright 2021 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 funcr
+
+import (
+	"bytes"
+	"fmt"
+	"log/slog"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/go-logr/logr"
+	"github.com/go-logr/logr/internal/testhelp"
+)
+
+func TestSlogSink(t *testing.T) {
+	testCases := []struct {
+		name      string
+		withAttrs []any
+		withGroup string
+		args      []any
+		expect    string
+	}{{
+		name:   "just msg",
+		args:   makeKV(),
+		expect: `{"logger":"","level":0,"msg":"msg"}`,
+	}, {
+		name:   "primitives",
+		args:   makeKV("int", 1, "str", "ABC", "bool", true),
+		expect: `{"logger":"","level":0,"msg":"msg","int":1,"str":"ABC","bool":true}`,
+	}, {
+		name:      "with attrs",
+		withAttrs: makeKV("attrInt", 1, "attrStr", "ABC", "attrBool", true),
+		args:      makeKV("int", 2),
+		expect:    `{"logger":"","level":0,"msg":"msg","attrInt":1,"attrStr":"ABC","attrBool":true,"int":2}`,
+	}, {
+		name:      "with group",
+		withGroup: "groupname",
+		args:      makeKV("int", 1, "str", "ABC", "bool", true),
+		expect:    `{"logger":"","level":0,"msg":"msg","groupname":{"int":1,"str":"ABC","bool":true}}`,
+	}, {
+		name:      "with attrs and group",
+		withAttrs: makeKV("attrInt", 1, "attrStr", "ABC"),
+		withGroup: "groupname",
+		args:      makeKV("int", 3, "bool", true),
+		expect:    `{"logger":"","level":0,"msg":"msg","attrInt":1,"attrStr":"ABC","groupname":{"int":3,"bool":true}}`,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			capt := &capture{}
+			logger := logr.New(newSink(capt.Func, NewFormatterJSON(Options{})))
+			slogger := slog.New(logr.ToSlogHandler(logger))
+			if len(tc.withAttrs) > 0 {
+				slogger = slogger.With(tc.withAttrs...)
+			}
+			if tc.withGroup != "" {
+				slogger = slogger.WithGroup(tc.withGroup)
+			}
+			slogger.Info("msg", tc.args...)
+			if capt.log != tc.expect {
+				t.Errorf("\nexpected %q\n     got %q", tc.expect, capt.log)
+			}
+		})
+	}
+}
+
+func TestSlogSinkNestedGroups(t *testing.T) {
+	capt := &capture{}
+	logger := logr.New(newSink(capt.Func, NewFormatterJSON(Options{})))
+	slogger := slog.New(logr.ToSlogHandler(logger))
+	slogger = slogger.With("out", 0)
+	slogger = slogger.WithGroup("g1").With("mid1", 1)
+	slogger = slogger.WithGroup("g2").With("mid2", 2)
+	slogger = slogger.WithGroup("g3").With("in", 3)
+	slogger.Info("msg", "k", "v")
+
+	expect := `{"logger":"","level":0,"msg":"msg","out":0,"g1":{"mid1":1,"g2":{"mid2":2,"g3":{"in":3,"k":"v"}}}}`
+	if capt.log != expect {
+		t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
+	}
+}
+
+func TestSlogSinkWithCaller(t *testing.T) {
+	capt := &capture{}
+	logger := logr.New(newSink(capt.Func, NewFormatterJSON(Options{LogCaller: All})))
+	slogger := slog.New(logr.ToSlogHandler(logger))
+	slogger.Error("msg", "int", 1)
+	_, file, line, _ := runtime.Caller(0)
+	expect := fmt.Sprintf(`{"logger":"","caller":{"file":%q,"line":%d},"msg":"msg","error":null,"int":1}`, filepath.Base(file), line-1)
+	if capt.log != expect {
+		t.Errorf("\nexpected %q\n     got %q", expect, capt.log)
+	}
+}
+
+func TestRunSlogTests(t *testing.T) {
+	fn := func(buffer *bytes.Buffer) slog.Handler {
+		printfn := func(obj string) {
+			fmt.Fprintln(buffer, obj)
+		}
+		opts := 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
+			},
+		}
+		logger := NewJSON(printfn, opts)
+		return logr.ToSlogHandler(logger)
+	}
+	exceptions := []string{
+		"a Handler should ignore a zero Record.Time", // Time is generated by sink.
+	}
+	testhelp.RunSlogTests(t, fn, exceptions...)
+}
+
+func TestLogrSlogConversion(t *testing.T) {
+	f := New(func(prefix, args string) {}, Options{})
+	f2 := logr.FromSlogHandler(logr.ToSlogHandler(f))
+	if want, got := f, f2; got != want {
+		t.Helper()
+		t.Errorf("Expected %T %+v, got instead: %T %+v", want, want, got, got)
+	}
+}
diff --git a/internal/testhelp/slog.go b/internal/testhelp/slog.go
new file mode 100644
index 0000000..dce6139
--- /dev/null
+++ b/internal/testhelp/slog.go
@@ -0,0 +1,79 @@
+//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 testhelp holds helper functions for the testing of logr and built-in
+// implementations.
+package testhelp
+
+import (
+	"bytes"
+	"encoding/json"
+	"log/slog"
+	"strings"
+	"testing"
+	"testing/slogtest"
+)
+
+// RunSlogTests runs slogtest.TestHandler on a given slog.Handler, which is
+// expected to emit JSON into the provided buffer.
+func RunSlogTests(t *testing.T, createHandler func(buffer *bytes.Buffer) slog.Handler, exceptions ...string) {
+	var buffer bytes.Buffer
+	handler := createHandler(&buffer)
+	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.Errorf("%v: %q", err, string(line))
+			}
+			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 unwrappable, ok := err.(interface {
+			Unwrap() []error
+		}); ok {
+			for _, err := range unwrappable.Unwrap() {
+				if !containsOne(err.Error(), exceptions...) {
+					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
+}
diff --git a/internal/testhelp/slog_test.go b/internal/testhelp/slog_test.go
new file mode 100644
index 0000000..cdc7f61
--- /dev/null
+++ b/internal/testhelp/slog_test.go
@@ -0,0 +1,33 @@
+//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 testhelp
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+)
+
+func TestRunSlogTestsOnSlogSink(t *testing.T) {
+	// This proves that RunSlogTests works.
+	RunSlogTests(t, func(buffer *bytes.Buffer) slog.Handler {
+		return slog.NewJSONHandler(buffer, nil)
+	})
+}
diff --git a/logr_slog_test.go b/logr_slog_test.go
deleted file mode 100644
index 7712235..0000000
--- a/logr_slog_test.go
+++ /dev/null
@@ -1,218 +0,0 @@
-//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 logr
-
-import (
-	"bytes"
-	"context"
-	"log/slog"
-	"testing"
-	"time"
-)
-
-var _ SlogSink = &testLogSink{}
-
-// testSlogSink gets embedded in testLogSink to add slog-specific fields
-// which are only available when slog is supported by Go.
-type testSlogSink struct {
-	attrs  []slog.Attr
-	groups []string
-
-	fnHandle    func(l *testLogSink, ctx context.Context, record slog.Record)
-	fnWithAttrs func(l *testLogSink, attrs []slog.Attr)
-	fnWithGroup func(l *testLogSink, name string)
-}
-
-func (l *testLogSink) Handle(ctx context.Context, record slog.Record) error {
-	if l.fnHandle != nil {
-		l.fnHandle(l, ctx, record)
-	}
-	return nil
-}
-
-func (l *testLogSink) WithAttrs(attrs []slog.Attr) SlogSink {
-	if l.fnWithAttrs != nil {
-		l.fnWithAttrs(l, attrs)
-	}
-	out := *l
-	n := len(out.attrs)
-	out.attrs = append(out.attrs[:n:n], attrs...)
-	return &out
-}
-
-func (l *testLogSink) WithGroup(name string) SlogSink {
-	if l.fnWithGroup != nil {
-		l.fnWithGroup(l, name)
-	}
-	out := *l
-	n := len(out.groups)
-	out.groups = append(out.groups[:n:n], name)
-	return &out
-}
-
-func withAttrs(record slog.Record, attrs ...slog.Attr) slog.Record {
-	record = record.Clone()
-	record.AddAttrs(attrs...)
-	return record
-}
-
-func toJSON(record slog.Record) string {
-	var buffer bytes.Buffer
-	record.Time = time.Time{}
-	handler := slog.NewJSONHandler(&buffer, nil)
-	if err := handler.Handle(context.Background(), record); err != nil {
-		return err.Error()
-	}
-	return buffer.String()
-}
-
-func TestToSlogHandler(t *testing.T) {
-	lvlThreshold := 0
-	actualCalledHandle := 0
-	var actualRecord slog.Record
-
-	sink := &testLogSink{}
-	logger := New(sink)
-
-	sink.fnEnabled = func(lvl int) bool {
-		return lvl <= lvlThreshold
-	}
-
-	sink.fnHandle = func(l *testLogSink, ctx context.Context, record slog.Record) {
-		actualCalledHandle++
-
-		// Combine attributes from sink and call. Ordering of WithValues and WithAttrs
-		// is wrong, but good enough for test cases.
-		var values slog.Record
-		values.Add(l.withValues...)
-		var attrs []any
-		add := func(attr slog.Attr) bool {
-			attrs = append(attrs, attr)
-			return true
-		}
-		values.Attrs(add)
-		record.Attrs(add)
-		for _, attr := range l.attrs {
-			attrs = append(attrs, attr)
-		}
-
-		// Wrap them in groups - not quite correct for WithValues that
-		// follows WithGroup, but good enough for test cases.
-		for i := len(l.groups) - 1; i >= 0; i-- {
-			attrs = []any{slog.Group(l.groups[i], attrs...)}
-		}
-
-		actualRecord = slog.Record{
-			Level:   record.Level,
-			Message: record.Message,
-		}
-		actualRecord.Add(attrs...)
-	}
-
-	verify := func(t *testing.T, expectedRecord slog.Record) {
-		actual := toJSON(actualRecord)
-		expected := toJSON(expectedRecord)
-		if expected != actual {
-			t.Errorf("JSON dump did not match, expected:\n%s\nGot:\n%s\n", expected, actual)
-		}
-	}
-
-	reset := func() {
-		lvlThreshold = 0
-		actualCalledHandle = 0
-		actualRecord = slog.Record{}
-	}
-
-	testcases := map[string]struct {
-		run            func()
-		expectedRecord slog.Record
-	}{
-		"simple": {
-			func() { slog.New(ToSlogHandler(logger)).Info("simple") },
-			slog.Record{Message: "simple"},
-		},
-
-		"disabled": {
-			func() { slog.New(ToSlogHandler(logger.V(1))).Info("") },
-			slog.Record{},
-		},
-
-		"enabled": {
-			func() {
-				lvlThreshold = 1
-				slog.New(ToSlogHandler(logger.V(1))).Info("enabled")
-			},
-			slog.Record{Level: -1, Message: "enabled"},
-		},
-
-		"error": {
-			func() { slog.New(ToSlogHandler(logger.V(100))).Error("error") },
-			slog.Record{Level: slog.LevelError, Message: "error"},
-		},
-
-		"with-parameters": {
-			func() { slog.New(ToSlogHandler(logger)).Info("", "answer", 42, "foo", "bar") },
-			withAttrs(slog.Record{}, slog.Int("answer", 42), slog.String("foo", "bar")),
-		},
-
-		"with-values": {
-			func() { slog.New(ToSlogHandler(logger.WithValues("answer", 42, "foo", "bar"))).Info("") },
-			withAttrs(slog.Record{}, slog.Int("answer", 42), slog.String("foo", "bar")),
-		},
-
-		"with-group": {
-			func() { slog.New(ToSlogHandler(logger)).WithGroup("group").Info("", "answer", 42, "foo", "bar") },
-			withAttrs(slog.Record{}, slog.Group("group", slog.Int("answer", 42), slog.String("foo", "bar"))),
-		},
-
-		"with-values-and-group": {
-			func() {
-				slog.New(ToSlogHandler(logger.WithValues("answer", 42, "foo", "bar"))).WithGroup("group").Info("")
-			},
-			// Behavior of testLogSink is not quite correct here.
-			withAttrs(slog.Record{}, slog.Group("group", slog.Int("answer", 42), slog.String("foo", "bar"))),
-		},
-
-		"with-group-and-values": {
-			func() {
-				slog.New(ToSlogHandler(logger)).WithGroup("group").With("answer", 42, "foo", "bar").Info("")
-			},
-			withAttrs(slog.Record{}, slog.Group("group", slog.Int("answer", 42), slog.String("foo", "bar"))),
-		},
-
-		"with-group-and-logr-values": {
-			func() {
-				slogLogger := slog.New(ToSlogHandler(logger)).WithGroup("group")
-				logrLogger := FromSlogHandler(slogLogger.Handler()).WithValues("answer", 42, "foo", "bar")
-				slogLogger = slog.New(ToSlogHandler(logrLogger))
-				slogLogger.Info("")
-			},
-			withAttrs(slog.Record{}, slog.Group("group", slog.Int("answer", 42), slog.String("foo", "bar"))),
-		},
-	}
-
-	for name, tc := range testcases {
-		t.Run(name, func(t *testing.T) {
-			tc.run()
-			verify(t, tc.expectedRecord)
-			reset()
-		})
-	}
-}
diff --git a/logr_test.go b/logr_test.go
index 354f038..dc33cc2 100644
--- a/logr_test.go
+++ b/logr_test.go
@@ -24,85 +24,6 @@ import (
 	"testing"
 )
 
-// testLogSink is a Logger just for testing that calls optional hooks on each method.
-type testLogSink struct {
-	fnInit       func(ri RuntimeInfo)
-	fnEnabled    func(lvl int) bool
-	fnInfo       func(lvl int, msg string, kv ...any)
-	fnError      func(err error, msg string, kv ...any)
-	fnWithValues func(kv ...any)
-	fnWithName   func(name string)
-
-	withValues []any
-
-	// testSlogSink contains some additional fields if (and only if) slog is supported by Go.
-	// See logr_slog_test.go.
-	//nolint:unused // Only unused with Go < 1.21.
-	testSlogSink
-}
-
-var _ LogSink = &testLogSink{}
-
-func (l *testLogSink) Init(ri RuntimeInfo) {
-	if l.fnInit != nil {
-		l.fnInit(ri)
-	}
-}
-
-func (l *testLogSink) Enabled(lvl int) bool {
-	if l.fnEnabled != nil {
-		return l.fnEnabled(lvl)
-	}
-	return false
-}
-
-func (l *testLogSink) Info(lvl int, msg string, kv ...any) {
-	if l.fnInfo != nil {
-		l.fnInfo(lvl, msg, kv...)
-	}
-}
-
-func (l *testLogSink) Error(err error, msg string, kv ...any) {
-	if l.fnError != nil {
-		l.fnError(err, msg, kv...)
-	}
-}
-
-func (l *testLogSink) WithValues(kv ...any) LogSink {
-	if l.fnWithValues != nil {
-		l.fnWithValues(kv...)
-	}
-	out := *l
-	n := len(out.withValues)
-	out.withValues = append(out.withValues[:n:n], kv...)
-	return &out
-}
-
-func (l *testLogSink) WithName(name string) LogSink {
-	if l.fnWithName != nil {
-		l.fnWithName(name)
-	}
-	out := *l
-	return &out
-}
-
-type testCallDepthLogSink struct {
-	testLogSink
-	callDepth       int
-	fnWithCallDepth func(depth int)
-}
-
-var _ CallDepthLogSink = &testCallDepthLogSink{}
-
-func (l *testCallDepthLogSink) WithCallDepth(depth int) LogSink {
-	if l.fnWithCallDepth != nil {
-		l.fnWithCallDepth(depth)
-	}
-	out := *l
-	out.callDepth += depth
-	return &out
-}
-
 func TestNew(t *testing.T) {
 	calledInit := 0
 
diff --git a/slogr_test.go b/slogr_test.go
index fff6fb5..2b00a50 100644
--- a/slogr_test.go
+++ b/slogr_test.go
@@ -17,13 +17,10 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package logr_test
+package logr
 
 import (
 	"bytes"
-	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -32,66 +29,81 @@ import (
 	"runtime"
 	"strings"
 	"testing"
-	"testing/slogtest"
 
-	"github.com/go-logr/logr"
-	"github.com/go-logr/logr/funcr"
+	"github.com/go-logr/logr/internal/testhelp"
 )
 
-var debugWithoutTime = &slog.HandlerOptions{
-	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
-		if a.Key == "time" {
-			return slog.Attr{}
+func TestToSlogHandler(t *testing.T) {
+	t.Run("from simple Logger", func(t *testing.T) {
+		logger := New(&testLogSink{})
+		handler := ToSlogHandler(logger)
+		if _, ok := handler.(*slogHandler); !ok {
+			t.Errorf("expected type *slogHandler, got %T", handler)
 		}
-		return a
-	},
-	Level: slog.LevelDebug,
-}
-
-func ExampleFromSlogHandler() {
-	logrLogger := logr.FromSlogHandler(slog.NewTextHandler(os.Stdout, debugWithoutTime))
+	})
 
-	logrLogger.Info("hello world")
-	logrLogger.Error(errors.New("fake error"), "ignore me")
-	logrLogger.WithValues("x", 1, "y", 2).WithValues("str", "abc").WithName("foo").WithName("bar").V(4).Info("with values, verbosity and name")
+	t.Run("from slog-enabled Logger", func(t *testing.T) {
+		logger := New(&testSlogSink{})
+		handler := ToSlogHandler(logger)
+		if _, ok := handler.(*slogHandler); !ok {
+			t.Errorf("expected type *slogHandler, got %T", handler)
+		}
+	})
 
-	// 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
+	t.Run("from slogSink Logger", func(t *testing.T) {
+		logger := New(&slogSink{handler: slog.NewJSONHandler(os.Stderr, nil)})
+		handler := ToSlogHandler(logger)
+		if _, ok := handler.(*slog.JSONHandler); !ok {
+			t.Errorf("expected type *slog.JSONHandler, got %T", handler)
+		}
+	})
 }
 
-func ExampleToSlogHandler() {
-	funcrLogger := funcr.New(func(prefix, args string) {
-		if prefix != "" {
-			fmt.Fprintln(os.Stdout, prefix, args)
-		} else {
-			fmt.Fprintln(os.Stdout, args)
+func TestFromSlogHandler(t *testing.T) {
+	t.Run("from slog Handler", func(t *testing.T) {
+		handler := slog.NewJSONHandler(os.Stderr, nil)
+		logger := FromSlogHandler(handler)
+		if _, ok := logger.sink.(*slogSink); !ok {
+			t.Errorf("expected type *slogSink, got %T", logger.sink)
 		}
-	}, funcr.Options{
-		Verbosity: 10,
 	})
 
-	slogLogger := slog.New(logr.ToSlogHandler(funcrLogger))
-	slogLogger.Info("hello world")
-	slogLogger.Error("ignore me", "err", errors.New("fake error"))
-	slogLogger.With("x", 1, "y", 2).WithGroup("group").With("str", "abc").Warn("with values and group")
+	t.Run("from simple slogHandler Handler", func(t *testing.T) {
+		handler := &slogHandler{sink: &testLogSink{}}
+		logger := FromSlogHandler(handler)
+		if _, ok := logger.sink.(*testLogSink); !ok {
+			t.Errorf("expected type *testSlogSink, got %T", logger.sink)
+		}
+	})
 
-	slogLogger = slog.New(logr.ToSlogHandler(funcrLogger.V(int(-slog.LevelDebug))))
-	slogLogger.Info("info message reduced to debug level")
+	t.Run("from discard slogHandler Handler", func(t *testing.T) {
+		handler := &slogHandler{}
+		logger := FromSlogHandler(handler)
+		if logger != Discard() {
+			t.Errorf("expected type *testSlogSink, got %T", logger.sink)
+		}
+	})
+}
 
-	// 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"
+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 TestWithCallDepth(t *testing.T) {
 	debugWithCaller := *debugWithoutTime
 	debugWithCaller.AddSource = true
 	var buffer bytes.Buffer
-	logger := logr.FromSlogHandler(slog.NewTextHandler(&buffer, &debugWithCaller))
+	logger := FromSlogHandler(slog.NewTextHandler(&buffer, &debugWithCaller))
+
+	logHelper := func(logger Logger) {
+		logger.WithCallDepth(1).Info("hello")
+	}
 
 	logHelper(logger)
 	_, file, line, _ := runtime.Caller(0)
@@ -102,148 +114,67 @@ func TestWithCallDepth(t *testing.T) {
 	}
 }
 
-func logHelper(logger logr.Logger) {
-	logger.WithCallDepth(1).Info("hello")
-}
-
-func TestJSONHandler(t *testing.T) {
-	testSlog(t, func(buffer *bytes.Buffer) logr.Logger {
-		handler := slog.NewJSONHandler(buffer, nil)
-		sink := testSlogSink{handler: handler}
-		return logr.New(sink)
-	})
-}
-
-var _ logr.LogSink = testSlogSink{}
-var _ logr.SlogSink = testSlogSink{}
-
-// testSlogSink is only used through slog and thus doesn't need to implement the
-// normal LogSink methods.
-type testSlogSink struct {
-	handler slog.Handler
-}
-
-func (s testSlogSink) Init(logr.RuntimeInfo)                  {}
-func (s testSlogSink) Enabled(int) bool                       { return true }
-func (s testSlogSink) Error(error, string, ...interface{})    {}
-func (s testSlogSink) Info(int, string, ...interface{})       {}
-func (s testSlogSink) WithName(string) logr.LogSink           { return s }
-func (s testSlogSink) WithValues(...interface{}) logr.LogSink { return s }
-
-func (s testSlogSink) Handle(ctx context.Context, record slog.Record) error {
-	return s.handler.Handle(ctx, record)
-}
-func (s testSlogSink) WithAttrs(attrs []slog.Attr) logr.SlogSink {
-	return testSlogSink{handler: s.handler.WithAttrs(attrs)}
-}
-func (s testSlogSink) WithGroup(name string) logr.SlogSink {
-	return testSlogSink{handler: s.handler.WithGroup(name)}
-}
-
-func TestFuncrHandler(t *testing.T) {
-	testSlog(t, func(buffer *bytes.Buffer) logr.Logger {
-		logger := 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
-			},
-		})
-		return logger
-	},
-		"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.
-	)
-}
-
-func testSlog(t *testing.T, createLogger func(buffer *bytes.Buffer) logr.Logger, exceptions ...string) {
-	var buffer bytes.Buffer
-	logger := createLogger(&buffer)
-	handler := logr.ToSlogHandler(logger)
-	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(), exceptions...) {
-					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 TestRunSlogTestsOnSlogHandlerLogSink(t *testing.T) {
+	// This proves that slogHandler passes slog's own tests when given a
+	// non-SlogSink LogSink.
+	exceptions := []string{
+		// logr sinks handle time themselves
+		"a Handler should ignore a zero Record.Time",
+		// slogHandler does not do groups "properly", so these all fail with
+		// "missing group".  It's looking for `"G":{"a":"b"}` and getting
+		// `"G.a": "b"`.
+		"a Handler should handle Group attributes",
+		"a Handler should handle the WithGroup method",
+		"a Handler should handle multiple WithGroup and WithAttr calls",
+		"a Handler should not output groups for an empty Record",
+		"a Handler should call Resolve on attribute values in groups",
+		"a Handler should call Resolve on attribute values in groups from WithAttrs",
 	}
+	testhelp.RunSlogTests(t, func(buffer *bytes.Buffer) slog.Handler {
+		// We want a known-good Logger that emits JSON but is not a slogHandler
+		// or SlogSink (since those get special treatment).  We can trust that
+		// the slog JSONHandler works.
+		handler := slog.NewJSONHandler(buffer, nil)
+		sink := &passthruLogSink{handler: handler}
+		logger := New(sink)
+		return ToSlogHandler(logger)
+	}, exceptions...)
+}
+
+func TestRunSlogTestsOnSlogHandlerSlogSink(t *testing.T) {
+	// This proves that slogHandler passes slog's own tests when given a
+	// SlogSink.
+	exceptions := []string{}
+	testhelp.RunSlogTests(t, func(buffer *bytes.Buffer) slog.Handler {
+		// We want a known-good Logger that emits JSON and implements SlogSink,
+		// to cover those paths.  We can trust that the slog JSONHandler works.
+		handler := slog.NewJSONHandler(buffer, nil)
+		sink := &passthruSlogSink{handler: handler}
+		logger := New(sink)
+		return ToSlogHandler(logger)
+	}, exceptions...)
 }
 
-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(logr.ToSlogHandler(logr.Discard()))
+func TestSlogSinkOnDiscard(_ *testing.T) {
+	// Compile-test
+	logger := slog.New(ToSlogHandler(Discard()))
 	logger.WithGroup("foo").With("x", 1).Info("hello")
 }
 
 func TestConversion(t *testing.T) {
-	d := logr.Discard()
-	d2 := logr.FromSlogHandler(logr.ToSlogHandler(d))
+	d := Discard()
+	d2 := FromSlogHandler(ToSlogHandler(d))
 	expectEqual(t, d, d2)
 
-	e := logr.Logger{}
-	e2 := logr.FromSlogHandler(logr.ToSlogHandler(e))
+	e := Logger{}
+	e2 := FromSlogHandler(ToSlogHandler(e))
 	expectEqual(t, e, e2)
 
-	f := funcr.New(func(prefix, args string) {}, funcr.Options{})
-	f2 := logr.FromSlogHandler(logr.ToSlogHandler(f))
-	expectEqual(t, f, f2)
-
 	text := slog.NewTextHandler(io.Discard, nil)
-	text2 := logr.ToSlogHandler(logr.FromSlogHandler(text))
+	text2 := ToSlogHandler(FromSlogHandler(text))
 	expectEqual(t, text, text2)
 
-	text3 := logr.ToSlogHandler(logr.FromSlogHandler(text).V(1))
+	text3 := ToSlogHandler(FromSlogHandler(text).V(1))
 	if handler, ok := text3.(interface {
 		GetLevel() slog.Level
 	}); ok {
diff --git a/testimpls_slog_test.go b/testimpls_slog_test.go
new file mode 100644
index 0000000..5ae2690
--- /dev/null
+++ b/testimpls_slog_test.go
@@ -0,0 +1,140 @@
+//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 logr
+
+import (
+	"context"
+	"log/slog"
+	"time"
+)
+
+var _ SlogSink = &testSlogSink{}
+
+// testSlogSink is a trivial SlogSink implementation, just for testing, which
+// calls (optional) hooks on each method.
+type testSlogSink struct {
+	// embed a plain LogSink
+	testLogSink
+
+	attrs  []slog.Attr
+	groups []string
+
+	fnHandle    func(ss *testSlogSink, ctx context.Context, record slog.Record)
+	fnWithAttrs func(ss *testSlogSink, attrs []slog.Attr)
+	fnWithGroup func(ss *testSlogSink, name string)
+}
+
+func (ss *testSlogSink) Handle(ctx context.Context, record slog.Record) error {
+	if ss.fnHandle != nil {
+		ss.fnHandle(ss, ctx, record)
+	}
+	return nil
+}
+
+func (ss *testSlogSink) WithAttrs(attrs []slog.Attr) SlogSink {
+	if ss.fnWithAttrs != nil {
+		ss.fnWithAttrs(ss, attrs)
+	}
+	out := *ss
+	n := len(out.attrs)
+	out.attrs = append(out.attrs[:n:n], attrs...)
+	return &out
+}
+
+func (ss *testSlogSink) WithGroup(name string) SlogSink {
+	if ss.fnWithGroup != nil {
+		ss.fnWithGroup(ss, name)
+	}
+	out := *ss
+	n := len(out.groups)
+	out.groups = append(out.groups[:n:n], name)
+	return &out
+}
+
+// passthruLogSink is a trivial LogSink implementation, which implements the
+// logr.LogSink methods in terms of a slog.Handler.
+type passthruLogSink struct {
+	handler slog.Handler
+}
+
+func (pl passthruLogSink) Init(RuntimeInfo) {}
+
+func (pl passthruLogSink) Enabled(int) bool { return true }
+
+func (pl passthruLogSink) Error(_ error, msg string, kvList ...interface{}) {
+	var record slog.Record
+	record.Message = msg
+	record.Level = slog.LevelError
+	record.Time = time.Now()
+	record.Add(kvList...)
+	_ = pl.handler.Handle(context.Background(), record)
+}
+
+func (pl passthruLogSink) Info(_ int, msg string, kvList ...interface{}) {
+	var record slog.Record
+	record.Message = msg
+	record.Level = slog.LevelInfo
+	record.Time = time.Now()
+	record.Add(kvList...)
+	_ = pl.handler.Handle(context.Background(), record)
+}
+
+func (pl passthruLogSink) WithName(string) LogSink { return &pl }
+
+func (pl passthruLogSink) WithValues(kvList ...interface{}) LogSink {
+	var values slog.Record
+	values.Add(kvList...)
+	var attrs []slog.Attr
+	add := func(attr slog.Attr) bool {
+		attrs = append(attrs, attr)
+		return true
+	}
+	values.Attrs(add)
+
+	pl.handler = pl.handler.WithAttrs(attrs)
+	return &pl
+}
+
+// passthruSlogSink is a trivial SlogSink implementation, which stubs out the
+// logr.LogSink methods and passes Logr.SlogSink thru to a slog.Handler.
+type passthruSlogSink struct {
+	handler slog.Handler
+}
+
+func (ps passthruSlogSink) Init(RuntimeInfo)                    {}
+func (ps passthruSlogSink) Enabled(int) bool                    { return true }
+func (ps passthruSlogSink) Error(error, string, ...interface{}) {}
+func (ps passthruSlogSink) Info(int, string, ...interface{})    {}
+func (ps passthruSlogSink) WithName(string) LogSink             { return &ps }
+func (ps passthruSlogSink) WithValues(...interface{}) LogSink   { return &ps }
+
+func (ps *passthruSlogSink) Handle(ctx context.Context, record slog.Record) error {
+	return ps.handler.Handle(ctx, record)
+}
+
+func (ps passthruSlogSink) WithAttrs(attrs []slog.Attr) SlogSink {
+	ps.handler = ps.handler.WithAttrs(attrs)
+	return &ps
+}
+
+func (ps passthruSlogSink) WithGroup(name string) SlogSink {
+	ps.handler = ps.handler.WithGroup(name)
+	return &ps
+}
diff --git a/testimpls_test.go b/testimpls_test.go
new file mode 100644
index 0000000..08571db
--- /dev/null
+++ b/testimpls_test.go
@@ -0,0 +1,92 @@
+/*
+Copyright 2021 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 logr
+
+// testLogSink is a trivial LogSink implementation, just for testing, which
+// calls (optional) hooks on each method.
+type testLogSink struct {
+	fnInit       func(ri RuntimeInfo)
+	fnEnabled    func(lvl int) bool
+	fnInfo       func(lvl int, msg string, kv ...any)
+	fnError      func(err error, msg string, kv ...any)
+	fnWithValues func(kv ...any)
+	fnWithName   func(name string)
+
+	withValues []any
+}
+
+var _ LogSink = &testLogSink{}
+
+func (ls *testLogSink) Init(ri RuntimeInfo) {
+	if ls.fnInit != nil {
+		ls.fnInit(ri)
+	}
+}
+
+func (ls *testLogSink) Enabled(lvl int) bool {
+	if ls.fnEnabled != nil {
+		return ls.fnEnabled(lvl)
+	}
+	return false
+}
+
+func (ls *testLogSink) Info(lvl int, msg string, kv ...any) {
+	if ls.fnInfo != nil {
+		ls.fnInfo(lvl, msg, kv...)
+	}
+}
+
+func (ls *testLogSink) Error(err error, msg string, kv ...any) {
+	if ls.fnError != nil {
+		ls.fnError(err, msg, kv...)
+	}
+}
+
+func (ls *testLogSink) WithValues(kv ...any) LogSink {
+	if ls.fnWithValues != nil {
+		ls.fnWithValues(kv...)
+	}
+	out := *ls
+	n := len(out.withValues)
+	out.withValues = append(out.withValues[:n:n], kv...)
+	return &out
+}
+
+func (ls *testLogSink) WithName(name string) LogSink {
+	if ls.fnWithName != nil {
+		ls.fnWithName(name)
+	}
+	out := *ls
+	return &out
+}
+
+type testCallDepthLogSink struct {
+	testLogSink
+	callDepth       int
+	fnWithCallDepth func(depth int)
+}
+
+var _ CallDepthLogSink = &testCallDepthLogSink{}
+
+func (ls *testCallDepthLogSink) WithCallDepth(depth int) LogSink {
+	if ls.fnWithCallDepth != nil {
+		ls.fnWithCallDepth(depth)
+	}
+	out := *ls
+	out.callDepth += depth
+	return &out
+}