Contents

Standard lib structured logging in Go 1.21

Go 1.21 includes structured logging (with levels) as a standard library package.

In this post we look at the log/slog package, and what it provides.

TL;DR - here’s a package to make using log/slog easier: github.com/zknill/slogmw

Levels

log/slog comes with 4 levels:

1
2
3
4
slog.Debug("hello world")
slog.Info("hello world")
slog.Warn("hello world")
slog.Error("hello world")

The functions providing each level come with the same pair of exported method calls.

Here’s the example for Info:

1
2
func Info(msg string, args ...any)
func InfoContext(msg string, args ...any)

We will come back to the InfoContext function, but for now, let’s focus on the Info function.

Structured arguments

The basic calls to log/slog level functions are like this:

1
slog.Info("hello world", "user", 5)

By default, this log call produces the output:

1
2023/08/09 09:31:24 INFO hello world user=5

This is the default because the package level functions that we’ve seen (like Info, etc) use the “default” slog.Logger. We can change the format of the default logger to use a JSONHandler, which writes the log events as json objects.

1
2
3
4
5
6
7
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)

slog.Info("hello world", "user", 5)

// Output
{"time":"2023-08-09T09:39:05.234745912+01:00","level":"INFO","msg":"hello world","user":5}

Using HandlerOptions

HandlerOptions provides the main hooks we can use to modify the built in TextHander and JSONHandler. The Handlers provide the formatting, and decide which events should be printed based on the log level.

Setting the log level

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
opts := &slog.HandlerOptions{
    Level: slog.LevelWarn,
}

logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
slog.SetDefault(logger)

// Info log event is not printed
slog.Info("hello world", "user", 5)
slog.Warn("only this is printed")

// Output with modified time format:
// time=2023-08-09T11:04:08.527+01:00 level=WARN msg="only this is printed"

Set the time format

But the time format has changed in the JSON message, to change the time format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
opts := &slog.HandlerOptions{
    // Use the ReplaceAttr function on the handler options
    // to be able to replace any single attribute in the log output
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
         // check that we are handling the time key
         if a.Key != slog.TimeKey {
         	return a
         }
         
         t := a.Value.Time()
         
         // change the value from a time.Time to a String
         // where the string has the correct time format.
         a.Value = slog.StringValue(t.Format(time.DateTime))
         
         return a
    },
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
slog.SetDefault(logger)

slog.Info("hello world", "user", 5)

// Output with modified time format
{"time":"2023-08-09 09:54:46","level":"INFO","msg":"hello world","user":5}

Change the event keys

We can use the same ReplaceAttr pattern to change the keys in the log message:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
opts := &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
    	if a.Key != slog.MessageKey {
    		return a
    	}
    
    	// change the key from "msg" to "message"
    	a.Key = "message"
    
    	return a
    },
}

logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
slog.SetDefault(logger)

slog.Info("hello world", "user", 5)

// Output
time=2023-08-09T10:57:59.213+01:00 level=INFO message="hello world" user=5

Using nested log objects

log/slog provides a level of nesting of log fields with the slog.Group() method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
textLog := slog.New(slog.NewTextHandler(os.Stdout, nil))
jsonLog := slog.New(slog.NewJSONHandler(os.Stdout, nil))

slog.SetDefault(textLog)

slog.Info("outside the group",
	// The Group method takes the outer key, then kv pairs
	slog.Group("request",
		"method", "GET",
		"url", "http://zknill.io",
	),
)

slog.SetDefault(jsonLog)

slog.Info("outside the group",
  slog.Group("request",
  	"method", "GET",
  	"url", "http://zknill.io",
  ),
)

Output from text logger

1
time=... level=INFO msg="outside the group" request.method=GET request.url=http://zknill.io

Output from json logger

1
{"time":"...","level":"INFO","msg":"outside the group","request":{"method":"GET","url":"http://zknill.io"}}

The JSON logger will render the group as a child object of the request key. The text logger will concat the keys with a . separator; like request.method=GET

Carrying log context

I mentioned that we would return to the InfoContext(...) method. This method, and the other level context methods accept a context, which means the logger can have access to the context objects and extract app specific information to add to the log events.

But to access the context, we will need to create our own handler. So far we’ve been using the built in handlers: slog.TextHander and slog.JSONHandler. Both of these handlers ignore the context.

Handler interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Handler interface {
  // Enabled reports if the specific log event should 
  // be printed for this Handler and the given log level.
  // We can also choose to use info from the Context that
  // we passed in the InfoContext(...) call. If we used
  // Info(...) not InfoContext(...) then the context will 
  // be context.Background().
  Enabled(context.Context, slog.Level) bool

  // Handle takes the log record and prints it. The handle
  // function has access to the context, so we can extract
  // request specific info that we want to include in our
  // log event here.
  Handle(context.Context, Record) error

  // WithAttrs are the key value pairs that we're including in the structured logging
  // e.g. user=5
  WithAttrs(attrs []Attr) Handler

  // WithGroup is a sub-handler, with the group/object name
  WithGroup(name string) Handler
}

Defining a custom JSON handler that includes extra context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
    "context"
    "log/slog"
    "os"
)

func main() {
    // make the default json handler
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)

    // wrap the default json handler in a handler that logs context
    myHandler := &myHandler{JSONHandler: jsonHandler}
    

    logger := slog.New(myHandler)
    slog.SetDefault(logger)
    
    // add our context fields 
    ctx := context.Background()
    ctx = context.WithValue(ctx, userIDKey, "5")
    
    // call the context log method
    slog.InfoContext(ctx, "hello world")
}

// myHandler embeds the anonymous struct member
// *slog.JSONHandler. This gives us method overriding
// where all methods of the handler interface we don't
// implement will be dispatched to the built in handler
type myHandler struct {
    *slog.JSONHandler
}

type contextKey string

const userIDKey contextKey = "userid"

// Handle passes the userID key and value as an extra
// attribute to the standard json handler then calls handle.
func (h *myHandler) Handle(ctx context.Context, r slog.Record) error {
    userID := ctx.Value(userIDKey).(string)
    
    return h.
    	JSONHandler.
    	WithAttrs([]slog.Attr{slog.String("userID", userID)}).
    	Handle(ctx, r)
}

slogmw package

Most of us are going to want to modify or change elements of the log/slog package output, so here is a set of middleware that captures the patterns above into a more usable interface.

github.com/zknill/slogmw