What if every function added its location to returned errors?
errtrace is an experimental package to trace an error's return path — the return trace — through a Go program.
Where a stack trace tracks the code path that led to an error, a return trace tracks the code path that the error took to get to the user. Often these are the same path, but in Go they can diverge, since errors are values that can be transported across goroutines (e.g. with channels). When that happens, a return trace can be more useful than a stack trace.
This library is inspired by Zig's error return traces.
- Lightweight
errtrace brings no other runtime dependencies with it. - Simple
The library API is simple, straightforward, and idiomatic. - Easy
The errtrace CLI will automatically instrument your code. - Fast
On popular 64-bit systems, errtrace is much faster than capturing a stack trace.
With stack traces, caller information for the goroutine is captured once when the error is created.
In constrast, errtrace records the caller information incrementally, following the return path the error takes to get to the user. This approach works even if the error isn't propagated directly through function returns, and across goroutines.
Both approaches look similar when the error flows through function calls within the same goroutine, but can differ significantly when errors are passed outside of functions and across goroutines (e.g., channels).
Here's a real-world example that shows the benefits of errtrace tracing the return path by comparing a custom dial error returned for a HTTP request, which the net/http library uses a background goroutine for.
errtrace compared to a stack trace
errtrace | stack trace |
|
|
errtrace reports the method that triggered the HTTP request | stack trace shows details of how the HTTP client creates a connection |
errtrace also reduces the performance impact of capturing caller information for errors that are handled rather than returned to the user, as the information is captured incrementally. Stack traces pay a fixed cost to capture caller information even if the error is handled immediately by the caller close to where the error is created.
Try out errtrace with your own code:
-
Install the CLI.
go install braces.dev/errtrace/cmd/errtrace@latest
-
Switch to your Git repository and instrument your code.
errtrace -w ./...
-
Let
go mod tidy
install the errtrace Go module for you.go mod tidy
-
Run your tests to ensure everything still works. You may see failures if you're comparing errors with
==
on critical paths or if you're type-casting errors directly. See Error wrapping for more details.go test ./...
-
Print return traces for errors in your code. To do this, you can use the
errtrace.FormatString
function or format the error with%+v
infmt.Printf
-style functions.if err != nil { fmt.Fprintf(os.Stderr, "%+v", err) }
Return traces printed by errtrace will include the error message and the path the error took until it was printed. The output will look roughly like this:
error message
example.com/myproject.MyFunc
/home/user/myproject/myfile.go:123
example.com/myproject.CallerOfMyFunc
/home/user/myproject/another_file.go:456
[...]
Here's a real-world example of errtrace in action:
Example
doc2go: parse file: /path/to/project/example/foo.go:3:1: expected declaration, found invalid
go.abhg.dev/doc2go/internal/gosrc.parseFiles
/path/to/project/internal/gosrc/parser.go:85
go.abhg.dev/doc2go/internal/gosrc.(*Parser).ParsePackage
/path/to/project/internal/gosrc/parser.go:44
main.(*Generator).renderPackage
/path/to/project/generate.go:193
main.(*Generator).renderTree
/path/to/project/generate.go:141
main.(*Generator).renderTrees
/path/to/project/generate.go:118
main.(*Generator).renderPackageIndex
/path/to/project/generate.go:149
main.(*Generator).renderTree
/path/to/project/generate.go:137
main.(*Generator).renderTrees
/path/to/project/generate.go:118
main.(*Generator).renderPackageIndex
/path/to/project/generate.go:149
main.(*Generator).renderTree
/path/to/project/generate.go:137
main.(*Generator).renderTrees
/path/to/project/generate.go:118
main.(*Generator).Generate
/path/to/project/generate.go:110
main.(*mainCmd).run
/path/to/project/main.go:199
Note the some functions repeat in this trace because the functions are mutually recursive.
In Go, errors are values. This means that an error can be passed around like any other value. You can store it in a struct, pass it through a channel, etc. This level of flexibility is great, but it can also make it difficult to track down the source of an error. A stack trace stored in an error — recorded at the error site — becomes less useful as the error moves through the program. When it's eventually surfaced to the user, we've lost a lot of context about its origin.
With errtrace, we instead record the path the program took from the error site to get to the user — the return trace. Not only can this be more useful than a stack trace, it tends to be much faster and more lightweight as well.
Install errtrace with Go modules:
go get braces.dev/errtrace@latest
If you want to use the CLI, use go install
.
go install braces.dev/errtrace/cmd/errtrace@latest
errtrace offers the following modes of usage:
import "braces.dev/errtrace"
Under manual instrumentation, you're expected to import errtrace, and wrap errors at all return sites like so:
// ...
if err != nil {
return errtrace.Wrap(err)
}
Example
Given a function like the following:
func writeToFile(path string, src io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
return nil
}
With errtrace, you'd change it to:
func writeToFile(path string, src io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return errtrace.Wrap(err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return errtrace.Wrap(err)
}
return nil
}
It's important that the errtrace.Wrap
function is called
inside the same function that's actually returning the error.
A helper function will not suffice.
If manual instrumentation is too much work (we agree), we've included a tool that will automatically instrument all your code with errtrace.
First, install the tool. Then, run it on your code:
errtrace -w path/to/file.go path/to/another/file.go
Instead of specifying individual files, you can also specify a Go package pattern. For example:
errtrace -w example.com/path/to/package
errtrace -w ./...
errtrace can be set be setup as a custom formatter in your editor, similar to gofmt or goimports.
If you're relying on automatic instrumentation and want to ignore specific lines from being instrumented, you can add a comment in one of the following forms on relevant lines:
//errtrace:skip
//errtrace:skip(explanation)
//errtrace:skip // explanation
This can be especially useful if the returned error
has to match another error exactly because the caller still uses ==
.
For example, if you're implementing io.Reader
,
you need to return io.EOF
when you reach the end of the input.
Wrapping it will cause functions like io.ReadAll
to misbehave.
type myReader struct{/* ... */}
func (*myReader) Read(bs []byte) (int, error) {
// ...
return 0, io.EOF //errtrace:skip(io.Reader expects io.EOF)
}
errtrace is designed to have very low overhead on supported systems.
Benchmark results for linux/amd64 on an Intel Core i5-13600 (best of 10):
BenchmarkFmtErrorf 11574928 103.5 ns/op 40 B/op 2 allocs/op
# default build, uses Go assembly.
BenchmarkWrap 78173496 14.70 ns/op 24 B/op 0 allocs/op
# build with -tags safe to avoid assembly.
BenchmarkWrap 5958579 198.5 ns/op 24 B/op 0 allocs/op
# benchext compares capturing stacks using pkg/errors vs errtrace
# both tests capture ~10 frames,
BenchmarkErrtrace 6388651 188.4 ns/op 280 B/op 1 allocs/op
BenchmarkPkgErrors 1673145 716.8 ns/op 304 B/op 3 allocs/op
Stack traces have a large initial cost, while errtrace scales with each frame that an error is returned through.
errtrace operates by wrapping your errors to add caller information.
As a result of this,
error comparisons and type-casting may not work as expected.
You can no longer use ==
to compare errors, or type-cast them directly.
You must use the standard library's
errors.Is and
errors.As functions.
For example, if you have a function readFile
that wraps an io.EOF
error with errtrace:
Matching errors
err := readFile() // returns errtrace.Wrap(io.EOF)
// This will not work.
fmt.Println(err == io.EOF) // false
// Use errors.Is instead.
fmt.Println(errors.Is(err, io.EOF)) // true
Similarly, if you have a function runCmd
that wraps an exec.ExitError
error with errtrace:
Type-casting errors
err := runCmd() // returns errtrace.Wrap(&exec.ExitError{...})
// This will not work.
exitErr, ok := err.(*exec.ExitError) // ok = false
// Use errors.As instead.
var exitErr *exec.ExitError
ok := errors.As(err, &exitErr) // ok = true
You can use go-errorlint
to find places in your code
where you're comparing errors with ==
instead of using errors.Is
or type-casting them directly instead of using errors.As
.
To achieve the performance above on supported systems, errtrace makes use of unsafe operations using Go assembly to read the caller information directly from the stack. This is part of the reason why we have the disclaimer on top.
errtrace includes an opt-in safe mode
that drops these unsafe operations in exchange for poorer performance.
To opt into safe mode,
use the safe
build tag when compiling code that uses errtrace.
go build -tags safe
errtrace's unsafe operations are currently implemented
for GOARCH=amd64
and GOARCH=arm64
only.
Other systems are supported but they will use safe mode, which is slower.
Contributions to support unsafe mode for other architectures are welcome.
Contributions are welcome. However, we ask that before contributing new features, you open an issue to discuss the feature with us.
The idea of tracing return paths instead of stack traces comes from Zig's error return traces.
This software is made available under the BSD3 license. See LICENSE file for details.