Skip to content

Commit

Permalink
Add TraceWithLocation and revamps docs
Browse files Browse the repository at this point in the history
  • Loading branch information
alnvdl committed Mar 12, 2023
1 parent 02a597b commit 3a1f56f
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 140 deletions.
101 changes: 50 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ debug errors across layers in complex applications.
To help with that, terr fully embraces the native Go error handling paradigms,
but it adds two features:
- file and line information for tracing errors;
- the ability to print error trees using the `fmt` package and the `%@` verb;
- the ability to print error trees using the `fmt` package with the `%@` verb;

This library introduces the concept of **traced errors**: a wrapper for errors
that includes the location where they were created (`errors.New`), passed along
Expand All @@ -24,66 +24,44 @@ was returned by one of the functions of this library.
Traced errors work seamlessly with `errors.Is`, `errors.As` and `errors.Unwrap`
just as if terr were not being used.

## Using terr
Without terr | With terr
-------------------------------|------------------------------
`errors.New("error")` | `terr.Newf("error")`
`fmt.Errorf("error: %w", err)` | `terr.Newf("error: %w", err)`
`[return] err` | `terr.Trace(err)` (annotates file and line)
`[return] err` | `terr.Trace(err)`
`[return] NewCustomErr()` | `terr.TraceWithLocation(NewCustomErr())`

## Under the hood
Starting with Go 1.20, wrapped errors are kept as a n-ary tree. terr works by
build a parallel tree containing tracing information, leaving the Go error tree
untouched, as if terr weren't being used. Each traced error is thus a node of
the parallel traced error tree.
`terr.Newf` can receive multiple errors. In fact, it is just a very slim
wrapper around `fmt.Errorf`. Any traced error passed to `terr.Newf` will be
included in the traced error tree, regardless of the `fmt` verb used for it.

