Understanding Go's slog Package

ยท

7 min read

Go's log/slog standard library package provides everything you need to get started with structured logging. It has been thoughtfully designed to support both library authors and program authors. The first article of this series was intended to show the need for structured logging. Let us now look into the log/slog package to understand how it works.

Structure of the log/slog package

There are four main conceptual components that form the structure of the log/slog package.

  1. The Logger

  2. Levels

  3. Attributes

  4. Handlers

The package exposes other types and functions in its API, but they all relate to these four.

The Logger

You interact with the Logger the most. It is the struct that defines the logging methods: slog.Debug, slog.Error, slog.Info, and slog.Warn. You call those methods to log values in your program. Each of those methods have a *Context variant (like slog.DebugContext, slog.InfoContext, etc) that accepts a context.Context value (will be important later).

The Logger is also hierarchical. You program can have a shared logger and override certain aspects by deriving a new logger from it. For example, suppose you want to name your loggers for different parts of your program. Logger.WithGroup(groupName) will give you a derived logger that qualifies all keys logged through it with groupName (view example in the playground).

logger := slog.Default().WithGroup("request")
logger.Info("Hello, world!", "username", "friendly_stranger")
// 2009/11/10 23:00:00 INFO Hello, world! request.username=friendly_stranger

The above example derived a logger that qualifies every attribute (or key) with "request". But you may want to only name the logger by attaching an attribute to it. You can do that using Logger.With (view example in the playground)

logger := slog.Default().With("logger", "Tasks Logger")
logger.Info("Starting new background task.", "task_name", "Drink water reminder")
// 2009/11/10 23:00:00 INFO Starting new background task. logger="Tasks Logger" task_name="Drink water reminder"

Here, we defined a logger that has a name attribute with a value of "Tasks Logger". It can be passed to our imaginary job scheduler that runs jobs like reminding us to drink water. Hey, drink some water.

That is almost all there is to using the Logger. The slog package exports logging functions that use a global logger (what slog.Default gave us). We can also create our own logger by calling slog.New and passing a Handler. We can change the global logger for our program by calling slog.SetDefault (never do this in a library, please ๐Ÿ˜…).

๐Ÿ’ก
If you have old programs that use the older log package, you may be interested in slog.NewLogLogger. It is a bridge API that lets you keep using the older log package as you gradually migrate to structured logging. Now, let's go the next level.

Level

Log levels are how we separate log output by their significance. Error logs call our attention immediately; reading debug logs is like listening to a child narrate their first day at school, but without the natural cuteness. slog defines four primary log levels: DEBUG, INFO, WARN, and ERROR. This is the definition from the documentation.

const (
    LevelDebug Level = -4
    LevelInfo  Level = 0
    LevelWarn  Level = 4
    LevelError Level = 8
)

This definition is important. The levels are defined as integers in increments of 4, starting at -4. This provides a good default without initialisation (LevelInfo = 0) and enough space for interoperability with other logging systems and custom logging levels (which we shall demonstrate in a future article). I encourage you to read the rationale for this design in the documentation for Level.

As you must have guessed, the logging functions and methods that we have seen so far are convenience functions. They are all defined in terms of Logger.Log (which lets you pass the level yourself).

The level is also important for building the logger. When you call slog.New, you pass a handler that does the actual formatting writing of the logs. The handler can be enabled at a log level and disabled at others. There are endless possibilities for customisation here. Again, future article.

Attributes

Attributes are contextual information that we use to enrich our logs. They provide additional context for the log message. Consider our first example above.

logger.Info("Hello, world!", "username", "friendly_stranger")
// 2009/11/10 23:00:00 INFO Hello, world! request.username=friendly_stranger

We called the Info method on the logger and passed three arguments. The first argument is the log message. This is the primary message that what we want to tell the reader of the logs. It should be enough to let them know what has happened. The other arguments are alternating pairs of string keys and severally typed values. Each key-value pair makes an attribute.

Attribute Tips

There are some tips and gotchas to watch out for the around attributes. The most common is unmatched key-value pairs. The others should not be needed often.

Unmatched key-value pairs

Always ensure that your attribute keys and values are paired up correctly. It is easy to make a mistake here. For example, if you swap their order, your log keys won't make sense. It is also easy to forget the key and provide the value only. Use attribute constructor functions (slog.Bool, slog.Int, etc) to avoid these mistakes. See https://pkg.go.dev/log/slog#Attr.

Lazy log values

Sometimes you do not need to compute a value if it will not be logged. Your program may call slog.Debug with an attribute, but the logger may be disabled at the debug level. You can use the techniques described under performance considerations to defer the computation. +1 for Haskell and laziness, I guess.

Grouped attributes

While demonstrating hierarchical loggers, I used logger.WithGroup to qualify all attributes for the given logger. This is usually not what you want. You can group specific attributes rather than all of them. For this, use slog.Group to construct grouped attributes.

As with levels, attributes are another hook point for customising the behaviour of the logger. Attributes are formatted and printed by handlers, so you may implement the LogValuer interface, use a custom Handler, or get creative with HandlerOptions.ReplaceAttr. And now, for an unhandled topic.

Handlers

Handlers are the executive component of the slog package, so to speak. The carry out the final business of formatting the log message & attributes and writing them to output streams. They are also the most extensible point for customisation because they implement a more powerful interface โ€” the slog.Handler interface. If you are familiar with the http.Handler interface, you can tell where this is going.

The Logger struct relies on the handler to actually write logs. That is why slog.New requires one. When we say that a logger is enabled or disabled at a log level, what we mean is that the underlying handler is enable or disabled at that level (see HandlerOptions). We want to build out a logging pipeline by composing handlers that carry out specific tasks on the pipeline, just as we compose http.Handler values to make HTTP middleware.

The slog package provides two basic handlers that should suffice for most simple use cases: slog.TextHandler (which logs in the logfmt format) and slog.JSONHandler (which logs in JSON format). You can go far with these alone, until you need to handler more. We will handle some more in the upcoming articles.

Putting it all together

We have seen the structure of the slog package. You now know how the basic parts fit together. Here is a small program to demonstrate what we have seen so far (run it on the playground).

package main

import (
    "log/slog"
    "os"
)

func main() {
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
    logger := slog.New(handler)

    logger.Debug("This will not be printed")
    logger.Info("This will be printed")

    taskLogger := logger.With("logger", "Tasks Logger")
    requestLogger := slog.With("logger", "Requests Logger")

    taskLogger.Info("Starting new background task.", "task_name", "Drink water reminder")
    requestLogger.Info("Incoming HTTP request",
        slog.Group("request", "route", "/hello", "operation", "greet", "remote_ip", "127.0.0.1", "scheme", "http"))
}
// > go run main
// {"time":"2009-11-10T23:00:00Z","level":"INFO","msg":"This will be printed"}
// {"time":"2009-11-10T23:00:00Z","level":"INFO","msg":"Starting new background task.","logger":"Tasks Logger","task_name":"Drink water reminder"}
// 2009/11/10 23:00:00 INFO Incoming HTTP request logger="Requests Logger" request.route=/hello request.operation=greet request.remote_ip=127.0.0.1 request.scheme=http
๐Ÿค”
Did you notice the bug in the slog package? The formatting is off for logs with grouped attributes.

Let's stop here and pick it up later, okay? If you like this one, please share it with someone else. Comments are welcome too, I'd love to read what you think. See you in the next one. It will be fun.