Skip to content

Commit b142419

Browse files
committed
support slog
This enables the use of the logr.Logger API on top of a slog.Handler. The other direction is also possible, but some information is lost. Instead, logr implementations should better get extended for usage as slog.Handler. Going back and forth between the two APIs is now part of logr.
1 parent e40bcc0 commit b142419

File tree

5 files changed

+412
-1
lines changed

5 files changed

+412
-1
lines changed

README.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,28 @@ received:
7474
If the Go standard library had defined an interface for logging, this project
7575
probably would not be needed. Alas, here we are.
7676

77+
When the Go developers started developing such an interface with
78+
[slog](https://github.com/golang/go/issues/56345), they adopted some of the
79+
logr design but also left out some parts and changed others:
80+
81+
| Feature | logr | slog |
82+
|---------|------|------|
83+
| High-level API | `Logger` (passed by value) | `Logger` (passed by [pointer](https://github.com/golang/go/issues/59126)) |
84+
| Low-level API | `LogSink` | `Handler` |
85+
| Stack unwinding | done by `LogSink` | done by `Logger` |
86+
| Skipping helper functions | `WithCallDepth`, `WithCallStackHelper` | [not supported by Logger](https://github.com/golang/go/issues/59145) |
87+
| Generating a value for logging on demand | `Marshaler` | `LogValuer` |
88+
| Log levels | >= 0, higher meaning "less important" | positive and negative, with 0 for "info" and higher meaning "more important" |
89+
| Error log entries | always logged, don't have a verbosity level | normal log entries with level >= `LevelError` |
90+
| Passing logger via context | `NewContext`, `FromContext` | no API |
91+
| Adding a name to a logger | `WithName` | no API |
92+
| Grouping of key/value pairs | not supported | `WithGroup`, `GroupValue` |
93+
94+
The high-level slog API is explicitly meant to be one of many different APIs
95+
that can be layered on top of a shared `slog.Handler`. logr is one such
96+
alternative API, with interoperability as [described
97+
below](#slog-interoperability).
98+
7799
### Inspiration
78100

79101
Before you consider this package, please read [this blog post by the
@@ -119,6 +141,63 @@ There are implementations for the following logging libraries:
119141
- **github.com/go-kit/log**: [gokitlogr](https://github.com/tonglil/gokitlogr) (also compatible with github.com/go-kit/kit/log since v0.12.0)
120142
- **bytes.Buffer** (writing to a buffer): [bufrlogr](https://github.com/tonglil/buflogr) (useful for ensuring values were logged, like during testing)
121143

144+
## slog interoperability
145+
146+
Interoperability goes both ways, using the `logr.Logger` API with a `slog.Handler`
147+
and using the `slog.Logger` API with a `logr.LogSink`. logr provides `ToSlog` and
148+
`FromSlog` API calls to convert between a `logr.Logger` and a `slog.Logger`. Because
149+
the `slog.Logger` API is optional, there are also variants of these calls which
150+
work directly with a `slog.Handler`.
151+
152+
Ideally, the backend should support both logr and slog. In that case, log calls
153+
can go from the high-level API to the backend with no intermediate glue
154+
code. Because of a conflict in the parameters of the common Enabled method, it
155+
is [not possible to implement both interfaces in the same
156+
type](https://github.com/golang/go/issues/59110). A second type and methods for
157+
converting from one type to the other are needed. Here is an example:
158+
159+
```
160+
// logSink implements logr.LogSink and logr.SlogImplementor.
161+
type logSink struct { ... }
162+
163+
func (l *logSink) Enabled(lvl int) bool { ... }
164+
...
165+
166+
// logHandler implements slog.Handler.
167+
type logHandler logSink
168+
169+
func (l *logHandler) Enabled(ctx context.Context, slog.Level) bool { ... }
170+
...
171+
172+
// Explicit support for converting between the two types is needed by logr
173+
// because it cannot do type assertions.
174+
175+
func (l *logSink) GetSlogHandler() slog.Handler { return (*logHandler)(l) }
176+
func (l *logHandler) GetLogrLogSink() logr.LogSink { return (*logSink)(l) }
177+
```
178+
179+
Such a backend also should support values that implement specific interfaces
180+
from both packages for logging (`logr.Marshaler`, `slog.LogValuer`). logr does not
181+
convert between those.
182+
183+
If a backend only supports `logr.LogSink`, then `ToSlog` uses
184+
[`slogHandler`](sloghandler.go) to implement the `logr.Handler` on top of that
185+
`logr.LogSink`. This solution is problematic because there is no way to log the
186+
correct call site. All log entries with `slog.Level` >= `slog.LevelInfo` (= 0)
187+
and < `slog.LevelError` get logged as info message with logr level 0, >=
188+
`slog.LevelError` as error message and negative levels as debug messages with
189+
negated level (i.e. `slog.LevelDebug` = -4 becomes
190+
`V(4).Info`). `slog.LogValuer` will not get used. Applications which care about
191+
these aspects should switch to a logr implementation which supports slog.
192+
193+
If a backend only supports slog.Handler, then `FromSlog` uses
194+
[`slogSink`](slogsink.go). This solution is more viable because call sites can
195+
be logged correctly. However, `logr.Marshaler` will not get used. Types that
196+
support `logr.Marshaler` should also support
197+
`slog.LogValuer`. `logr.Logger.Error` logs with `slog.ErrorLevel`,
198+
`logr.Logger.Info` with the negated level (i.e. `V(0).Info` uses `slog.Level` 0
199+
= `slog.InfoLevel`, `V(4).Info` uses `slog.Level` -4 = `slog.DebugLevel`).
200+
122201
## FAQ
123202

124203
### Conceptual
@@ -242,7 +321,9 @@ Otherwise, you can start out with `0` as "you always want to see this",
242321

243322
Then gradually choose levels in between as you need them, working your way
244323
down from 10 (for debug and trace style logs) and up from 1 (for chattier
245-
info-type logs.)
324+
info-type logs). For reference, slog pre-defines -4 for debug logs
325+
(corresponds to 4 in logr), which matches what is
326+
[recommended for Kubernetes](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md#what-method-to-use).
246327

247328
#### How do I choose my keys?
248329

slog.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//go:build go1.21
2+
// +build go1.21
3+
4+
/*
5+
Copyright 2023 The logr Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package logr
21+
22+
import (
23+
"log/slog"
24+
)
25+
26+
// SlogImplementor is an interface that a logr.LogSink can implement
27+
// to support efficient logging through the slog.Logger API.
28+
type SlogImplementor interface {
29+
// GetSlogHandler returns a handler which uses the same settings as the logr.LogSink.
30+
GetSlogHandler() slog.Handler
31+
}
32+
33+
// LogrImplementor is an interface that a slog.Handler can implement
34+
// to support efficient logging through the logr.Logger API.
35+
type LogrImplementor interface {
36+
// GetLogrLogSink returns a sink which uses the same settings as the slog.Handler.
37+
GetLogrLogSink() LogSink
38+
}
39+
40+
// ToSlog returns a slog.Logger which writes to the same backend as the logr.Logger.
41+
func ToSlog(logger Logger) *slog.Logger {
42+
return slog.New(ToSlogHandler(logger))
43+
}
44+
45+
// ToSlog returns a slog.Handler which writes to the same backend as the logr.Logger.
46+
func ToSlogHandler(logger Logger) slog.Handler {
47+
if slogImplementor, ok := logger.GetSink().(SlogImplementor); ok {
48+
handler := slogImplementor.GetSlogHandler()
49+
return handler
50+
}
51+
52+
return &slogHandler{sink: logger.GetSink()}
53+
}
54+
55+
// FromSlog returns a logr.Logger which writes to the same backend as the slog.Logger.
56+
func FromSlog(logger *slog.Logger) Logger {
57+
return FromSlogHandler(logger.Handler())
58+
}
59+
60+
// FromSlog returns a logr.Logger which writes to the same backend as the slog.Handler.
61+
func FromSlogHandler(handler slog.Handler) Logger {
62+
if logrImplementor, ok := handler.(LogrImplementor); ok {
63+
logSink := logrImplementor.GetLogrLogSink()
64+
return New(logSink)
65+
}
66+
67+
return New(&slogSink{handler: handler})
68+
}
69+
70+
func levelFromSlog(level slog.Level) int {
71+
if level >= 0 {
72+
// logr has no level lower than 0, so we have to truncate.
73+
return 0
74+
}
75+
return int(-level)
76+
}
77+
78+
func levelToSlog(level int) slog.Level {
79+
// logr starts at info = 0 and higher values go towards debugging (negative in slog).
80+
return slog.Level(-level)
81+
}

slog_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//go:build go1.21
2+
// +build go1.21
3+
4+
/*
5+
Copyright 2023 The logr Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package logr_test
21+
22+
import (
23+
"errors"
24+
"fmt"
25+
"log/slog"
26+
"os"
27+
28+
"github.com/go-logr/logr"
29+
"github.com/go-logr/logr/funcr"
30+
)
31+
32+
var debugWithoutTime = &slog.HandlerOptions{
33+
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
34+
if a.Key == "time" {
35+
return slog.Attr{}
36+
}
37+
return a
38+
},
39+
Level: slog.LevelDebug,
40+
}
41+
42+
func ExampleFromSlog() {
43+
logger := logr.FromSlog(slog.New(slog.NewTextHandler(os.Stdout, debugWithoutTime)))
44+
45+
logger.Info("hello world")
46+
logger.Error(errors.New("fake error"), "ignore me")
47+
logger.WithValues("x", 1, "y", 2).WithValues("str", "abc").WithName("foo").WithName("bar").V(4).Info("with values, verbosity and name")
48+
49+
// Output:
50+
// level=INFO msg="hello world"
51+
// level=ERROR msg="ignore me" err="fake error"
52+
// level=DEBUG msg="foo/bar: with values, verbosity and name" x=1 y=2 str=abc
53+
}
54+
55+
func ExampleToSlog() {
56+
logger := logr.ToSlog(funcr.New(func(prefix, args string) {
57+
if prefix != "" {
58+
fmt.Fprintln(os.Stdout, prefix, args)
59+
} else {
60+
fmt.Fprintln(os.Stdout, args)
61+
}
62+
}, funcr.Options{}))
63+
64+
logger.Info("hello world")
65+
logger.Error("ignore me", "err", errors.New("fake error"))
66+
logger.With("x", 1, "y", 2).WithGroup("group").With("str", "abc").Warn("with values and group")
67+
68+
// Output:
69+
// "level"=0 "msg"="hello world"
70+
// "msg"="ignore me" "error"=null "err"="fake error"
71+
// "level"=0 "msg"="with values and group" "x"=1 "y"=2 "group.str"="abc"
72+
}

sloghandler.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//go:build go1.21
2+
// +build go1.21
3+
4+
/*
5+
Copyright 2023 The logr Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package logr
21+
22+
import (
23+
"context"
24+
"log/slog"
25+
)
26+
27+
type slogHandler struct {
28+
sink LogSink
29+
groupPrefix string
30+
}
31+
32+
func (l *slogHandler) Enabled(ctx context.Context, level slog.Level) bool {
33+
return l.sink.Enabled(levelFromSlog(level))
34+
}
35+
36+
func (l *slogHandler) Handle(ctx context.Context, record slog.Record) error {
37+
kvList := make([]any, 0, 2*record.NumAttrs())
38+
record.Attrs(func(attr slog.Attr) bool {
39+
kvList = append(kvList, appendPrefix(l.groupPrefix, attr.Key), attr.Value.Any())
40+
return true
41+
})
42+
if record.Level >= slog.LevelError {
43+
l.sink.Error(nil, record.Message, kvList...)
44+
} else {
45+
l.sink.Info(levelFromSlog(record.Level), record.Message, kvList...)
46+
}
47+
return nil
48+
}
49+
50+
func (l slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
51+
kvList := make([]any, 0, 2*len(attrs))
52+
for _, attr := range attrs {
53+
kvList = append(kvList, appendPrefix(l.groupPrefix, attr.Key), attr.Value.Any())
54+
}
55+
l.sink = l.sink.WithValues(kvList...)
56+
return &l
57+
}
58+
59+
func (l slogHandler) WithGroup(name string) slog.Handler {
60+
l.groupPrefix = appendPrefix(l.groupPrefix, name)
61+
return &l
62+
}
63+
64+
func appendPrefix(prefix, name string) string {
65+
if prefix == "" {
66+
return name
67+
}
68+
return prefix + "." + name
69+
}
70+
71+
var _ slog.Handler = &slogHandler{}

0 commit comments

Comments
 (0)