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/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/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/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/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/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: `""="val" ""="val" ""="val"`, + expectKV: `""="val" ""="val" ""="val"`, expectJSON: `{"":"val","":"val","":"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.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/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/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 © + 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 © + 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/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/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 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 +}