Logging in Go with Slog: A Practitioner's Guide
dash0.com78 points by ayoisaiah 5 days ago
78 points by ayoisaiah 5 days ago
It's fine for application logging but I have two gripes with slog:
1) If you're writing a library that can be used by many different applications and want to emit logs, you'll still need to write a generic log interface with adapters for slog, zap, charmlog, etc. That the golang team refuses to bless a single interface for everyone to settle on both makes sense given their ideological standpoint on shipping interfaces and also causes endless mild annoyance and code duplication.
2) I believe it's still impossible to see the correct callsite in test logs when using slog as the logger. For more information, see https://github.com/neilotoole/slogt?tab=readme-ov-file#defic.... It's possible I'm out of date here — please correct me if this is wrong, it's actually a much larger annoyance for me and one of the reasons I still use uber/zap or charmbracelet/log.
Overall, especially given that it performs worse than uber/zap and everyone has basically standardized on that and it provides essentially the same interface, I recommend using uber/zap instead.
EDIT: just to expand further, take a look at the recommended method of wrapping helper methods that call logs. Compare to the `t.Helper()` approach. And some previous discussion. Frustrating!
- https://pkg.go.dev/log/slog#example-package-Wrapping
- https://github.com/golang/go/issues/59145#issuecomment-14770...
The blessed interface for libraries is to accept a slog.Logger.
The blessed interface for logging backends is slog.Handler.
Applications can then wire that up with a handler they like, for example
zap: https://pkg.go.dev/go.uber.org/zap/exp/zapslog#Handler
charm https://github.com/charmbracelet/log?tab=readme-ov-file#slog...
I used slog.Logger for an OSS project and I will not do it again. The interface is terrible, and far more verbose and less expressive than something like Zap or zerolog. e.g. there's not really anything as good as zerolog's log.Dict() for dealing with complex structures.
1) The idea is that your library should accept the slog logger and use it. The caller would create a logger with a handler that defines how log messages are handled. But there are problems with supported types; see my other comments.
2) It is improved in 1.25. See https://github.com/golang/go/issues/59928 and https://pkg.go.dev/testing#T.Output. Now it is possible to update slogt to provide correct callsite – the stack depth should be the same.
1) Right, but this is complicated and annoying. Imagine a world where you could just pass your existing logger in, because my library references an interface like `stdlib/logging.GenericLoggerInterface` and slog, zap, zerolog, etc. all implement that! Would be nice!
2) TIL about `T.Output`, thank you, that's great to know about. Still annoying and would be nice if the slog package showed an example of logging from tests with correct callsites. Golang gets so many things right about testing, so the fact that logging in tests is difficult really stands out and bothers me.
> That the golang team refuses to bless a single interface for everyone to settle on
Uh... https://pkg.go.dev/golang.org/x/exp/slog#Handler
If zap, charmlog, etc. don't provide conformance to the interface, that's not really on the Go team. It wouldn't be that hard to write your own adapter around your unidiomatic logger of choice if you're really stuck, though. This isn't an actual problem unless you think someone else owes you free labor for some reason.
That's close, but not what I meant — that's specific to this package, and is the interface for processing log records produced by a slog.Logger. What I mean is that there should be a single interface for Logging that is implemented by slog.Logger, uber/zap.Logger, etc. that library authors can use without needing to reinvent the wheel every time.
For an example from one of my own libraries, see
https://github.com/peterldowns/pgmigrate/blob/d3ecf8e4e8af87...
> What I mean is that there should be a single interface for Logging that is implemented by slog.Logger, uber/zap.Logger, etc.
There is: https://pkg.go.dev/golang.org/x/exp/slog#Handler
If, say, zap was conformant, you'd slog.New(zap.NewHandler()) or whatever and away you go. It seems the only problem here is that the logging packages you want to use are not following the blessed, idiomatic path.
> For an example from one of my own libraries
There are a lot of problem with that approach at scale. That might not matter for your pet projects, but slog also has to serve those who are pushing computers to their limits. Your idea didn't escape anyone.
I know it didn't escape anyone; I'm explaining the downside to the choices made by the stdlib authors, from my perspective. When performance is a concern, people pick uber/zap.Logger or zerolog. When performance isn't a huge concern, slog is overly complicated and annoying. I believe you understand my complaint.
> I believe you understand my complaint.
I don't, really. If performance is of utmost concern, you're not going to accept the overhead of passing the logger through an interface anyway, so that's moot. A library concerned about performance as a top priority has to pick one and only one.
But if a library has decided that flexibility is more important than raw efficiency, then the interface is already defined.
zaplogger.Info("Calling third-party library")
thirdparty.Call(slog.New(zaplogger)) // Logs whatever the package logs to zaplogger
zaplogger.Info("Called third-party library")
The only 'problem' I can see is if `zaplogger` hasn't implemented the interface. But there isn't much the Go team can do about implementations not playing nicely.Your library should just take a *slog.Logger, and using *slog.Logger is an orthogonal choice to zap/zerolog/whatever. Those compete with slog.TextHandler or slog.JSONHandler, and sure, if you’re performance sensitive don’t pick them. In my newer projects I use zap under the hood with an application-facing *slog.Logger through go.uber.org/zap/exp/zapslog just fine (actually further locked down with my own interface so that coworkers can’t go crazy, but that’s beside the point). Your bespoke interface, or that standard interface you want isn’t going to be any more performant than going through slog.Handler interface anyway.
The thing that gets me about slog is that the output key for the slog JSON handler is msg, but that's not compatible with Googles own GCP Stackdriver logging. Since that key is a constant I now need to use an attribute replacer to change it from msg to message (or whatever it is stackdriver wants). Good work Google.
We had the same annoyance, and wrote https://pkg.go.dev/github.com/chainguard-dev/clog/gcp to bridge the gap.
It's a slog handler that formats everything the way GCP wants, including with trace contexts, etc.
We've had this in production for months, and it's been pretty great.
You can add this at your main.go
import _ "github.com/chainguard-dev/clog/gcp/init"
(the rest of the library is about attaching a logger to a context.Context, but you don't need to use that to use the GCP logger)My biggest gripe with slog is that there is no clear guidance on supported types of attributes.
One could argue that supported types are the ones provided by Attr "construct" functions (like slog.String, slog.Duration, etc), but it is not enough. For example, there is no function for int32 – does it mean it is not supported? Then there is slog.Any and some support in some handlers for error and fmt.Stringer interfaces. The end result is a bit of a mess.
All values are supported.
Well, is fmt.Stringer supported? The result might surprise you:
req := expvar.NewInt("requests")
req.Add(1)
attr := slog.Any("requests", req)
slog.New(slog.NewTextHandler(os.Stderr, nil)).Info("text", attr)
slog.New(slog.NewJSONHandler(os.Stderr, nil)).Info("json", attr)
This code produces time=2025-09-12T13:15:42.125+02:00 level=INFO msg=text requests=1
{"time":"2025-09-12T13:15:42.125555+02:00","level":"INFO","msg":"json","requests":{}}
So the code that uses slog but does not know what handler will be used can't rely on it lazily calling the `String() string` method: half of the standard handlers do that, half don't.If you need more control, you can create a wrapper type that implements `slog.LogValuer`
type StringerValue struct {
fmt.Stringer
}
func (v StringerValue) LogValue() slog.Value {
return slog.StringValue(v.String())
}
Usage example: slog.Any("requests", StringerValue{req})
There might be a case for making the expvar types implement `slog.LogValuer` directly.So clearly not all values are supported.
And I know that I can create a wrapper for unsupported types. My problem is exactly that – I don't know what types are supported. Is error supported, for example? Should I create a wrapper for it? And, as a handler author, should I support it directly or not?
Not sure what your definition of "supported" is, but I'm afraid you're going to have to bite the bullet and ... gasp ... read the documentation https://pkg.go.dev/log/slog
Not sure I understand your sarcasm. I read the documentation, source code, handler writing guide, and issues in the Go repository multiple times over two years, and I use slog extensively. Go is my primary language since r60. I think I know how to read Go docs.
Now, please point me to the place in the documentation that says if I can or can't use a value implementing the error interface as an attribute value, and will the handler or something else would call the `Error() string` method.
My definition of "supported" is simple – I could pass a supported value to the logger and get a reasonable representation from any handler. In my example, the JSON handler does not provide it for the fmt.Stringer.
https://pkg.go.dev/log/slog#JSONHandler.Handle
> Values are formatted as with an encoding/json.Encoder with SetEscapeHTML(false), with two exceptions.
> First, an Attr whose Value is of type error is formatted as a string, by calling its Error method. Only errors in Attrs receive this special treatment, not errors embedded in structs, slices, maps or other data structures that are processed by the encoding/json package.
So the json handler more or less works as if you called json.Marshal, which sounds pretty reasonable.
I think you missed the “any handler” part. Currently, the types that my library package could use depend on the handler used by the caller. This limits types to an unspecified subset, making things quite impractical.
That seems to work as expected?
The output of data is handled by the handler. Such behaviour is clearly outlined in the documentation by the JSONHandler. I wouldn't expect a JSONHandler to use Stringer. I'd expect it to use the existing JSON interfaces, which it does.
I'd expect the Text handler to use TextMarshaller. Which it does. Or Stringer, which it does implicitly via fmt.Sprintf.
My problem with that is that it makes it impossible to use slog logger safely without knowing what handler is being used. Which kind of defeats the purpose of defining the common structured logging interface.
> Which kind of defeats the purpose of defining the common structured logging interface.
Does it, though? Why would the log producer care about how the log entires are formatted? Only the log consumer cares about that.
As a producer of the response, if I didn't care about being understood, I would use a made-up language. As a consumer, you may care about understanding my response, but you cannot do anything about it.
https://www.bls.gov/cpi/questions-and-answers.htm
CPI is price quotes, youve gone noticably silent on the matter, why?