Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: slog support #196

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
test:
strategy:
matrix:
version: [ '1.15', '1.16', '1.17', '1.18' ]
version: [ '1.15', '1.16', '1.17', '1.18', '1.19', '1.20', '1.21.0-rc.4' ]
thockin marked this conversation as resolved.
Show resolved Hide resolved
platform: [ ubuntu-latest, macos-latest, windows-latest ]
runs-on: ${{ matrix.platform }}
steps:
Expand Down
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ received:
If the Go standard library had defined an interface for logging, this project
probably would not be needed. Alas, here we are.

When the Go developers started developing such an interface with
[slog](https://github.com/golang/go/issues/56345), they adopted some of the
logr design but also left out some parts and changed others:

| Feature | logr | slog |
|---------|------|------|
| High-level API | `Logger` (passed by value) | `Logger` (passed by [pointer](https://github.com/golang/go/issues/59126)) |
| Low-level API | `LogSink` | `Handler` |
| Stack unwinding | done by `LogSink` | done by `Logger` |
| Skipping helper functions | `WithCallDepth`, `WithCallStackHelper` | [not supported by Logger](https://github.com/golang/go/issues/59145) |
| Generating a value for logging on demand | `Marshaler` | `LogValuer` |
| Log levels | >= 0, higher meaning "less important" | positive and negative, with 0 for "info" and higher meaning "more important" |
| Error log entries | always logged, don't have a verbosity level | normal log entries with level >= `LevelError` |
| Passing logger via context | `NewContext`, `FromContext` | no API |
| Adding a name to a logger | `WithName` | no API |
| Grouping of key/value pairs | not supported | `WithGroup`, `GroupValue` |

The high-level slog API is explicitly meant to be one of many different APIs
that can be layered on top of a shared `slog.Handler`. logr is one such
alternative API, with interoperability as [described
below](#slog-interoperability).

### Inspiration

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

## slog interoperability

Interoperability goes both ways, using the `logr.Logger` API with a `slog.Handler`
and using the `slog.Logger` API with a `logr.LogSink`. logr provides `ToSlog` and
`FromSlog` API calls to convert between a `logr.Logger` and a `slog.Logger`. Because
the `slog.Logger` API is optional, there are also variants of these calls which
work directly with a `slog.Handler`.

Ideally, the backend should support both logr and slog. In that case, log calls
can go from the high-level API to the backend with no intermediate glue
code. Because of a conflict in the parameters of the common Enabled method, it
is [not possible to implement both interfaces in the same
type](https://github.com/golang/go/issues/59110). A second type and methods for
converting from one type to the other are needed. Here is an example:

```
// logSink implements logr.LogSink and logr.SlogImplementor.
type logSink struct { ... }

func (l *logSink) Enabled(lvl int) bool { ... }
...

// logHandler implements slog.Handler.
type logHandler logSink

func (l *logHandler) Enabled(ctx context.Context, slog.Level) bool { ... }
...

// Explicit support for converting between the two types is needed by logr
// because it cannot do type assertions.

func (l *logSink) GetSlogHandler() slog.Handler { return (*logHandler)(l) }
func (l *logHandler) GetLogrLogSink() logr.LogSink { return (*logSink)(l) }
```

Such a backend also should support values that implement specific interfaces
from both packages for logging (`logr.Marshaler`, `slog.LogValuer`). logr does not
convert between those.

If a backend only supports `logr.LogSink`, then `ToSlog` uses
[`slogHandler`](sloghandler.go) to implement the `logr.Handler` on top of that
`logr.LogSink`. This solution is problematic because there is no way to log the
correct call site. All log entries with `slog.Level` >= `slog.LevelInfo` (= 0)
and < `slog.LevelError` get logged as info message with logr level 0, >=
`slog.LevelError` as error message and negative levels as debug messages with
negated level (i.e. `slog.LevelDebug` = -4 becomes
`V(4).Info`). `slog.LogValuer` will not get used. Applications which care about
these aspects should switch to a logr implementation which supports slog.

If a backend only supports slog.Handler, then `FromSlog` uses
[`slogSink`](slogsink.go). This solution is more viable because call sites can
be logged correctly. However, `logr.Marshaler` will not get used. Types that
support `logr.Marshaler` should also support
`slog.LogValuer`. `logr.Logger.Error` logs with `slog.ErrorLevel`,
`logr.Logger.Info` with the negated level (i.e. `V(0).Info` uses `slog.Level` 0
= `slog.InfoLevel`, `V(4).Info` uses `slog.Level` -4 = `slog.DebugLevel`).

## FAQ

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

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

#### How do I choose my keys?

Expand Down
81 changes: 81 additions & 0 deletions slog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//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 (
"log/slog"
)

// SlogImplementor is an interface that a logr.LogSink can implement
// to support efficient logging through the slog.Logger API.
type SlogImplementor interface {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a hard time really grasping why we want this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See other comments ("avoid glue code").

Copy link
Contributor

@thockin thockin Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we maybe do this as 2 (or more) PRs?

  1. Add trvially obvious bridge support with known gotchas.
  2. Make changes to logr to make cotchas less.
  3. Add optional interface indicating direct support for slog.
  4. Context mania

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can definitely start with step 1.

Do you want to do that here on some experimental branch or in a separate repo? I'm leaning towards doing it here, in the main package, because I expect that we'll need that eventually. We just shouldn't merge it into "master" yet.

I'm less sure about the order of the next two steps. Solving some of these issues will be hard and take time. The end result will still be less efficient. It would be simpler to do step 3 first and enhance klog and zapr. Then Kubernetes will be ready to support packages using slog as API much sooner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just saw your comment below about context handling with the key defined in an internal package. That should work, so let me take back the "in the main package": let's start in this repo with "slogr" as package?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm OK to skip step 2 if we decide step 3 is the better answer.

This branch seems fine, and we can commit things and then revise, as long as we don't tag a release yet.

WRT which package - logr.ToSlog and logr.FromSlog are pleasant. I think a slogr package is better if the API is really parallel to funcr, zapr, etc - slogr.New(slog.Handler).

Maybe step 1 is to evaluate that difference.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of rewriting the entire branch for this PR, I created a new one for step 1: #205

// GetSlogHandler returns a handler which uses the same settings as the logr.LogSink.
GetSlogHandler() slog.Handler
}

// LogrImplementor is an interface that a slog.Handler can implement
// to support efficient logging through the logr.Logger API.
type LogrImplementor interface {
// GetLogrLogSink returns a sink which uses the same settings as the slog.Handler.
GetLogrLogSink() LogSink
}

// ToSlog returns a slog.Logger which writes to the same backend as the logr.Logger.
func ToSlog(logger Logger) *slog.Logger {
return slog.New(ToSlogHandler(logger))
}

// ToSlog returns a slog.Handler which writes to the same backend as the logr.Logger.
func ToSlogHandler(logger Logger) slog.Handler {
if slogImplementor, ok := logger.GetSink().(SlogImplementor); ok {
handler := slogImplementor.GetSlogHandler()
return handler
}

return &slogHandler{sink: logger.GetSink()}
}

// FromSlog returns a logr.Logger which writes to the same backend as the slog.Logger.
func FromSlog(logger *slog.Logger) Logger {
return FromSlogHandler(logger.Handler())
}

// FromSlog returns a logr.Logger which writes to the same backend as the slog.Handler.
func FromSlogHandler(handler slog.Handler) Logger {
if logrImplementor, ok := handler.(LogrImplementor); ok {
logSink := logrImplementor.GetLogrLogSink()
return New(logSink)
}

return New(&slogSink{handler: handler})
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good if FromSlog(ToSlog(...)) and vice versa returned the original logger. This currently works when the backend supports both APIs. But if not, we end up layering slogSink on top of slogHandler or vice versa.

Can be fixed with the "underlier" pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I skipped over one detail: logr.Logger.level gets lost during conversion.

Suppose someone does this:

func foo(logger logr.Logger) {
    // The Info call could be in some other library which only uses slog.
    logr.ToSlog(logger).Info("hello")
}

func bar(logger logr.Logger) {
  foo(logger.V(4))
}

Should that info message then become a debug message (info - 4 = debug in slog)? I think the answer is yes.

This can be done in the slogHandler or by wrapping a real slog.Handler (modify record), but it makes the conversion code more complex because logr.Logger.level needs to be restored when going back from such a slog.Handler to a logr.Logger (FromSlog(ToSlog(logger.V(4)) == logger.V(4)).


func levelFromSlog(level slog.Level) int {
if level >= 0 {
// logr has no level lower than 0, so we have to truncate.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logr docs say:

"Negative V-levels have the same meaning as V(0)."

so you don't need to worry about this (minor point)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point I want to discuss with you whether V(-1) should be supported, though.

It think it is a valid use case to say "I want more output when calling this code here", which is someCode(logger.V(-1)). Right now we only support "I want less output": someCode(logger.V(1)). Ironically, the latter use case is the one that Jordan was concerned about because when seeing logger.V(5).Info in someCode it's not obvious that running the binary with -v=5 won't produce that output. That's not a problem with someCode(logger.V(-1)) and it was a use case that scheduler folks wanted ("produce more output when scheduling a specific pod").

That slog supports "more important than INFO" (= slog.LevelWarn = 4) and "less important than INFO" (= slog.LevelDebug = -4) while logr only supports "less important than INFO (= V(4)) is another aspect to consider. I understand that this was intentional, but it keeps coming up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not heard that argument, and I am skeptical of it.

Every package is the center of their own universe, and thinks they know what's important. logr V(0) means "always log", unless somebody upstream from you has decided that you are less important than you think you are. I'm not sure we should be giving callsites the ability to override that.

I don't object to leaving this, other than "less is more", but simply negating the level seems correct-ish anyway. If you somehow had a V(-1) logr call it would become INFO+1 in slog.

Anyway, that's orthogonal to this PR. I'd prefer we strip this one of anything speculative, get the bare basics merged, and then see how to do better

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I got carried away when you mentioned negative levels 😅

return 0
}
return int(-level)
}

func levelToSlog(level int) slog.Level {
// logr starts at info = 0 and higher values go towards debugging (negative in slog).
return slog.Level(-level)
}
72 changes: 72 additions & 0 deletions slog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//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 ExampleFromSlog() {
logger := logr.FromSlog(slog.New(slog.NewTextHandler(os.Stdout, debugWithoutTime)))

logger.Info("hello world")
logger.Error(errors.New("fake error"), "ignore me")
logger.WithValues("x", 1, "y", 2).WithValues("str", "abc").WithName("foo").WithName("bar").V(4).Info("with values, verbosity and name")

// Output:
// level=INFO msg="hello world"
// level=ERROR msg="ignore me" err="fake error"
// level=DEBUG msg="foo/bar: with values, verbosity and name" x=1 y=2 str=abc
}

func ExampleToSlog() {
logger := logr.ToSlog(funcr.New(func(prefix, args string) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about adding slog support to funcr, but decided against it because:

  • here I want to test the code in slogHandler, which wouldn't be used if funcr itself supported slog
  • the handlers that come with slog provide similar functionality, so I don't see the need

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, klog and zapr definitely should get extended. I've started doing that for zapr, using the same interface for conversion that I am proposing here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For zapr see go-logr/zapr#60

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't object to impl libraries supporting both logr and slog, but I don't know why it's logr's responsibility to participate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because logr.ToSlog and log.FromSlog are using it to avoid glue code. slogr.New and slogr.NewHandler (your PR) don't, but I think they should if we go with that API.

if prefix != "" {
fmt.Fprintln(os.Stdout, prefix, args)
} else {
fmt.Fprintln(os.Stdout, args)
}
}, funcr.Options{}))

logger.Info("hello world")
logger.Error("ignore me", "err", errors.New("fake error"))
logger.With("x", 1, "y", 2).WithGroup("group").With("str", "abc").Warn("with values and group")

// 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"
}
71 changes: 71 additions & 0 deletions sloghandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//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"
)

type slogHandler struct {
sink LogSink
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my impl chose to store the Logger rather than the LogSink. I'm not sure it's better, but I am curious why you chose Sink? I chose Logger because it felt easier. (FWIW, my impl of logr->slog first held a slog.Logger, but apparently that's not what slog wants us to do).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly for symmetry: we are layering some high-level API on top of some low-level API. Doing the same here with LogSink as in slogSink with slog.Handler seemed appropriate.

I chose Logger because it felt easier.

Is it really? Using l.sink.Enabled/Info/Error below is trivial and avoids calling code that doesn't add any value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logr.Logger is not a no-op, though.

logrLogger := logr.Discard()  // nil LogSink
slogLogger := logr.ToSlog()
slogLogger.Info() // -> panic (I think?)

Similarly, Logger.WithCallDepth() does the LogSink interface test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, I need to handle a nil sink. I wasn't trying to do anything with call stack unwinding, so I didn't need that.

groupPrefix string
}

func (l *slogHandler) Enabled(ctx context.Context, level slog.Level) bool {
return l.sink.Enabled(levelFromSlog(level))
}

func (l *slogHandler) Handle(ctx context.Context, record slog.Record) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all of these, ctx is unusued, so should be named _ ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to document somewhere that the passed-in timestamp is ignored, and any logr.LogSink which logs the time is going to produce its own timestamp.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README has a list of drawbacks that come from using glue code for "logr on slog" - this is another one which I need to add.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere we need to handle caller info. My PR gets it right by hardcoding a depth, which I don't think slog will want to guarantee.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need to call Callers() again and walk back until I find the PC they gave us, and add that many frames to the logr depth?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My PR gets it right by hardcoding a depth, which I don't think slog will want to guarantee.

Only if the code using the handler is using slog.Logger. We can't assume that, the code might also use something else with different offsets.

Maybe we need to call Callers() again and walk back until I find the PC they gave us, and add that many frames to the logr depth?

Urgh. That probably works in most usages, but not when the record was produced in one goroutine and then gets logged somewhere else entirely.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kvList := make([]any, 0, 2*record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool {
kvList = append(kvList, appendPrefix(l.groupPrefix, attr.Key), attr.Value.Any())
return true
})
if record.Level >= slog.LevelError {
l.sink.Error(nil, record.Message, kvList...)
} else {
l.sink.Info(levelFromSlog(record.Level), record.Message, kvList...)
}
return nil
}

func (l slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
kvList := make([]any, 0, 2*len(attrs))
for _, attr := range attrs {
kvList = append(kvList, appendPrefix(l.groupPrefix, attr.Key), attr.Value.Any())
}
l.sink = l.sink.WithValues(kvList...)
return &l
}

func (l slogHandler) WithGroup(name string) slog.Handler {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see now what this is, and I am less enamored with it. I thought it was to accumulate Attrs into a "pseudo-struct" which would then fold into the parent Handler, but I see that's not the case.

e.g.

log := getMySlogger()
log.WithGroup("grp").WithAttrs(slog.Int("a", 1), slog.Int("b", 2))
log.Info("The message", "arg", 31415)

=> {"msg": "The message", "grp": { "a": 1, "b": 2 }, "arg": 31415}`

But that's not it. then I thought it would at least assemble a struct and log it as such:

log := getMySlogger()
log.WithGroup("grp").LogAttrs(lvl, "The message", slog.Int("a", 1), slog.Int("b", 2))

=> {"msg": "The message", "grp": { "a": 1, "b": 2 }}`

But that doesn't seem right either, unless this adapter caches all the WithAttrs and then sends them all to logr when it is actually logged (which will break things like funcr's hooks).

So a key-prefix seems about as good as we can get. That said, using a period as the delimiter means we get JSON like { "the.key1": 1, "the.key2": 2} which is strictly VALID but seems to break jq:

$ echo '{ "a": "aval", "b.key": 1, "b.val": 2}' | jq .
{
  "a": "aval",
  "b.key": 1,
  "b.val": 2
}

$ echo '{ "a": "aval", "b.key": 1, "b.val": 2}' | jq .a
"aval"

$ echo '{ "a": "aval", "b.key": 1, "b.val": 2}' | jq .b
null

$ echo '{ "a": "aval", "b.key": 1, "b.val": 2}' | jq .b.key
null

 echo '{ "a": "aval", "b.key": 1, "b.val": 2}' | jq '.["b.key"]'
1

I didn't find any other "special" character that doesn't also break jq, so either we use something like underscore or we just document this oddity. Or we let users pass in the group-delimiter string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it would at least assemble a struct and log it as such

That's indeed what the slog.JSONLogger does:

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logger.WithGroup("group").With("hello", "world").Info("ping")
}

=>

{"time":"2023-08-04T08:33:43.56963767+02:00","level":"INFO","msg":"ping","group":{"hello":"world"}}

When wrapping zapr (the current one without slog support), I get instead:

{"level":"info","msg":"ping","group.hello":"world"}

With slog support there is at least a chance to implement WithGroup properly - but I haven't tried that yet (open TODO).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once I found https://github.com/uber-go/zap/blob/v1.25.0/field.go#L334 it was trivial.

Now I get:

{"level":"info","msg":"ping","group":{"hello":"world"}}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does that intersect with WithValues() ?

E.g.:

s := logr.ToSlog(myLogger)
s.With("a", 1)  // calls Handler.WithAttrs(), which calls logr WithValues("a", 1)
s = s.WithGroup("g")
s.With("b", 2)  // calls Handler.WithAttrs(), which calls logr WithValues("g.b", 2)
s.Info("msg", "c", 3) // calls Handler.Handle, which calls logr.Info("msg", "g.c", 3)

The only options I see are

  1. Live with output "g.b": 2, "g.c": 3

  2. don't use logrHandler under slog, use native slog support

  3. accumulate calls to WithAttr(), so it becomes:

s := logr.ToSlog(myLogger)
s.With("a", 1)  // calls Handler.WithAttrs(), which calls logr WithValues("a", 1)
s = s.WithGroup("g")
s.With("b", 2)  // calls Handler.WithAttrs(), which saves "b"=2
s.Info("msg", "c", 3) // calls Handler.Handle, which calls logr.Info("msg", "g", map[string]any{"b": 2, "c": 3})
  1. teach logr to have a "group" construct.

I'm interested in this approach, but I think we can accept (0) until we do that. Fair?

#199

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

l.groupPrefix = appendPrefix(l.groupPrefix, name)
return &l
}

func appendPrefix(prefix, name string) string {
if prefix == "" {
return name
}
return prefix + "." + name
}

var _ slog.Handler = &slogHandler{}
Loading
Loading