errtrace

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Dec 22, 2023 License: BSD-3-Clause Imports: 8 Imported by: 29

README

errtrace

What if every function added its location to returned errors?

errtrace logo

CI Go Reference codecov

Introduction

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.

Features
  • 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.
Comparison with stack traces

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
errtracestack trace
Error: connect rate limited

braces.dev/errtrace_test.rateLimitDialer
	/path/to/errtrace/example_http_test.go:72
braces.dev/errtrace_test.(*PackageStore).updateIndex
	/path/to/errtrace/example_http_test.go:59
braces.dev/errtrace_test.(*PackageStore).Get
	/path/to/errtrace/example_http_test.go:49
Error: connect rate limited
braces.dev/errtrace_test.rateLimitDialer
	/errtrace/example_stack_test.go:81
net/http.(*Transport).dial
	/goroot/src/net/http/transport.go:1190
net/http.(*Transport).dialConn
	/goroot/src/net/http/transport.go:1625
net/http.(*Transport).dialConnFor
	/goroot/src/net/http/transport.go:1467
runtime.goexit
	/goroot/src/runtime/asm_arm64.s:1197
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 it out

Try out errtrace with your own code:

  1. Install the CLI.

    go install braces.dev/errtrace/cmd/errtrace@latest
    
  2. Switch to your Git repository and instrument your code.

    errtrace -w ./...
    
  3. Let go mod tidy install the errtrace Go module for you.

    go mod tidy
    
  4. 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 ./...
    
  5. Print return traces for errors in your code. To do this, you can use the errtrace.FormatString function or format the error with %+v in fmt.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.

Why is this useful?

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.

Installation

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

Usage

errtrace offers the following modes of usage:

Manual instrumentation
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.

Automatic instrumentation

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.

Opting-out during automatic instrumentation

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)
}

Performance

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.

Caveats

Error wrapping

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
Linting

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.

Safety

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
Supported systems

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.

Contributing

Contributions are welcome. However, we ask that before contributing new features, you open an issue to discuss the feature with us.

Acknowledgements

The idea of tracing return paths instead of stack traces comes from Zig's error return traces.

License

This software is made available under the BSD3 license. See LICENSE file for details.

Documentation

Overview

Package errtrace provides the ability to track a return trace for errors. This differs from a stack trace in that it is not a snapshot of the call stack at the time of the error, but rather a trace of the path taken by the error as it was returned until it was finally handled.

Wrapping errors

Use the Wrap function at a return site to annotate it with the position of the return.

// Before
if err != nil {
	return err
}

// After
if err != nil {
	return errtrace.Wrap(err)
}

Formatting return traces

errtrace provides the Format and FormatString functions to obtain the return trace of an error.

errtrace.Format(os.Stderr, err)

See Format for details of the output format.

Additionally, errors returned by errtrace will also print a trace if formatted with the %+v verb when used with a Printf-style function.

log.Printf("error: %+v", err)

See also

https://github.com/bracesdev/errtrace.

Example (Http)
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"

	"braces.dev/errtrace"
	"braces.dev/errtrace/internal/tracetest"
)

func main() {
	tp := &http.Transport{Dial: rateLimitDialer}
	client := &http.Client{Transport: tp}
	ps := &PackageStore{
		client: client,
	}

	_, err := ps.Get()
	fmt.Printf("Error fetching packages: %+v\n", tracetest.MustClean(errtrace.FormatString(err)))
}

type PackageStore struct {
	client         *http.Client
	packagesCached []string
}

func (ps *PackageStore) Get() ([]string, error) {
	if ps.packagesCached != nil {
		return ps.packagesCached, nil
	}

	packages, err := ps.updateIndex()
	if err != nil {
		return nil, errtrace.Wrap(err)
	}

	ps.packagesCached = packages
	return packages, nil
}

func (ps *PackageStore) updateIndex() ([]string, error) {
	resp, err := ps.client.Get("http://example.com/packages.index")
	if err != nil {
		return nil, errtrace.Wrap(err)
	}

	contents, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, errtrace.Wrap(err)
	}

	return strings.Split(string(contents), ","), nil
}

func rateLimitDialer(network, addr string) (net.Conn, error) {
	// for testing, always return an error.
	return nil, errtrace.New("connect rate limited")
}
Output:

Error fetching packages: Get "http://example.com/packages.index": connect rate limited

braces.dev/errtrace_test.rateLimitDialer
	/path/to/errtrace/example_http_test.go:3
braces.dev/errtrace_test.(*PackageStore).updateIndex
	/path/to/errtrace/example_http_test.go:2
braces.dev/errtrace_test.(*PackageStore).Get
	/path/to/errtrace/example_http_test.go:1
Example (Trace)
package main

import (
	"fmt"

	"braces.dev/errtrace"
	"braces.dev/errtrace/internal/tracetest"
)

func f1() error {
	return errtrace.Wrap(f2())
}

func f2() error {
	return errtrace.Wrap(f3())
}

func f3() error {
	return errtrace.New("failed")
}

func main() {
	got := errtrace.FormatString(f1())

	// make trace agnostic to environment-specific location
	// and less sensitive to line number changes.
	fmt.Println(tracetest.MustClean(got))

}
Output:

failed

braces.dev/errtrace_test.f3
	/path/to/errtrace/example_trace_test.go:3
