Go Development

Passing Loggers in Go: Golang Logging Best Practices

Instrumentation and logging are one of most important features of an application deployed to production. Without a proper amount of logs, it’s almost impossible to debug any problem that we may be encountered during a software project. This article aims to briefly summarize the pros and cons of logging approaches commonly experienced in the gopher community.

Patrycja Szabłowska

Patrycja Szabłowska, Backend Developer

Updated Oct 05, 2020

This article won't compare all the loggers available out there nor will we tackle specific cases of what should or shouldn't be logged. Instead, we will focus entirely on how to use a logger of your choosing in your application.

In all examples we will be using the builtin log package for simplicity and ease of trying it out (e.g. in Go's playground).

Global State Logging Approaches

Example

package main import ( "log" "os" ) var logger = log.New(os.Stdout, "", 5) func main() { logger.Println("Hello, playground") // or log.Println("Hello, playground") }

Pros

  • Available to every function[1]

  • It's a common approach in many libraries, often given as an example[2]

Cons

  • Tight coupling[3]

  • Testing is difficult[4]

  • Hides state/behavior[5]

  • It is sometimes discouraged by the creators of logging libraries[6]

Introducing Logger interface

To address concerns with global logger, in all other approaches we'll be using a Logger interface:

type Logger interface { Debug(args ...interface{}) Debugf(format string, args ...interface{}) }

A Logger interface contains just the Debug-related methods for simplicity, but you can add more methods if you'd like.

Sample implementation using built-in Go's logger:

type BuiltinLogger struct { logger *log.Logger } func NewBuiltinLogger() *BuiltinLogger { return &BuiltinLogger{logger: log.New(os.Stdout, "", 5)} } func (l *BuiltinLogger) Debug(args ...interface{}) { l.logger.Println(args...) } func (l *BuiltinLogger) Debugf(format string, args ...interface{}) { l.logger.Printf(format, args...) }

Global Logger - Wrapped In a Struct, Used as an Interface

Example

var logger Logger = NewBuiltinLogger() func main() { logger.Debugf("Hello with %s", "formatting") }

Pros

  • Can be easily replaced with another implementation

  • Available to every function [1]

Cons

  • Much like the previous logigng approach, this loggeris also still hard to test. It isn't possible to simply inject anotherimplementation just fortests

  • It needs to have some kind of a default implementation initialized so it doesn't break tests

Passing in context.Context

Example

func DoStuff(ctx context.Context) { log := ctx.Value("logger").(Logger) log.Debugf("Hello with %s", "formatting") } func main() { ctx := context.WithValue(context.Background(), "logger", NewBuiltinLogger()) DoStuff(ctx) }

Pros

  • There's no need to pass logger everywhere. This is because you will be re-using the context, which is often passed as a first parameter to all functions

  • This log approach can be easily replaced with other loggers

Cons

  • Context doesn't have any schema, so there's no guarantee that the key is in the context [3]

  • Logger becomes an implicit dependency

[...] passing loggers inside context.Context would be the worst solution to the problem of decoupling loggers from implementations. Weʼd have gone from an explicit compile time dependency to an implicit run time dependency, one that could not be enforced by the compiler.[3]

Logger injected as a dependency

Example

type Stuff struct { logger Logger } func NewStuff(logger Logger) *Stuff { return &Stuff{logger: logger} } func (s *Stuff) DoStuff() { s.logger.Debug("Hello playground") s.logger.Debugf("Hello with %s", "formatting") } func main() { s := NewStuff(NewBuiltinLogger()) s.DoStuff() }

Pros

  • Dependencies should be explicit

  • It can be easily replaced with another implementation

  • The only one which allows to injection of a mock for testing purposes

Ad. 1.

Make dependencies explicit! Loggers are dependencies [...] [4]

[...] logging, like instrumentation, is often crucial to the operation of a service. And hiding dependencies in the global scope can and does come back to bite us, whetheritʼs something as seemingly benign as a logger, or perhaps another, more important, domain-specific component that we havenʼt bothered to parameterize. Save yourself the future pain by being strict: make all your dependencies explicit.[4]

[...] itʼs the only way to achieve decoupled design.[3]

Cons

  • Verbose[3] - we need to pass it to any struct (or function) we're using

Conclusion

At this stage, you're probably wondering: which logging approach should I choose?

We believe it depends on the codebase you're dealing with. For a brand new project injecting an explicit dependency is a worthwhile option. However, if you're dealing with legacy code, changing it overnight is hard. For that reason, in this case we would advise opting for a global logger used as an interface.

No matter which Golang logging approaches you choose, always pass it as an interface, and not as a concrete type. This will guarantee you the flexibility to change components of your code whenever you need, and also helps to avoid spreading external dependencies everywhere.

Get an estimate
I accept the Cookie Policy