In the glorious day error tracing is added to Go, and assuming it gets done in
a way that respects error handling as defined in Go 1.13+,
[removing `terr` from a codebase](#getting-rid-of-terr) should be a matter of
replacing the `terr` function calls with the equivalent documented expressions.
`terr.Trace` and `terr.TraceWithLocation` on the other hand do nothing with
the error they receives (no wrapping and no masking), but they add one level
to the error tracing tree.

`terr.Newf` can wrap multiple errors. In fact, it is just a very slim wrapper
around `fmt.Errorf`. Any traced error passed to `terr.Newf` will be included in
the traced error tree, regardless of the `fmt` verb used.
A couple of examples are available showing these functions in use[^2] [^3].

`terr.Trace` on the other hand does nothing with the error it receives (no
wrapping and no masking), but it adds one level to the parallel error tracing
tree.

To obtain the full trace, terr functions must be used consistently. If
`fmt.Errorf` is used at one point, the error tracing information will be reset
at that point, but Go's wrapped error tree will be preserved even in that case.

## Printing errors
A traced error tree can be printed with the special `%@` formatting verb. For
example:
```go
err := terr.Newf("base")
traced := terr.Trace(err)
wrapped := terr.Newf("wrapped: %w", traced)
masked := terr.Newf("masked: %v", wrapped)
fmt.Printf("%@\n", masked)
```

Will output:
```
masked: wrapped: base @ /mygomod/file.go:14
wrapped: base @ /mygomod/file.go:13
base @ /mygomod/file.go:12
base @ /mygomod/file.go:11
```
### Printing errors
A traced error tree can be printed with the special `%@` formatting verb. An
example is available[^2].

`%@` prints the error tree in a tab-indented, multi-line representation. If a
custom format is needed (e.g., JSON), it is possible to implement a function
that walks the error tree and generates that tree in the desired format. See
the [next subsection](#walking-the-traced-error-tree).

## Walking the traced error tree
### Tracing custom errors
Custom errors can be turned into traced errors as well by using
`terr.TraceWithLocation` in constructor functions. An example is available[^3].

### Walking the traced error tree
`terr.TraceTree(err) TracedError` can be used to obtain the root of an n-ary
traced error tree, which can be navigated using the following methods:
```go
type TracedError interface {
Error() string
Location() string
Location() (string, int)
Children() []TracedError
}
```
Expand All @@ -99,31 +77,52 @@ Methods provided by the by the Go standard library should be used to walk Go's
wrapped error tree, which would includes non-traced errors and ignores masked
errors (e.g., `errors.Unwrap`).

## Adopting terr
### Adopting terr
Run the following commands in a folder to recursively adopt terr in all its
files (make sure `goimports`[^2] is installed first):
files (make sure `goimports`[^4] is installed first):
```sh
$ go get github.com/alnvdl/terr
$ gofmt -w -r 'errors.New(a) -> terr.Newf(a)' .
$ gofmt -w -r 'fmt.Errorf -> terr.Newf' .
$ goimports -w .
```

Adopting `terr.Trace` is harder, as it's impossible write a simple gofmt
rewrite rule that works for all cases. `terr.Trace` has to be applied as needed
in a code base, typically in cases where `return err` is used, turning it
into `return terr.Trace(err)`.
Adopting `terr.Trace` and `terr.TraceWithLocation` is harder, as it is
impossible to write a simple gofmt rewrite rule that works for all cases.
Therefore, `terr.Trace` and `terr.TraceWithLocation` have to be applied as
needed in a code base. A rough guideline would be:
- `return err` becomes `return terr.Trace(err)`;
- `return NewCustomErr()` becomes
`return terr.TraceWithLocation(NewCustomErr())`.

## Getting rid of terr
### Getting rid of terr
Run the following commands in a folder to recursively get rid of terr in all
its files (make sure `goimports`[^2] is installed first):
its files (make sure `goimports`[^4] is installed first):
```sh
$ gofmt -w -r 'terr.Newf(a) -> errors.New(a)' .
$ gofmt -w -r 'terr.Newf -> fmt.Errorf' .
$ gofmt -w -r 'terr.Trace(a) -> a' .
$ gofmt -w -r 'terr.TraceWithLocation(a, b, c) -> a' .
$ goimports -w .
$ go mod tidy
```

## Under the hood
Starting with Go 1.20, wrapped errors are kept as a n-ary tree. terr works by
building a tree containing tracing information in parallel, leaving the Go
error tree untouched, as if terr weren't being used. Each traced error is thus
a node of this parallel traced error tree.

In the glorious day error tracing is added to Go, and assuming it gets done in
a way that respects error handling as defined in Go 1.13+,
[removing `terr` from a codebase](#getting-rid-of-terr) should be a matter of
replacing the `terr` function calls with the equivalent documented expressions.

To obtain the full trace, terr functions must be used consistently. If
`fmt.Errorf` is used at one point, the error tracing information will be reset
at that point, but Go's wrapped error tree will be preserved even in that case.

[^1]: https://go.dev/blog/go1.13-errors
[^2]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports
[^2]: https://pkg.go.dev/github.com/alnvdl/terr#example-package
[^3]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceWithLocation
[^4]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports
17 changes: 17 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package terr_test

import (
"fmt"

"github.com/alnvdl/terr"
)

// This example shows how to call different terr functions and print a traced
// error tree at the end.
func Example() {
err := terr.Newf("base")
traced := terr.Trace(err)
wrapped := terr.Newf("wrapped: %w", traced)
masked := terr.Newf("masked: %v", wrapped)
fmt.Printf("%@\n", masked)
}
29 changes: 29 additions & 0 deletions example_tracewithlocation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package terr_test

import (
"fmt"
"runtime"

"github.com/alnvdl/terr"
)

type ValidationError struct{ msg string }

func (e *ValidationError) Error() string {
return e.msg
}

func NewValidationError(msg string) error {
// Custom errors can define a location for the error. In this case, we set
// it to the location of the caller of this function.
_, file, line, _ := runtime.Caller(1)
return terr.TraceWithLocation(&ValidationError{msg}, file, line)
}

// This example shows how to adding tracing information to custom error types
// using TraceWithLocation.
func ExampleTraceWithLocation() {
// err will be annotated with the line number of the next line.
err := NewValidationError("x must be >= 0")
fmt.Printf("%@\n", err)
}
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module github.com/alnvdl/terr

retract v1.0.0 // Published with go 1.13 directive.
retract v1.0.1 // Published with README typo, v1.0.0 retract did not work.
retract [v0.0.0, v1.0.7] // Incomplete and/or wrong releases.

go 1.20
92 changes: 52 additions & 40 deletions terr.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,48 +9,45 @@ import (
"strings"
)

// TracedError is a wrapper for error that can be used to keep a tree of
// tracing information for related errors.
// TracedError is an error with tracing information (its location) and possibly
// other related errors, forming a tree of traced errors.
type TracedError interface {
// error is the underlying error.
error
// Location returns a string in the format "file:line" pointing to the
// location in the code where the error was created, traced, wrapped or
// masked.
Location() string
// Location identifies the file and line where error was created, traced,
// wrapped or masked.
Location() (string, int)
// Children returns other traced errors that were traced, wrapped or
// masked by this traced error.
Children() []TracedError
}

// tracedError is an error with a file:line location and pointer to the
// traced errors that precedes it in the chain (if any).
// tracedError implements the TracedError interface while being compatible with
// functions from the standard library "errors" package.
type tracedError struct {
err error
loc string
terrs []TracedError
error
location
children []TracedError
}

type location struct {
file string
line int
}

// newTracedError builds a traced error for err and its children traced errors
// (whether passed, wrapped or masked).
func newTracedError(err error, children []error) error {
func getCallerLocation() location {
_, file, line, _ := runtime.Caller(2)
return location{file, line}
}

func newTracedError(err error, children []error, loc location) error {
var terrs []TracedError
for _, child := range children {
if terr, ok := child.(*tracedError); ok {
if terr, ok := child.(TracedError); ok {
terrs = append(terrs, terr)
}
}

return &tracedError{
err: err,
loc: getLocation(2),
terrs: terrs,
}
}

func getLocation(depth int) string {
_, file, line, _ := runtime.Caller(depth + 1)
return fmt.Sprintf("%s:%d", file, line)
return &tracedError{err, loc, terrs}
}

func filterErrors(objs []interface{}) []error {
Expand All @@ -65,32 +62,32 @@ func filterErrors(objs []interface{}) []error {

// Is returns whether the error is another error for use with errors.Is.
func (e *tracedError) Is(target error) bool {
return errors.Is(e.err, target)
return errors.Is(e.error, target)
}

// As returns the error as another error for use with errors.As.
func (e *tracedError) As(target interface{}) bool {
return errors.As(e.err, target)
return errors.As(e.error, target)
}

// Unwrap returns the wrapped error for use with errors.Unwrap.
func (e *tracedError) Unwrap() error {
return errors.Unwrap(e.err)
return errors.Unwrap(e.error)
}

// Error implements the error interface.
func (e *tracedError) Error() string {
return e.err.Error()
return e.error.Error()
}

// Location implements the TracedError interface.
func (e *tracedError) Location() string {
return e.loc
func (e *tracedError) Location() (string, int) {
return e.file, e.line
}

// Children implements the TracedError interface.
func (e *tracedError) Children() []TracedError {
return e.terrs
return e.children
}

// Format implements fmt.Formatter.
Expand All @@ -99,21 +96,22 @@ func (e *tracedError) Format(f fmt.State, verb rune) {
fmt.Fprint(f, strings.Join(treeRepr(e, 0), "\n"))
return
}
fmt.Fprintf(f, fmt.FormatString(f, verb), e.err)
fmt.Fprintf(f, fmt.FormatString(f, verb), e.error)
}

// treeRepr returns human-readable lines representing an error tree rooted in
// err.
// treeRepr returns a tab-indented, multi-line representation of an error tree
// rooted in err.
func treeRepr(err error, depth int) []string {
var locations []string
te := err.(TracedError)
// No need to check the cast was successful: treeRepr is only invoked
// internally via tracedError.Format. If that pre-condition is ever
// violated, a panic is warranted.
file, line := te.Location()
locations = append(locations, fmt.Sprintf("%s%s @ %s",
strings.Repeat("\t", depth),
te.Error(),
te.Location()))
fmt.Sprintf("%s:%d", file, line)))
children := te.Children()
for _, child := range children {
locations = append(locations, treeRepr(child, depth+1)...)
Expand All @@ -127,7 +125,11 @@ func treeRepr(err error, depth int) []string {
// Implements the pattern fmt.Errorf("...", ...). If used without verbs and
// additional arguments, it also implements the pattern errors.New("...").
func Newf(format string, a ...interface{}) error {
return newTracedError(fmt.Errorf(format, a...), filterErrors(a))
return newTracedError(
fmt.Errorf(format, a...),
filterErrors(a),
getCallerLocation(),
)
}

// Trace returns a new traced error for err. If err is already a traced error,
Expand All @@ -137,11 +139,21 @@ func Trace(err error) error {
if err == nil {
return nil
}
return newTracedError(err, []error{err})
return newTracedError(err, []error{err}, getCallerLocation())
}

// TraceWithLocation works like Trace, but lets the caller specify a file and
// line for the error. This is most useful for custom error constructor
// functions, so they can return a traced error pointing at their callers.
func TraceWithLocation(err error, file string, line int) error {
if err == nil {
return nil
}
return newTracedError(err, []error{err}, location{file, line})
}

// TraceTree returns the root of the n-ary traced error tree for err. Returns
// nil if err is nil.
// nil if err is nil or not a traced error.
// Presenting these arbitrarily complex error trees in human-comprehensible way
// is left as an exercise to the caller. Or just use fmt.Sprintf("%@", err) for
// a tab-indented multi-line string representation of the tree.
Expand Down
Loading

0 comments on commit 3a1f56f

Please sign in to comment.