Back

Structured Logging In Go Using Standard Library - Slog

Learn how to use log/slog from Go 1.21

by Percy Bolmér, June 9, 2023

By Percy Bolmer
By Percy Bolmer

Structured logging is a really important thing in applications, which is why I am so happy to see that we can finally use the Std lib in Go to perform it. 

About a year ago I wrote an article about how to perform structured logging in Go, In that article I recommend third-party libraries such as Lumberjack, Zerolog, Logrus, and Zap.

Since the release of Go 1.21, there is no longer any need to rely on those libraries.

And fewer third-party dependencies is an amazing thing that I always strive for.

Whats harm is using a third party logging library gonna do to you? Well, ask the users of Log4J.

As systems grow larger, logs and metrics play an increasingly important role in understanding what is happening inside the systems.

A structured log is simple what it sounds like, instead of printing a single line of random text, we print out structured logging information, commonly found in such structure as the date, service, and severity or level. It does not have to be JSON, It can also be a structured text row.

An example can be the

{ "date": "2024-06-21", "service": "buggy-script", "level": "warning", "message": "something went wrong", "context": "myFuncThatfails"}
An example of structured log

The advantages are many, easily readable, but most importantly we can ingest the logs into monitoring systems to get a better understanding and overview of the applications.

An video recording of this article

How To Use Structured Logs With Slog

To get started make sure you have Go installed, and the version has to be above 1.21. You can download the latest go here.

Create a new directory and initialize a Go module.

mkdir slogtest
cd slogtest
go mod init programmingpercy/slogtest
Creating a new project to test with

To use Slog, we can simply import the log/slog module and get started. Much like the log module, there is a default logger that we can use right of the bat if we don’t want to customize it.

package main

import (
 "log/slog"
)

func main(){
 slog.Info("test")
}
Simple slog example printing info

Running that software using go run main.go will trigger the following output

2023/08/09 19:03:43 INFO test
Example Log

Log Levels And Level Functions

An important part of any structured logging solution is log levels. 

The common approach is to have a few severity levels, and depending on your configuration you only see logs more severe than the configuration. 

Log levels are always hierarchical, what this means is that if we have set the applications log level to Debug, we would see all logs, no matter the level.

If we instead up increase the log level into Info, we would no longer see Debug logs, since debug is lower in the hierarchy.

In Slog, we have the following levels, each level is a higher severity than the previous. (PS. How you use the levels is up to you, its important to understand that they do have a hierarchical ranking)

  • Debug - Any information needed when debugging
  • Info - Basic information about what the application is up to
  • Warn - A warning in the application
  • Error - Something went wrong

Each level comes as a function in the slog module. If you use slog.Info it will print an Info log etc.

All these level functions follows the same function signature. Which means we use them in the same way, regardless of the level.

func(msg string, args ...any)
The level function Signature

The level functions (Debug, Info, Warn, Error) all accept the message as the first parameter.

Let’s print a message for each level and see what happens.

package main

import (
 "log/slog"
)

func main(){
 slog.Debug("debug level")
 slog.Info("info level")
 slog.Warn("warn level")
 slog.Error("error level")
}
Printing the different log levels

Now run the software with go run main.go and you should see the following.

2023/08/09 19:37:45 INFO info level
2023/08/09 19:37:45 WARN warn level
2023/08/09 19:37:45 ERROR error level
The logs output, missing debug

Now, you might be wondering where the Debug level is?

The default log Handler that we are using when calling on the Log functions directly from the slog module won’t print Debug logs.

This is because it is configured with the log level of info.

To see the debug logs, we need to create a new Handler which is used to print out logs, and then set the Log level appropriately.

There are two Handlers right now, JSON and Text. The default that we have seen is an internal Slog handler named defaultHandler.

We will start off with the Text handler, and then try out a JSON handler to see the difference.

We can create the Handlers using either NewTextHandler or NewJSONHandler. Again, the function signatures are the same.

func(w io.Writer, opts *slog.HandlerOptions) *slog.TextHandler
The signature for Handlers

The return type is different, but we can easily swap between the two thanks to this.

The handlers want an io.writer, and we want to print to stdout so I will simply print to os.Stdout so the logs appear in my terminal. The second argument is a slog.HandlerOptions, this is the place where we change how the logger behaves, such as log level.

The HandlerOptions is a struct with 3 fields, It allows us to set the log level, add the source code that triggers the log, or replace attributes if you want different timestamp format etc.

