Here’s One Simple Trick… Pass The Logger To Your Context!

As a software engineer, having an effective logging strategy is essential. Logs let us know what’s happening in the code and what’s gone wrong. Without adequate logs, we may not be able to provide an explanation for failures. In Go, logging can be handled in many different ways and there is a variety of packages to choose from. In this blog post, I’ll explain why your logger belongs in the context, share my favourite package and provide some code snippets to get you started.

Table Of Contents

Where Should The Logger Go?

There are many different ways to handle logging and it would be foolish to say there is a single best solution. What I can do though, is provide you with information on different options, so that you can make the best decision for your project.

Global Variable

A global logger instance which could be exported in its own package or placed at the top of a single file application. This is convenient, as it can be accessed from anywhere in the code without any dependency injection or extra function parameters. However, it is not thread-safe, meaning if you run multiple go routines it can lead to race conditions. Therefore this should only be used in small non-concurrent applications.

Function Parameter

This involves passing the logger instance around as a parameter to each function. This is a modular solution, as the function only depends on its parameters. Assuming each thread has its own copy of the logger, it can also be considered thread-safe too. The downside to this option is that you now have an extra parameter on every function. You will really pay the price for this the more layers your application has. As a result, this option can be used for small concurrent applications.

Dependency Injected

To achieve this, we add an extra logger field to a struct. This will allow easy access to the logger on all the methods on the struct. The solution is thread-safe and does not require an extra parameter on every function. But, you will now have an extra field which is unrelated to the struct.

Moreover, unless your entire application runs from this single struct, you will be left with a decision, to either inject the logger into every struct or pass the logger as a parameter to every subsequent function.

This solution can be used for concurrent applications which do not make use of the context deeper in the application or if the application mainly stems from a single struct.

Added To Your Context

This involves setting the logger as a value in the context and retrieving it when you want to log. The advantage of this approach is that the context will likely be passed to functions as a parameter anyway. Meaning no extra fields or parameters are required.

It is worth noting that dependent on your logger of choice, you may need a couple of helper functions to achieve this. You can see what these might look like in the example section.

This is a great solution if the application contains many layers and makes use of the context throughout. An example would be an API which follows a pattern like Hexagonal Architecture. I would dependency inject the logger into the handler. Then enrich the logger with fields such as the Tenant ID and X Request ID, before adding the logger to the context. This means that any log that is made will contain these fields, so they do not need to be added later on.

Which Package Should I Use?

You may feel swamped by the abundance of package options when it comes to logging in Go with each claiming to be the shiniest. But if we’re being honest, it probably doesn’t matter which you choose.

Yes, some are more performant with a couple of extra bells and whistles, but they all accomplish the same thing. They all provide the ability to produce structured logs (usually JSON) which you can contextually add extra fields to create more informative logs.

But, that doesn’t mean I don’t have a favourite. Mine is Zerolog and it’s because it has built-in functionality to store and retrieve the logger from the context. We’ll look at this in more detail in the next section.

How To Store The Logger In The Context

Now we know when to store the logger in the context, let’s take a look at how. If you’re using Zap or Logrus, then you are going to need a couple of helper functions to do this. I would recommend having these functions with your logger implementation or in their own package. They will be used to set and get the logger from the context. I have provided an example below of what these functions might look like if you are using Zap.

package log

import (
    "context"
    
    "go.uber.org/zap"
)

type key struct{}

// WithContext returns a new context with the logger added.
func WithContext(ctx context.Context, logger *zap.Logger) context.Context {
    return context.WithValue(ctx, key{}, logger)
}

// FromContext returns the logger in the context if it exists, otherwise a new logger is returned.
func FromContext(ctx context.Context) *zap.Logger {
    logger := ctx.Value(key{})
    if l, ok := logger.(*zap.Logger); ok {
        return l
    }
    return zap.Must(zap.NewProduction())
}
Go

Below you can see how these functions can be used.

func f() {
    l := zap.Must(zap.NewProduction())
    ctx := log.WithContext(context.Background(), l)
    log.FromContext(ctx).Info("Hello World!") 
}
Go

If that felt a little on the hacky side, then let me show you why Zerolog is my favourite. It has functions which allow you to store the logger in the context straight out of the box. The code below is from their documentation.

func f() {
    logger := zerolog.New(os.Stdout)
    ctx := context.Background()

    // Attach the Logger to the context.Context
    ctx = logger.WithContext(ctx)
    someFunc(ctx)
}

func someFunc(ctx context.Context) {
    // Get Logger from the go Context. if it's nil, then
    // `zerolog.DefaultContextLogger` is returned, if
    // `DefaultContextLogger` is nil, then a disabled logger is returned.
    logger := zerolog.Ctx(ctx)
    logger.Info().Msg("Hello")
}
Go

Conclusion

In conclusion, adding the logger to the context is not always the right answer. For smaller applications, this would be adding unnecessary complexity.

However, there are many situations the context is the perfect place for the logger. A great example would be an API which follows a pattern like Hexagonal Architecture. This removes a lot of clutter by not requiring any extra function parameters or fields.

With that being said, it can feel a bit hacky using helper functions to set and get the logger from the context. But, I don’t think this should put you off. If this really annoys you, then just use Zerolog, as it has this functionality built-in.

If you’ve never tried storing your logger in your context, here’s your sign to give it a try!

If you have any questions or comments, please leave them below. Also, don’t forget to subscribe to our newsletter to receive more informative posts like this directly to your inbox.

If you found this article helpful, please share it with your friends and colleagues.

Thank you for reading!

Leave a Reply

Your email address will not be published. Required fields are marked *