braces.dev/errtrace_test.f2
	/path/to/errtrace/example_trace_test.go:2
braces.dev/errtrace_test.f1
	/path/to/errtrace/example_trace_test.go:1
Example (Tree)
package main

import (
	"errors"
	"fmt"
	"strings"

	"braces.dev/errtrace"
	"braces.dev/errtrace/internal/tracetest"
)

func normalErr(i int) error {
	return fmt.Errorf("std err %v", i)
}

func wrapNormalErr(i int) error {
	return errtrace.Wrap(normalErr(i))
}

func nestedErrorList(i int) error {
	return errors.Join(
		normalErr(i),
		wrapNormalErr(i+1),
	)
}

func main() {
	errs := errtrace.Wrap(errors.Join(
		normalErr(1),
		wrapNormalErr(2),
		nestedErrorList(3),
	))
	got := errtrace.FormatString(errs)

	// make trace agnostic to environment-specific location
	// and less sensitive to line number changes.
	fmt.Println(trimTrailingSpaces(tracetest.MustClean(got)))

}

func trimTrailingSpaces(s string) string {
	lines := strings.Split(s, "\n")
	for i := range lines {
		lines[i] = strings.TrimRight(lines[i], " \t")
	}
	return strings.Join(lines, "\n")
}
Output:

+- std err 1
|
+- std err 2
|
|  braces.dev/errtrace_test.wrapNormalErr
|  	/path/to/errtrace/example_tree_test.go:1
|
|  +- std err 3
|  |
|  +- std err 4
|  |
|  |  braces.dev/errtrace_test.wrapNormalErr
|  |  	/path/to/errtrace/example_tree_test.go:1
|  |
+- std err 3
|  std err 4
|
std err 1
std err 2
std err 3
std err 4

braces.dev/errtrace_test.Example_tree
	/path/to/errtrace/example_tree_test.go:2

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Errorf

func Errorf(format string, args ...any) error

Errorf creates an error message according to a format specifier and returns the string as a value that satisfies error.

It's equivalent to fmt.Errorf followed by Wrap to add caller information.

func Format

func Format(w io.Writer, target error) (err error)

Format writes the return trace for given error to the writer. The output takes a fromat similar to the following:

<error message>

<function>
	<file>:<line>
<caller of function>
	<file>:<line>
[...]

If the error doesn't have a return trace attached to it, only the error message is reported. If the error is comprised of multiple errors (e.g. with errors.Join), the return trace of each error is reported as a tree.

Returns an error if the writer fails.

func FormatString

func FormatString(target error) string

FormatString writes the return trace for err to a string. See Format for details of the output format.

func New

func New(text string) error

New returns an error with the supplied text.

It's equivalent to errors.New followed by Wrap to add caller information.

func Wrap

func Wrap(err error) error

Wrap adds information about the program counter of the caller to the error. This is intended to be used at all return points in a function. If err is nil, Wrap returns nil.

func Wrap2

func Wrap2[T any](t T, err error) (T, error)

Wrap2 is used to Wrap the last error return when returning 2 values. This is useful when returning multiple returns from a function call directly:

return Wrap2(fn())

Wrap2 is used by the CLI to avoid line number changes.

func Wrap3

func Wrap3[T1, T2 any](t1 T1, t2 T2, err error) (T1, T2, error)

Wrap3 is used to Wrap the last error return when returning 3 values. This is useful when returning multiple returns from a function call directly:

return Wrap3(fn())

Wrap3 is used by the CLI to avoid line number changes.

func Wrap4

func Wrap4[T1, T2, T3 any](t1 T1, t2 T2, t3 T3, err error) (T1, T2, T3, error)

Wrap4 is used to Wrap the last error return when returning 4 values. This is useful when returning multiple returns from a function call directly:

return Wrap4(fn())

Wrap4 is used by the CLI to avoid line number changes.

func Wrap5

func Wrap5[T1, T2, T3, T4 any](t1 T1, t2 T2, t3 T3, t4 T4, err error) (T1, T2, T3, T4, error)

Wrap5 is used to Wrap the last error return when returning 5 values. This is useful when returning multiple returns from a function call directly:

return Wrap5(fn())

Wrap5 is used by the CLI to avoid line number changes.

func Wrap6

func Wrap6[T1, T2, T3, T4, T5 any](t1 T1, t2 T2, t3 T3, t4 T4, t5 T5, err error) (T1, T2, T3, T4, T5, error)

Wrap6 is used to Wrap the last error return when returning 6 values. This is useful when returning multiple returns from a function call directly:

return Wrap6(fn())

Wrap6 is used by the CLI to avoid line number changes.

Types

This section is empty.

Directories

Path Synopsis
cmd
errtrace
errtrace instruments Go code with error return tracing.
errtrace instruments Go code with error return tracing.
internal
diff
Package diff provides utilities for comparing strings and slices to produce a readable diff output for tests.
Package diff provides utilities for comparing strings and slices to produce a readable diff output for tests.
pc
Package pc provides access to the program counter to determine the caller of a function.
Package pc provides access to the program counter to determine the caller of a function.
tracetest
Package tracetest provides utilities for errtrace to test error trace output conveniently.
Package tracetest provides utilities for errtrace to test error trace output conveniently.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL