Skip to content

Commit

Permalink
Clearly differentiate error tracing trees from traced errors
Browse files Browse the repository at this point in the history
  • Loading branch information
alnvdl committed Mar 12, 2023
1 parent 983b7e1 commit 9f1e493
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 94 deletions.
80 changes: 40 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/alnvdl/terr.svg)](https://pkg.go.dev/github.com/alnvdl/terr)
[![Test workflow](https://github.com/alnvdl/terr/actions/workflows/test.yaml/badge.svg)](https://github.com/alnvdl/terr/actions/workflows/test.yaml)

terr (short for **t**raced **err**or) is a minimalistic package for adding
terr (short for **t**raced **err**or) is a minimalistic library for adding
error tracing to Go 1.20+.

Go's error representation primitives introduced in Go 1.13[^1] are quite
The error representation primitives introduced in Go 1.13[^1] are quite
sufficient, but the lack of tracing capabilities makes it hard to confidently
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 with the `%@` verb;
- the ability to print error tracing trees using the `fmt` package with the
`%@` verb;

This package introduces the concept of **traced errors**: a wrapper for errors
that includes the location where they were created (`errors.New`), passed along
(`return err`), wrapped (`%w`) or masked (`%v`). Traced errors keep track of
children traced errors that relate to them. An error is a traced error if it
was returned by one of the functions of this package.

Traced errors work seamlessly with `errors.Is`, `errors.As` and `errors.Unwrap`
just as if terr were not being used.
Most importantnly, 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
Expand All @@ -34,11 +35,11 @@ Without terr | With terr

`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.
included in the error tracing tree, regardless of the `fmt` verb used for it.

`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.Trace` and `terr.TraceWithLocation` on the other hand do nothing with the
error they receive (no wrapping and no masking), but they add one level to the
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
Expand All @@ -49,53 +50,53 @@ Examples are available showing all these functions in use[^2].
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.

### Printing errors
A traced error tree can be printed with the special `%@` formatting verb. An
example is available[^3].

`%@` 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).
replacing the `terr` function calls with equivalent expressions.

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

### Printing errors
An error tracing tree can be printed with the special `%@` formatting verb. An
example is available[^4].

`%@` prints the 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 tracing tree and outputs it in the desired format. See the
[next subsection](#walking-the-error-tracing-tree).

### Walking the traced error tree
### Walking the error tracing tree
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.
a node in this parallel error tracing 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:
`terr.TraceTree(err) ErrorTracer` can be used to obtain the root of an n-ary
error tracing tree, which can be navigated using the following methods:
```go
type TracedError interface {
type ErrorTracer interface {
Error() string
Location() (string, int)
Children() []TracedError
Children() []ErrorTracer
}
```

Note that this is **not** the tree of wrapped errors built by Go's standard
Note that this is **not** the tree of wrapped errors built by the Go standard
library, because:
- if non-traced errors are provided to `terr.Newf`, even if wrapped, they will
not be a part of the traced error tree;
- even masked (`%v`) errors will be part of the traced error tree if
not be a part of the error tracing tree;
- even masked (`%v`) errors will be part of the error tracing tree if
`terr.Newf` was used to mask them.

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
wrapped error tree, which would include non-traced errors and ignore masked
errors (e.g., `errors.Unwrap`).

An example is available[^5].

### Adopting terr
Run the following commands in a folder to recursively adopt terr in all its
files (make sure `goimports`[^6] is installed first):
Run the following commands in a directory tree to adopt terr in all its files
(make sure `goimports`[^6] is installed first):
```sh
$ go get github.com/alnvdl/terr
$ gofmt -w -r 'errors.New(a) -> terr.Newf(a)' .
Expand All @@ -104,17 +105,16 @@ $ goimports -w .
```

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:
impossible to write a simple gofmt rewrite rule that works for all cases. So
these two functions have to be applied by hand following these guidelines:
- `return err` becomes `return terr.Trace(err)`;
- `return NewCustomErr()` requires an adaptation in `NewCustomErr` to use
`terr.TraceWithLocation`. An example is available[^4].
`terr.TraceWithLocation`. An example is available[^3].
`return terr.TraceWithLocation(NewCustomErr())`.

### Getting rid of terr
Run the following commands in a folder to recursively get rid of terr in all
its files (make sure `goimports`[^6] is installed first):
Run the following commands in a directory tree to get rid of terr in all its
files (make sure `goimports`[^6] is installed first):
```sh
$ gofmt -w -r 'terr.Newf(a) -> errors.New(a)' .
$ gofmt -w -r 'terr.Newf -> fmt.Errorf' .
Expand All @@ -126,7 +126,7 @@ $ go mod tidy

[^1]: https://go.dev/blog/go1.13-errors
[^2]: https://pkg.go.dev/github.com/alnvdl/terr#pkg-examples
[^3]: https://pkg.go.dev/github.com/alnvdl/terr#example-package
[^4]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceWithLocation
[^3]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceWithLocation
[^4]: https://pkg.go.dev/github.com/alnvdl/terr#example-package
[^5]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceTree
[^6]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports
29 changes: 16 additions & 13 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"github.com/alnvdl/terr"
)

// This example shows how to combine different terr functions and print a
// traced error tree at the end.
// This example shows how to combine different terr functions and print an
// error tracing tree at the end.
func Example() {
err := terr.Newf("base")
traced := terr.Trace(err)
Expand All @@ -18,10 +18,10 @@ func Example() {
fmt.Printf("%@\n", masked)
}

// This example shows how Newf interacts with a non-traced error compared to
// when it receives a traced error. Traced errors are included in the trace
// regardless of the fmt verb used for them, while non-traced errors are
// handled as usual, but do not get included in the trace.
// This example shows how Newf interacts with traced and non-traced errors.
// Traced errors are included in the trace regardless of the fmt verb used for
// them, while non-traced errors are handled as fmt.Errorf would, but they do
// not get included in the trace.
func ExampleNewf() {
nonTracedErr := errors.New("non-traced")
tracedErr1 := terr.Newf("traced 1")
Expand All @@ -40,12 +40,14 @@ func ExampleNewf() {
fmt.Println("newErr is tracedErr2:", errors.Is(newErr, tracedErr2))
}

// This example shows how terr.Trace interacts with a non-traced error compared
// to when it receives a traced error.
// This example shows how terr.Trace interacts with traced and non-traced
// errors.
func ExampleTrace() {
// Adds tracing information to non-traced errors.
nonTracedErr := errors.New("non-traced")
fmt.Printf("%@\n", terr.Trace(nonTracedErr))
fmt.Println("---")
// Adds another level of tracing information to traced errors.
tracedErr := terr.Newf("traced")
fmt.Printf("%@\n", terr.Trace(tracedErr))
}
Expand All @@ -62,9 +64,10 @@ func NewValidationError(msg string) error {
}

// This example shows how to adding tracing information to custom error types
// using TraceWithLocation. Custom errors constructors like NewValidationError
// can define a location for the errors they return. In this case, that
// location is being set it to the location of the NewValidationError caller.
// using TraceWithLocation. Custom error type constructors like
// NewValidationError can define a location for the errors they return. In this
// case, the location is being set it to the location of the NewValidationError
// caller.
func ExampleTraceWithLocation() {
// err will be annotated with the line number of the following line.
err := NewValidationError("x must be >= 0")
Expand All @@ -78,7 +81,7 @@ func ExampleTraceWithLocation() {
fmt.Println("Custom error message:", customErr.msg)
}

// This example shows how to use the n-ary traced error tree returned by
// This example shows how to use the n-ary error tracing tree returned by
// terr.TraceTree.
func ExampleTraceTree() {
nonTracedErr := errors.New("non-traced")
Expand All @@ -89,7 +92,7 @@ func ExampleTraceTree() {
tracedErr1,
tracedErr2)

printNode := func(node terr.TracedError) {
printNode := func(node terr.ErrorTracer) {
fmt.Printf("Error: %v\n", node.Error())
file, line := node.Location()
fmt.Printf("Location: %s:%d\n", file, line)
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module github.com/alnvdl/terr

retract [v0.0.0, v1.0.10] // Incomplete and/or incorrect release.
// Incomplete and/or incorrect release (should have been v0).
retract [v1.0.0, v1.0.11]

go 1.20
76 changes: 40 additions & 36 deletions terr.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Package terr implements a minimalistic library for adding error tracing to
// Go 1.20+.
// Package terr implements a set of functions for tracing errors in Go 1.20+.
package terr

import (
Expand All @@ -9,25 +8,13 @@ import (
"strings"
)

// 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 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 implements the TracedError interface while being compatible with
// functions from the "errors" package in the standard library.
// tracedError implements the error and ErrorTracer interfaces, while being
// compatible with functions from the "errors" and "fmt" package in the
// standard library by implementing Is, As, Unwrap and Format.
type tracedError struct {
error
location
children []TracedError
children []ErrorTracer
}

type location struct {
Expand All @@ -41,9 +28,9 @@ func getCallerLocation() location {
}

func newTracedError(err error, children []error, loc location) error {
var terrs []TracedError
var terrs []ErrorTracer
for _, child := range children {
if terr, ok := child.(TracedError); ok {
if terr, ok := child.(*tracedError); ok {
terrs = append(terrs, terr)
}
}
Expand Down Expand Up @@ -80,13 +67,13 @@ func (e *tracedError) Error() string {
return e.error.Error()
}

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

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

Expand All @@ -99,11 +86,11 @@ func (e *tracedError) Format(f fmt.State, verb rune) {
fmt.Fprintf(f, fmt.FormatString(f, verb), e.error)
}

// treeRepr returns a tab-indented, multi-line representation of an error tree
// rooted in err.
// treeRepr returns a tab-indented, multi-line representation of a traced error
// tree rooted in err.
func treeRepr(err error, depth int) []string {
var locations []string
te := err.(TracedError)
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.
Expand All @@ -122,8 +109,8 @@ func treeRepr(err error, depth int) []string {
// Newf works exactly like fmt.Errorf, but returns a traced error. All traced
// errors passed as formatting arguments are included as children, regardless
// of the formatting verbs used for these errors.
// Implements the pattern fmt.Errorf("...", ...). If used without verbs and
// additional arguments, it also implements the pattern errors.New("...").
// This function is equivalent to fmt.Errorf("...", ...). If used without verbs
// and additional arguments, it is equivalent to errors.New("...").
func Newf(format string, a ...interface{}) error {
return newTracedError(
fmt.Errorf(format, a...),
Expand All @@ -143,7 +130,7 @@ func Trace(err error) error {
}

// TraceWithLocation works like Trace, but lets the caller specify a file and
// line for the error. This is most useful for custom error constructor
// line for the error. This function can be used in 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 {
Expand All @@ -152,12 +139,29 @@ func TraceWithLocation(err error, file string, line int) error {
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 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.
func TraceTree(err error) TracedError {
te, _ := err.(TracedError)
// ErrorTracer is an object capable of tracing an error's location and possibly
// other related errors, forming an error tracing tree.
// Please note that implementing ErrorTracer is not enough to make an error
// a traced error: only errors returned by functions in this package are
// considered traced errors.
type ErrorTracer interface {
// error is the underlying error.
error
// 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() []ErrorTracer
}

// TraceTree returns the root of the n-ary error tracing tree for err. Returns
// nil if err is not a traced error. This function can be used to represent the
// error tracing tree using custom formats.
func TraceTree(err error) ErrorTracer {
te, _ := err.(*tracedError)
if te == nil {
return nil
}
return te
}
Loading

0 comments on commit 9f1e493

Please sign in to comment.