// HandlerOptions are options for a TextHandler or JSONHandler.
// A zero HandlerOptions consists entirely of default values.
type HandlerOptions struct {
 // AddSource causes the handler to compute the source code position
 // of the log statement and add a SourceKey attribute to the output.
 AddSource bool

 // Level reports the minimum record level that will be logged.
 // The handler discards records with lower levels.
 // If Level is nil, the handler assumes LevelInfo.
 // The handler calls Level.Level for each record processed;
 // to adjust the minimum level dynamically, use a LevelVar.
 Level Leveler

 // ReplaceAttr is called to rewrite each non-group attribute before it is logged.
 // The attribute's value has been resolved (see [Value.Resolve]).
 // If ReplaceAttr returns a zero Attr, the attribute is discarded.
 //
 // The built-in attributes with keys "time", "level", "source", and "msg"
 // are passed to this function, except that time is omitted
 // if zero, and source is omitted if AddSource is false.
 //
 // The first argument is a list of currently open groups that contain the
 // Attr. It must not be retained or modified. ReplaceAttr is never called
 // for Group attributes, only their contents. For example, the attribute
 // list
 //
 //     Int("a", 1), Group("g", Int("b", 2)), Int("c", 3)
 //
 // results in consecutive calls to ReplaceAttr with the following arguments:
 //
 //     nil, Int("a", 1)
 //     []string{"g"}, Int("b", 2)
 //     nil, Int("c", 3)
 //
 // ReplaceAttr can be used to change the default keys of the built-in
 // attributes, convert types (for example, to replace a `time.Time` with the
 // integer seconds since the Unix epoch), sanitize personal information, or
 // remove attributes from the output.
 ReplaceAttr func(groups []string, a Attr) Attr
}
The HandlerOptions struct from slog

Let’s create a new Handler using NewTextHandler with AddSource and a log level of debug.

The handler can be passed into slog.New which accepts a Handler and returns a Logger instance. We can use this Logger instance to log using our custom logger.

package main

import (
 "log/slog"
 "os"
)

func main(){

 logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelDebug,
  AddSource: true,

 })
 logger := slog.New(logHandler)

 logger.Debug("debug level")
 logger.Info("info level")
 logger.Warn("warn level")
 logger.Error("error level")
}
Creating a custom Handler

Run the software and you should see an updated input now also containing the source code that executes, and the Debug log.

time=2023-08-09T20:00:36.644+02:00 level=DEBUG source=/home/pp/development/blog/slog/main.go:16 msg="debug level"
time=2023-08-09T20:00:36.644+02:00 level=INFO source=/home/pp/development/blog/slog/main.go:17 msg="info level"
time=2023-08-09T20:00:36.644+02:00 level=WARN source=/home/pp/development/blog/slog/main.go:18 msg="warn level"
time=2023-08-09T20:00:36.644+02:00 level=ERROR source=/home/pp/development/blog/slog/main.go:19 msg="error level"
Logs printing from custom handler

If you want JSON logs, simply replace NewTextHandler with NewJSONHandler and it will work.

Custom Attributes and Groups

If you look at the log lines, you can see for instance how source is a key value pair. This is known as an Attribute.

We can add custom attributes by passing extra attributes into the Log functions. Remember that they accepted a variadic amount of arguments?

The second argument in the Log functions is a variadic amount of arguments, these arguments are extra fields that we want to log, maybe the functions name, or the version of the application.

These fields are known as Attributes in slog. It is basically extra fields of information that we want to print along side the log message, the information should be related to the log.

These fields are very important in a large ecosystem of applications. An example would be if you have multiple deployments of the service, and when debugging something that has gone wrong in your monitoring system such as Prometheus, maybe the region of the running instance would be important etc.

There are two ways of passing attributes, either allowing Go to infer and calculate the type of data by simply passing the information in by order key value.

An example would be

logger.Debug("What's the meaning of life?", "answer", 42)

// Prints
{"time":"2023-08-09T20:08:53.443176536+02:00","level":"DEBUG","source":{"function":"main.main","file":"/home/pp/development/blog/slog/main.go","line":17},"msg":"What's the meaning of life?","answer":42}
An example of passing attributes

See how it printed the attribute answer along with the Value?

This can be nice because the syntax or easy and simple. But for more efficient logging as mentioned in the docs, we can use LogAttributes.

We can create a new attribute by its function found in slog. Say we want to print answer as an Int. We would then instead do it like this

logger.Debug("What's the meaning of life?", slog.Int("answer", 42))
An example using Log Attributes instead

This will print the same thing, but it will be faster. The tradeoff is that it gets a little bit bloated fast.

Sometimes, we want to print a few related attributes. Slog allows us to group them by creating a group, it works in the same way as the level functions. We pass in the group name, followed by an variadic amount of attributes.

