Home   About Me   Blog  


Build a Go package that annotates errors with stack traces. (18 November 2019)

In Go, errors are values. This is different from languages like Java or Python that have exceptions.
The way I like thinking about this is; errors are not side effects of your application, instead, they are part and parcel of the application.
If you have an application that reads files from disk, the app's inability to read a file(maybe because the file does not exist) is an integral part of that applications' domain. And therefore, the errors that emanate from that failure should be considered part of that app's design space.
If, on the other hand, the app was unable to read a file because at that particular point in time the computer it was running in got hit by a cosmic ray from outer space, then that is an exception(the english meaning of the word.) In Go, we usually use panic for those kind of situations.

The only data that is available in a Go error produced using the stdlib errors package is the error message, which is a string.
Sometimes you may want more than that, like the file and line number where the error was emitted. The stdlib error will not "usually" give you that.

This article will not go into the question of whether stacktraces are necessary or not, there are plenty of articles out there that will try to convince you one way or the other. You should go read them.
This article assumes that you have come to the conclusion that you need stacktraces in your errors.
We are going to figure out how to get stacktraces out of any errors, including the ones created by the stdlib errors package.
We will do this by implementing a Go package that when given an error as an input, will return another error annotated with a stacktrace.

Implementation:
We are going to start with a custom error type, which is just about any type that implements the error interface


// Error is the type that implements the error interface.
// It contains the underlying err and its stacktrace.
type Error struct {
    Err        error
    StackTrace string
}
                
Then we need a function that takes in an error and returns an error annotated with a stacktrace.

// Wrap annotates the given error with a stack trace
func Wrap(err error) Error {
	return Error{Err: err, StackTrace: getStackTrace()}
}
                
We have the getStackTrace() function which will contain the bulk of our custom errors package implementation. What we want is a list of function calls that led up to the point where an error occured.
The Go runtime package has just the functions for that kind of thing,

func getStackTrace() string {
    stackBuf := make([]uintptr, 50)
    length := runtime.Callers(3, stackBuf[:])
    stack := stackBuf[:length]

    trace := ""
    frames := runtime.CallersFrames(stack)
    for {
        frame, more := frames.Next()
        trace = trace + fmt.Sprintf("\n\tFile: %s, Line: %d. Function: %s", frame.File, frame.Line, frame.Function)
        if !more {
            break
        }
    }
    return trace
}
                
runtime.Callers takes a slice and fills it up with the return program counters of function invocations on the calling goroutine's stack.
We then use runtime.CallersFrames to convert the program counters into Frames, which is a struct that contains a slice to Frame struct.
It is this Frame struct that contains the data that we are interested in. It contains, the name of the calling function, the file name and line location among other useful data.
Now that we have the data we want, we just have to make it available for use in our errors.

func (m Error) Error() string {
    return m.Err.Error() + m.StackTrace
}
                
We choose to append the stacktrace to the error message itself; but you could decide to avail that trace in some other way.

Finally, here's the full source code to our custom error package.

// Package errors provides ability to annotate you regular Go errors with stack traces.
package errors

import (
    "fmt"
    "runtime"
    "strings"
)

const maxStackLength = 50

// Error is the type that implements the error interface.
// It contains the underlying err and its stacktrace.
type Error struct {
    Err        error
    StackTrace string
}

func (m Error) Error() string {
    return m.Err.Error() + m.StackTrace
}

// Wrap annotates the given error with a stack trace
func Wrap(err error) Error {
    return Error{Err: err, StackTrace: getStackTrace()}
}

func getStackTrace() string {
    stackBuf := make([]uintptr, maxStackLength)
    length := runtime.Callers(3, stackBuf[:])
    stack := stackBuf[:length]

    trace := ""
    frames := runtime.CallersFrames(stack)
    for {
        frame, more := frames.Next()
        if !strings.Contains(frame.File, "runtime/") {
            trace = trace + fmt.Sprintf("\n\tFile: %s, Line: %d. Function: %s", frame.File, frame.Line, frame.Function)
        }
        if !more {
            break
        }
    }
    return trace
}
                

Usage:
We have the package, so how do we use it? Simple;

package main

import (
    "fmt"
    "strconv"
    "our.custom/errors" // import our custom errors package
)

func atoi() (int, error) {
	i, err := strconv.Atoi("f42")
	if err != nil {
		return 0, errors.Wrap(err) // annotate errors with stacktrace
	}
	return i, nil

}

func main() {
	_, err := atoi()
	if err != nil {
		fmt.Println(err)

	}
}
                
Running that code produces:

strconv.Atoi: parsing "f42": invalid syntax
    File: /tmp/code/main.go, Line: 50. Function: main.atoi
    File: /tmp/code/main.go, Line: 57. Function: main.main
                

Conclusion:
If you need stacktraces to accompany your errors, that's the way to do it; or at least one of the ways.
Of course you do not have to implement the package yourself, there already exists a number of such packages built by the Go open source community.
But getting to know how they work might be important to you.

All the code in this blogpost can be found at: https://github.com/komuw/komu.engineer/tree/master/blogs/08

You can comment on this article by clicking here.