Orso Labs

Handling errors only once and logging them with full context in Golang

Posted at — Oct 11, 2020

Handling error only once means that we log them at the place of handling, not at the place of occurrence. But how do we do this while providing to the structured log the full context of key/value pairs that is available only at the place of occurrence?

Handling errors only once and logging

Imagine having a call stack like

main()
    f1()
        f2()

where an error happens in f2() and f2() doesn’t know how to handle it. It will then return it to its caller, f1(). Assume also that f1() doesn’t know how to handle it, and so it returns it to its caller, main(). Finally main() handles it one way or the other. As part of the handling, it also logs it.

To avoid confusion, we want also to log the error only at the place of handling: if each function seeing the error were to log it, we would have in this example 3 log entries referring to the same error and it would be non-evident that actually there is only 1 error.

The simplest possible code is:

func main() {
	if err := f1(); err != nil {
		fmt.Printf("main: f1 failed: error: %v\n", err)
	}
}

func f1() error {
	connID := 123
	fmt.Printf("f1: inside connID=%d\n", connID)
	if err := f2(); err != nil {
		return fmt.Errorf("f2 failed: %v connID=%v", err, connID)
	}
	return nil
}

func f2() error {
	sessID := 456
	fmt.Printf("f2: inside sessID=%d\n", sessID)
	if _, err := os.Open("/non-existent"); err != nil {
		return fmt.Errorf("open failed: %w: sessID=%v", err, sessID)
	}
	return nil
}

Which generates the output (long lines wrapped for readability):

1  f1: inside connID=123
2  f2: inside sessID=456
3  main: f1 failed: error: f2 failed: open failed: open /non-existent: no such file or directory:
     sessID=456 connID=123

Note that:

  1. We had to explicitly log the context (connID and sessID), which is boring and error-prone.
  2. In log line 2 we miss the value of connID.

Structured logging

Structured logging allows to better search the logs for patterns, both by hand with tools like jq and systematically with log aggregation systems like Grafana Loki.

For this article we use the go-hclog structured logging library, but many other exists. We also use the nice feature Logger.With(), which gives back another logger that will automatically print all the passed key/value pairs, allowing us to avoid boredom and forgetfulness.

The code becomes:

func main() {
	log := hclog.New(&hclog.LoggerOptions{JSONFormat: true})
	if err := f1(log); err != nil {
		log.Error("main: f1 failed", "error", err)
	}
}

func f1(log hclog.Logger) error {
	connID := 123
	connLog := log.With("connID", connID)
	connLog.Info("f1: inside")
	if err := f2(connLog); err != nil {
		return fmt.Errorf("f2 failed: %v connID=%v", err, connID)
	}
	return nil
}

func f2(log hclog.Logger) error {
	sessID := 456
	sessLog := log.With("sessID", sessID)
	sessLog.Info("f2: inside")
	if _, err := os.Open("/non-existent"); err != nil {
		return fmt.Errorf("open failed: %w: sessID=%v", err, sessID)
	}
	return nil
}

Let’s run it and pass the output to jq for pretty-printing:

$ ./bin/ex02 2>| jq .
{
  "@level": "info",
  "@message": "f1: inside",
  "connID": 123
}
{
  "@level": "info",
  "@message": "f2: inside",
  "connID": 123,
  "sessID": 456
}
{
  "@level": "error",
  "@message": "main: f1 failed",
  "error": "f2 failed: open failed: open /non-existent: no such file or directory: sessID=456 connID=123"
}

Compared to the first example, the second log entry ("f2: inside") now reports also the connID value. allowing to better correlate events.

On the other hand, although we follow the best practice of using fmt.Errorf(), the most important log event, the one with the error, has swallowed and thus blurred the pairs connID and sessID into the error message. We cannot use jq or more advanced tooling to correlate. We are back to grepping the log output.

Can we do better, and at the same time log the error only once?

Wrapping the error with the log pairs

Thinking about it, we would like a way to encode in a more structured way (not in the error string!) the pairs from the log context into the error, and then extract the pairs from the error when we are handling it.

We can obtain the pairs from go-hclog with the function

// ImpliedArgs returns With key/value pairs
Logger.ImpliedArgs() []interface{}

We can take the Go idea of error wrapping and extend it to transport the log pairs as follows:

type logWrapError struct {
	msg   string
	err   error
	pairs []interface{}

Let’s add the classic methods to make this struct a proper error:

// Implement the error interface.
func (e logWrapError) Error() string {
	return e.msg
}
// Implement the wrap/unwrap error interface.
func (e logWrapError) Unwrap() error {
	return e.err
}

Now we only need a method to wrap the error (conceptually, like using fmt.Errorf() with the w verb):

// Err creates a wrapped error containing a list of key/value pairs from your
// structured logging library. Use Pairs to get the list back.
func Err(reason string, err error, pairs []interface{}) error {
	return &logWrapError{
		msg:   fmt.Sprintf("%s: %v", reason, err),
		err:   err,
		pairs: pairs,
	}
}

And a way to extract the innermost list of pairs:

// Pairs returns the list of key/value pairs of the innermost error in the chain, or
// returns the pair ["error"/err] if the chain is not of type lograpError. This means that
// in both cases it can be passed, without conditional logic, to a structured logger
// expecting a list of key/value pairs and it will do the right thing.
func Pairs(err error) []interface{} {
	prev, ok := err.(*logWrapError)
	if !ok {
		return []interface{}{"error", err}
	}
	for {
		next, ok := prev.err.(*logWrapError)
		if !ok {
			return append([]interface{}{"error", err}, prev.pairs...)
		}
		prev = next
	}
}

Armed with this simple wrapper, we can rewrite the example:

func main() {
	log := hclog.New(&hclog.LoggerOptions{JSONFormat: true})
	if err := g1(log); err != nil {
		log.Error("main: g1 failed", logwrap.Pairs(err)...)  // <= changed
	}
}

func g1(log hclog.Logger) error {
	connID := 123
	connLog := log.With("connID", connID)
	connLog.Info("g1: inside")
	if err := g2(connLog); err != nil {
		return logwrap.Err("g2 failed", err, connLog.ImpliedArgs())  // <= changed
	}
	return nil
}

func g2(log hclog.Logger) error {
	sessID := 456
	sessLog := log.With("sessID", sessID)
	sessLog.Info("g2: inside")
	if _, err := os.Open("/non-existent"); err != nil {
		return logwrap.Err("open failed", err, sessLog.ImpliedArgs()) // <= changed
	}
	return nil
}

Let’s run it and pass the output to jq for pretty-printing:

{
  "@level": "info",
  "@message": "g1: inside",
  "connID": 123
}
{
  "@level": "info",
  "@message": "g2: inside",
  "connID": 123,
  "sessID": 456
}
{
  "@level": "error",
  "@message": "main: g1 failed",
  "error": "g2 failed: open failed: open /non-existent: no such file or directory",
  "connID": 123, // <== visible and searchable!
  "sessID": 456  // <== visible and searchable!
}

:-)

Conclusions

Note that this wrapper is independent from the logging package, as long as said package:

  1. has a function to return the key/value pairs as a []interface{}.
  2. has at least one generic logging function that accepts a variadic list of arguments, along the lines of
    Level(msg string, args ...interface{})
    

I created a small package, called logwrap, that contains the code explained here and some tests, but the code is so short that you may want to follow the Go proverb A little copying is better than a little dependency and just copy and paste it into your code.

The code in this article is released under the MIT license.