Here is an example of how we print out the Votes to my Pokemon rating list.

package main

import (
 "log/slog"
 "os"
)

func main(){

 logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelDebug,
  AddSource: true,

 })
 logger := slog.New(logHandler)

 logger.Debug("Best Pokemon Rating",
  slog.Group("votes", 
   slog.Int("Picachu", 40),
   slog.Int("Mew", 24),
  ),
 )
}
Using a Attribute group in slog

The group fields will be grouped under the JSON field as an array.

{"time":"2023-08-09T20:18:06.205582248+02:00","level":"DEBUG","source":{"function":"main.main","file":"/home/pp/development/blog/slog/main.go","line":19},"msg":"Best Pokemon Rating","votes":{"Picachu":40,"Mew":24}}
Group log example

This can be quiet nice and brings us to the next part. 

What if we want Default Attributes that ALL log messages should have?

We can add that to the Handler using the either of the WithAttrs which accepts an Array of Attributes that will always be present.

A great idea is to print Application information, configurations or the name of the service as these default attributes, or you know, the meaning of life.

package main

import (
 "log/slog"
 "os"
)

func main(){

 logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelDebug,
  AddSource: true,

 }).WithAttrs([]slog.Attr{
  slog.Int("What's the meaning of life?", 42),
 })
 logger := slog.New(logHandler)

 logger.Debug("Test")
}
Adding default attributes to log handler

If you run that, you will see that the log line contains the attribute.

What is even more swell is that a Group is an Attribute itself, so we can push in my Pokemon votes as a default to each log line.

logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelDebug,
  AddSource: true,

 }).WithAttrs([]slog.Attr{
  slog.Int("What's the meaning of life?", 42),
  slog.Group("Pokemon votes",
   slog.Int("Picachu", 40),
   slog.Int("Mew", 27),
  ),
 })
Adding a default group to handler

Replacing The Default Logger And Replacing Attributes

Right now, we are creating a Log instance that we use to print out logs instead of using the Default log from Slog.

This allows us to make these amazing customization, but now we need to pass this logger around in our software, which can become quite troublesome.

To set the default logger you can use

slog.SetDefault(logger)
Override slog default logger

Then we can get our amazing customized logger to apply globally instead of passing the logger around.

One last thing we might want to do is learn about ReplaceAttr which is a field when we create the Handler.

This allows us to replace Keys or Values from attributes. This can be quiet useful, for example what if we don’t want to print timestamps, but instead Unix timestamps?

Let us look at how we can do that,

The ReplaceAttr is a function that accepts an array of group names, and also the current attribute that is being worked on.

This attribute is a pointer, so we can modify it to match our needs.

Slog uses time as the key for when the log is being printed, I want to change that to date and unix time.

We can do this by simply checking if the attribute Key matches the time string, and replace the value.

ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr{
   // Match the key we want
   if a.Key == slog.TimeKey {
    a.Key = "date" // Rename time into date
    a.Value = slog.Int64Value(time.Now().Unix()) // Set it to a int64 unix time
   }
   return a
  },
Changing the time field into date

That is all we need to make it work

package main

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

func main(){

 logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelDebug,
  AddSource: true,
  ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr{
   if a.Key == slog.TimeKey {
    // Parse it correctly
    a.Key = "date" // Rename time into date
    a.Value = slog.Int64Value(time.Now().Unix())
   }
   return a
  },
 }).WithAttrs([]slog.Attr{
  slog.Int("What's the meaning of life?", 42),
  slog.Group("Pokemon votes",
   slog.Int("Picachu", 40),
   slog.Int("Mew", 27),
  ),
 })

 logger := slog.New(logHandler)

 logger.Debug("Test")
 slog.SetDefault(logger)
 
 myLogFunc()
}

func myLogFunc() {
 slog.Info("Log from inside")
}
An full example of slog with replacing the time value into unix

Try running the software, and you should see a nice unix timestamp called date being printed.

{"date":1691607523,"level":"INFO","source":{"function":"main.myLogFunc","file":"/home/pp/development/blog/slog/main.go","line":43},"msg":"Log from inside","What's the meaning of life?":42,"Pokemon votes":{"Picachu":40,"Mew":27}}
The logs printed

Conclusion

That is it for this short article.

In it, we have covered how to get started with Structured logging using Slog in Go.

We have learned about the log levels, the attributes, and how we can group attributes. 

We learn about default fields and how to replace the default logger with our customized logger.

Finally we looked at how we can replace values and keys.

I hope you enjoyed this article!

If you enjoyed my writing, please support future articles by buying me an Coffee

Sign up for my Awesome newsletter