Where to Place Logger in Golang?

Logging is an integral part of any application. However, the correct location and use of the logger in the project structure raises questions even for experienced developers.

There are several ways of doing this, of which I give preference to one. I will explain why.

When designing an application, the developer chooses from several explicit options:

  • store the logger in a global variable;
  • get the logger from the logging library;
  • add the logger to data structures;
  • pass the logger explicitly in the function call.

In this article, I will use Zap logger as an example, but you may use any.

Store The Logger in a Global Variable

This is the simplest option that comes to mind.

var l, _ = zap.NewProduction()

It is good in its simplicity, but its implementation can be inefficient:

  • the use of global variables increases the coupling in the program, worsening the structure of the code;
  • the global logger is inconvenient to adjust for a particular context, for example - to add common fields for a message set to the logger;
  • defining a global scope can be tricky for an application with multiple executable files.

Get The Logger from the Logging Library

This option is almost the same as the global variable, only the variable is hidden inside the imported module.

l := zap.L()

This is a little more convenient to use, because you don’t have to search for the global variable in the code and import it. On the other hand, it is possible to get a library version conflict when plugging in external dependencies.

Add The Logger to Data Structures

This is a fairly common alternative used by many developers. However, its use adds an element to structures that does not relate to the data structure, but to their instrumentation.

type UserRepository struct {
    l *zap.Logger
    /*
    other fields
    */
}

func (ur *UserRepository) SaveUser(ctx context.Context, u user.User) error {
    ur.l.Info("saving user")
    // ...
}

In other words, logging is not related to data, but to operations, so it makes more sense to have it in functions and methods rather than keeping it in structures.

Pass The Logger Explicitly in the Function Call

It turns out that the most logical place for a logger is in the methods or functions themselves. However, passing a logger into every function obviously leads to a huge code complication:

  • Adding a parameter to a function will worsen the readability of the signature;
  • explicitly passing the logger along the call chain adds a lot of copy-paste;
  • functions with a fixed signature (e.g., HTTP handler) will require you to pass the logger through workarounds.
func CreateUser(ctx context.Context, u user.User, l *zap.Logger) error {
    l = l.With(zap.String("userID", u.ID))

    l.Info("user creation started")

    if err := SaveUser(ctx, u, l); err != nil {
       l.Error("user creation failed", zap.Error(err))
       return err
    }
    l.Info("user creation done")
    return nil
}

This makes the approach inapplicable in practice. But is there a way to combine the pros of the different approaches, avoiding the cons?

The Win-Win Context

As a developer, I would like to pass the logger to a function without adding new parameters to its signature. But these requirements seem to be contradictory.

Fortunately, there is an entity in Golang which is often passed to every function, and performs many utilitarian tasks - it is context.Context.

Context in Go solves very important problems:

  • gracefully interrupting processing at application shutdown;
  • limiting the execution time of a function;
  • managing asynchronous operations;
  • getting out of infinite loops;
  • distributed tracing.

As we can see, these tasks are purely utilitarian, that is, they do not relate to the business logic of the application. The same is the task of logging (if we are not talking about special logging kinds, such as an audit log).

This way, passing the log via context will not disrupt the function’s signature (since most functions already take the context as a first parameter, and others will benefit from it). This can be done as follows:

package log

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

type ctxLogger struct{}

// ContextWithLogger adds logger to context
func ContextWithLogger(ctx context.Context, l *zap.Logger) context.Context {
	return context.WithValue(ctx, ctxLogger{}, l)
}

// LoggerFromContext returns logger from context
func LoggerFromContext(ctx context.Context) *zap.Logger {
	if l, ok := ctx.Value(ctxLogger{}).(*zap.Logger); ok {
		return l
	}
	return zap.L()
}

This will greatly simplify the use of the logger and provide additional benefits:

  • add common fields to the log along the call chain, such as adding a userID from a request;
  • implement middleware for http/grpc, which will add to the log some information about the request;
  • integrate log with other tools that use context, such as distributed tracing.

In addition, the approach solves the issue of access to global data - now the logger is always local to the called function, and can be used without restrictions.

func CreateUser(ctx context.Context, u user.User) error {
    l := log.LoggerFromContext(ctx).With(zap.String("userID", u.ID))

    l.Info("user creation started")

    ctx = log.ContextWithLogger(ctx, l)
    if err := SaveUser(ctx, u); err != nil {
       l.Error("user creation failed", zap.Error(err))
       return err
    }
    l.Info("user creation done")
    return nil
}

In practice, it is not always convenient to use this approach, but it covers about 90%-95% of cases in a server application. For the rest of the cases, you can use another approach from those listed above.

It also comes in handy in teamwork, where different team members can add call context to the logger at different levels of the application as they work on their changes. This will eventually be combined into informative and useful records that are easy to work with.

comments