Playing With Slog, the Proposed Structured Logging Package for the Go Standard Library
Posted on Thursday, 24th November 2022
For a while now I’ve been keeping a close eye on the structure logging proposal making its way through Go’s language proposal process. Last month an experimental version of the proposal was released under the golang.org/x/exp
package (see documentation here) and I decided to experiment with it and decided to summarise my learnings in a blog for others to learn more about this proposed addition to the Go standard library.
What is the Structured Logging Proposal?
From a high level (see the proposal itself for a more in-depth look) the proposal aims to address the current gap that exists in Go’s standard library by adding structured logging with levels, residing in a new package with the import path log/slog
.
First, what is Structured Logging?
Logs have traditionally been aimed at being human readable. These days it’s common to want to index them to enable better filtering and searching. This can be performed by formatting the log output in a way that makes them machine-readable. This process is called structured logging.
For example, if you’ve used Go’s existing log package then the following code would output a log that looks like the output below:
log.Printf("processed %d items in %s", 23, time.Since(time.Now()))
// Not structured, boo :(
2022/11/10 02:53:02 processed 23 items in 83ns
Its lack of structure makes parsing the output difficult and potentially costly. A better, structured alternative would be the following which adds keys and associated values to the output making parsing it programmatically far easier and more reliable.
// Structured!
time=2022/11/10 02:53:02 msg="processed item" size=23 duration=83ns level=info
Now the logs have a structure associated with them, it’s much easier to encode them as other data formats such as JSON:
{"time":"2022-11-13T03:38:37.737821Z","level":"INFO","msg":"processed items","size":23,"duration":83}
Providing a general interface for logging in Go
A wide variety of structured logging packages exist within the Go ecosystem (Logrus, Zap and Zerolog to name a few), however this diverse API landscape can make supporting logging in a provider agnostic way challenging, often requiring abstractions to avoid coupling your implementation to any given logging package. This proposal would help alleviate this pain by providing a common interface in the standard library that service owners of package authors alike could depend on.
That’s enough talk for now, let’s take a look at the proposed package in a bit more depth.
Logging via the slog
package
A Bare Bones Example
Following on from the items processed logging theme above (which is common through examples in the proposal) the slog
proposal aims to introduce the following API. We’ll focus on the setup at first and dive into the log writing part later.
package main
import (
"os"
"time"
"golang.org/x/exp/slog"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout))
logger.Info("processed items",
"size", 23,
"duration", time.Since(time.Now()))
}
// time=2022-11-10T03:56:11.768Z level=INFO msg="processed items" size=23 duration=42ns
As demonstrated, upon creating a new instance of the slog
logger you can pass a type of Handler
; an interface whose responsibility is to handle log records produced by a Logger
struct type (returned when calling slog.New(...)
). The Handler
interface is a key piece of the extensibility enabled by the slog
package and one we’ll touch towards the end of this post.
In this instance we’ve used the TextHandler
for writing the log output as structured text, but a JSONHandler
also exists for formatting your structured logs to JSON.
...
logger := slog.New(slog.NewJSONHandler(os.Stdout))
logger.Info("processed items",
"size", 23,
"duration", time.Since(time.Now()))
// {"time":"2022-11-13T04:08:19.805393Z","level":"INFO","msg":"processed items","size":23,"duration":84}
At this point it’s also valuable to highlight that you can set the logger as the default logger via the SetDefault
method call. This will do two things, set the logger as the default when using the slog
package. For instance, both of these would output the same log statements:
...
func main() {
logger := slog.New(&LogrusHandler{
logger: logrus.StandardLogger(),
})
slog.SetDefault(logger)
logger.Error("something went wrong", net.ErrClosed,
"status", http.StatusInternalServerError)
slog.Error("something went wrong", net.ErrClosed,
"status", http.StatusInternalServerError)
}
// {"err":"use of closed network connection","level":"error","msg":"something went wrong","status":500,"time":"2022-11-24T22:31:06Z"}
Secondly, using slog.SetDefault()
will also set the logger as the default logger for the log
package. So function calls such as the following will log the expected output:
...
func main() {
logger := slog.New(&LogrusHandler{
logger: logrus.StandardLogger(),
})
slog.SetDefault(logger)
log.Print("something went wrong")
log.Fatalln("something went wrong")
}
//{"level":"info","msg":"something went wrong","time":"2022-11-24T22:33:57Z"}
// {"level":"info","msg":"something went wrong","time":"2022-11-24T22:33:57Z"}
// exit status 1
Logging
Logging via the slog
package can be performed in one of two ways, either via the loosely typed API seen above (designed for brevity at the sacrifice of additional allocations), or via a strongly typed but more performance focused alternative seen below, both approaches output the log in the same format.
...
// designed for brevity at the sacrifice of additional allocations
logger.Info("processed items",
"size", 23,
"duration", time.Since(time.Now()),
// designed for performance
logger.LogAttrs(slog.InfoLevel, "processed items",
slog.Int("size", 23),
slog.Duration("duration", time.Since(time.Now())),
Storing and fetching via context.Context
The slog
logger also provides an API to enable you to store and fetch the logger via a context
. Note that on the below example, FromContext
will actually return a default logger if none exists within the context.
...
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout))
ctx := context.Background()
// save logger to context.Context
ctx = slog.NewContext(ctx, logger)
// retrieve logger from context after propagating it throughout your API,
// If no logger exists then a default logger is returned
logger = slog.FromContext(ctx)
logger.Info("processed items",
"size", 23,
"duration", time.Since(time.Now()),
)
}
Whilst propagating objects via a context
is somewhat of a contentious point amongst Go developers (and one questioned and discussed on the initial proposal), scoping a logger to a request is a common pattern when logging and as one user commented, advocated within the OTEL (OpenTelemetry) specification, so personally I don’t have an issue with it in this case.
It becomes especially powerful when using the With
function (see docs) to attach request level attributes to the returned logger before propagating it further down the call stack like so:
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout))
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
// subsequent log entries within this context will contain these fields
l := logger.
With("path", r.URL.Path).
With("user-agent", r.UserAgent())
// propagate the logger with the above fields via the request context
ctx := slog.NewContext(r.Context(), l)
handleRequest(w, r.WithContext(ctx))
})
http.ListenAndServe(":8080", nil)
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
l := slog.FromContext(r.Context())
l.Info("handling request",
"status", http.StatusOK)
w.Write([]byte("Hello World"))
}
The above example prints the following log output, allowing you to attach the necessary fields to all log lines. This could include a request/trace ID or any other vital request level information.
{
"time":"2022-11-16T00:48:43.169545Z",
"level":"INFO",
"msg":"handling request",
"path":"/hello",
"user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
"status":200
}
Log Handler
At the beginning of this post we looked at how the logger takes an initial dependency on a slog.Handler
, for instance when creating a new TextHandler
. Let’s spend some time looking at what a handler is and how it provides extensibility to the slog
logger.
The Handler
is an interface with four methods:
type Handler interface {
Enabled(Level) bool
Handle(r Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
Other than the Enabled(Level)
method (which is used to report whether the logger supports the expected log level), the to most interesting method is Handle(Record)
, which gets invoked every time a log entry is written to the logger and receives an instance of Record
as an argument, which provides all the relevant information of the particular log entry.
By implementing this interface we can create a logger that performs tasks such as batching log entries to send over HTTP, or create an adapter to integrate an existing logger package with slog, so we can make our application or package logging library agnostic.
Building a Logrus adapter for the slog
logger
Let’s take a look at what that might look like as we create a slog
handler that integrates with Logrus
, a popular open source logging library. It’s not production ready but hopefully it gives you an idea of what a custom handler implementation would look like:
type LogrusHandler struct {
logger logrus.Logger
}
func (h *LogrusHandler) Enabled(_ slog.Level) bool {
// support all logging levels
return true
}
func (h *LogrusHandler) Handle(rec slog.Record) error {
fields := make(map[string]interface{}, rec.NumAttrs())
rec.Attrs(func(a slog.Attr) {
fields[a.Key] = a.Value.Any()
})
entry := h.logger.WithFields(fields)
switch rec.Level {
case slog.DebugLevel:
entry.Debug(rec.Message)
case slog.InfoLevel.Level():
entry.Info(rec.Message)
case slog.WarnLevel:
entry.Warn(rec.Message)
case slog.ErrorLevel:
entry.Error(rec.Message)
}
return nil
}
func (h *LogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// not implemented for brevity
return h
}
func (h *LogrusHandler) WithGroup(name string) slog.Handler {
// not implemented for brevity
return h
}
And to use the slog
handler we can do something like the following:
package main
import (
"net"
"net/http"
"os"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slog"
)
func init() {
// set up logrus
logrus.SetFormatter(&logrus.JSONFormatter{})
logrus.SetOutput(os.Stdout)
logrus.SetLevel(logrus.DebugLevel)
}
func main() {
// integrate Logrus with the slog logger
logger := slog.New(&LogrusHandler{
logger: logrus.StandardLogger(),
})
// start logging via the slog API
slog.Error("something went wrong", net.ErrClosed,
"status", http.StatusInternalServerError)
}
Now, whenever we log via the slog
logger, our log records will still be handled via Logrus but we’ll no longer need to depend on the Logrus API in our library and we’ll see the following output:
{"err":"use of closed network connection","level":"error","msg":"something went wrong","status":500,"time":"2022-11-19T00:07:51Z"}
Conclusion
Hopefully this post has given you some insight into the slog
package proposal, its goals and its potentially. Personally I’m hoping the proposal gets accepted as it’d be great to have structured logging as a first-class citizen in the standard library, but only time will tell.
If you’re interested in learning more then be sure to take a look at the proposal over here and the further discussions here.
Enjoy this post? Don't be a stranger!
Follow me on Twitter at @_josephwoodward and say Hi! I love to learn in the open, meet others in the community and talk Go, software engineering and distributed systems related topics.