trackerr

package module
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Jun 3, 2023 License: MIT Imports: 3 Imported by: 13

README

Trackerr

Package trackerr aims to facilitate creation of referenceable errors and elegant stack traces.

It was crafted in frustration trying to navigate Go's printed error stacks and the challenge of reliably asserting specific error types while testing.

I hope the code speaks mostly for itself so you don't have to trawl through my ramblings.

API

import (
	// Package imported is just called 'trackerr' 
	"github.com/PaulioRandall/go-trackerr"
)

Please note: TrackedError and UntrackedError are structs but I've specified them here as interfaces for documentation purposes.

var (
    // ErrTodo for specifying a TODO.
    ErrTodo = New("TODO: Implementation needed")

    // ErrBug for the site of known bugs
    ErrBug = New("BUG: Fix needed")

    // ErrInsane for sanity checking.
    ErrInsane = New("Sanity check failed!!")
)

func New(msg string, args ...any) TrackedError {}
func Track(msg string, args ...any) TrackedError {}
func Untracked(msg string, args ...any) UntrackedError {}

func All(e error, targets ...error) bool
func AllOrdered(e error, targets ...error) bool
func Any(e error, targets ...error) bool
func HasTracked(e error) bool
func Is(e, target error) bool
func IsTracked(e error) bool
func IsTrackerr(e error) bool
func Unwrap(e error) error

func Stack(rootCause error, errs ...ErrorThatWraps) error
func SliceStack(e error) []error
func Squash(e error) error
func Squashf(e error, f ErrorFormatter) error
func ErrorStack(e error) string
func ErrorStackf(e error, f ErrorFormatter) string
func ErrorWithoutCause(e error) string

func Debug(e error) (int, error)
func DebugPanic(catch *error)

func Initialised()

type ErrorFormatter func(errMsg string, e error, isFirst bool) string

type ErrorThatWraps interface {
	error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error
}

type TrackedError interface { // Actually a struct in code
	ErrorThatWraps

	Error() string

	Because(msg string, args ...any) error
	BecauseOf(rootCause error, msg string, args ...any) error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error

	Is(error) bool
	Unwrap() error
}

type UntrackedError interface { // Actually a struct in code
	ErrorThatWraps

	Error() string

	Because(msg string, args ...any) error
	BecauseOf(rootCause error, msg string, args ...any) error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error

	Unwrap() error
}

type Realm interface {
	New(msg string, args ...any) *TrackedError
	Track(msg string, args ...any) *TrackedError
}

type IntRealm struct {}

Tracked errors should be package variables

It's important to define errors created via New and Track as package scooped (global) or you won't be able to reference them. It is not recommended to create trackable errors after initialisation but Realms exist for such cases.

Wrapping errors

You can return a tracked or untracked error directly but it's recommended to call one of the receiving functions CausedBy, Because, BecauseOf, or ContextFor with additional information.

var (
	ErrLoadingData = trackerr.New("Failed to load data")
	ErrOpeningDatabase = trackerr.New("Could not open database")

	dbFile = "./data/db.sqlite"
)

func Err() error {
	return ErrLoadingData
}

func CausedBy() error {
	return ErrLoadingData.CausedBy(ErrOpeningDatabase)
}

func Because() error {
	return ErrLoadingData.Because("Database file '%s' not found", dbFile)
}

func BecauseOf() error {
	e := trackerr.Untracked("Database file '%s' not found", dbFile)
	return ErrLoadingData.BecauseOf(e, "Could not open database")
}

func ContextFor() error {
	e := trackerr.Untracked("Database file '%s' not found", dbFile)
	return ErrLoadingData.ContextFor(ErrOpeningDatabase, e)
}

Prevent creating tracked errors after program initialisation

It's also recommended to call Initialised from an init function in package main to prevent the creation of trackable errors after program initialisation.

package main

import (
	"github.com/PaulioRandall/go-trackerr"
)

var ErrForNoReason = trackerr.New("Failed for no reason")

func init() {
	trackerr.Initialised()
}

func main() {
	// Bad, will panic
	e = trackerr.New("I felt like it")

	_ = e
}

Debugging

For manual debugging there's trackerr.Debug which will print a readable stack trace.

func Debug() {
	a := trackerr.UntrackedError("Failed to load data")
	b := trackerr.UntrackedError("Could not open database")
	c := trackerr.UntrackedError("Database file not found")

	e := Stack(a, b, c)

	trackerr.Debug(e)

	// [DEBUG ERROR]
	// Failed to load data
	// ⤷ Could not open database
	// ⤷ Database file not found
}

Alternatively the deferable trackerr.DebugPanic(nil) will recover from a panic, print the error (if it is one), then resume the panic.

func DebugPanic() {
	defer trackerr.DebugPanic(nil)

	a := trackerr.UntrackedError("Failed to load data")
	b := trackerr.UntrackedError("Could not open database")
	c := trackerr.UntrackedError("Database file not found")

	e := Stack(a, b, c)
	panic(e)

	// [DEBUG ERROR]
	// Failed to load data
	// ⤷ Could not open database
	// ⤷ Database file not found
}

Passing a pointer to an error trackerr.DebugPanic(&e) will prevent the panic resuming and instead set it as the value pointed to by the pointer.

func DebugPanic() (e error) {
	defer trackerr.DebugPanic(&e)

	...
}

Custom errors

You may also craft your own error types and wrap or be wrapped by trackerr errors.

type myError struct {
	msg string
	cause error
}

func (e myError) CausedBy(other error) error {
	e.cause = other
	return e
}

func (e myError) Unwrap() error {
	return e.cause
}

var (
	ErrLoadingData = trackerr.New("Failed to load data")
	ErrFileNotFound = trackerr.New("Database file not found")
)

func main() {
	e := myError{ msg: "Could not open database" }
	e = ErrLoadingData.ContextFor(ErrFileNotFound, e)
	_ = e
}
Testing

One place trackerr becomes useful is when asserting errors in tests.

Trackerr assigns errors there own private unique identifiers which are used for comparison by errors.Is and trackerr's utility functions. This separates the concerns of communicating with humans from asserting that specific errors occur when they should.

// csvreader.go

import (
	"errors"
)

var ErrParsingCSV = trackerr.New("Could not parse CSV")

func ReadCSV(file string) error {
	...

	return ErrParsingCSV
}
// csvreader_test.go

import (
	"errors"
	"testing"
)

func TestReadCSV_InvalidFormat(t *testing.T) {
	e := ReadCSV("/path/to/csv/file")
	
	if !errors.Is(e, ErrParsingCSV) {
		t.Log("Expected ErrParsingCSV error")
		t.Fail()
	}
}

Design decisions

The design is largely usage lead and thus somewhat emergent. That is, I had projects requiring trackable errors to which I crafted structures and functions based on need.

Composition > Framing

The package is designed to work in a compositional manner such that trackerr.New, trackerr.Track, and errors.new can be exchanged incrementally. Engineers may compose all their errors using trackerr or just the few that require tracking. Most of trackerr's utility functions work on the error interface so the underlying error types matter little.

Composition is favoured over framing, when feasible, so the power to change and adapt, with needs and the times, remains in the hands of the consuming engineers. In so much as possible, minimising the my way or the highway mentality which is core to commercial software but also rampant in open source tooling.

If my package no longer provides value for cost or if something better appears then it should be incrementally removable or replacable. I find that a good design is one that can change easily. My preference for changability, Continuous Integration (CI), and Continuous Delivery (CD) certainly influenced these decisions.

Why not string equality?

Many programmers test assert using error messages (strings) but I've found this to be unreliable, reduces changability, and leaves me feeling less than confident in my code; and testing is all about gaining confidence.

Communicating aaccurate and relevant information to humans can be quite a fraught affair so I'd like to maximise the ease of improving and rewriting error messages without having to worry about breaking tests.

Why not pointer equality?

Comparing pointers is better than comparing text but this means package scooped errors must be immutable, thus cannot have a cause attached to them or be wrapped. The receiving functions of TrackedError and UntrackedError produce copies of themselves (including their IDs) that allows the attachment of causes while keeping the equality checking. errors.Is(copy, original) still returns true as private unique identifiers are compared, not string messages or pointers.

Unfortunately, this means copy == original will always return false. This is not much of a sacrifice as error pointer comparisons lost favour with the introduction of error wrapping (Go 1.13). Use errors.Is, trackerr.Is, or one of trackerr's other utility functions instead.

Checking out (in both senses)

git clone https://github.com/PaulioRandall/go-trackerr.git
cd go-trackerr

Standard Go commands can be used from here but my ./godo script eases things:

./godo [help]   # Print usage
./godo doc[s]   # Fire up documentation server
./godo clean    # Clean Go caches
./godo test     # fmt -> test -> vet

Documentation

Overview

Package trackerr aims to facilitate creation of referenceable errors and elegant stack traces.

It was crafted in frustration trying to navigate Go's printed error stacks and the challenge of reliably asserting specific error types while testing.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrTodo is a convenience trackable error for specifying a TODO.
	//
	// This can be useful if you're taking a stepwise refinement or test driven
	// approach to writing code.
	ErrTodo = New("TODO: Implementation needed")

	// ErrBug is a convenience trackable error for use at the site of known bugs.
	ErrBug = New("BUG: Fix needed")

	// ErrInsane is a convenience trackable error for sanity checking.
	ErrInsane = New("Sanity check failed!!")
)

Functions

func All

func All(e error, targets ...error) bool

All returns true only if errors.Is returns true for all targets.

func AllOrdered added in v0.13.0

func AllOrdered(e error, targets ...error) bool

AllOrdered returns true only if errors.Is returns true for all targets and the order of the targets matches the descending order of errors.

This does not mean the depth of the error stack must be the same as the number of targets. If three targets are supplied then true is returned if during descent of the error stack:

THE three targets exist in the error stack
AND first target is found first
AND the second target is found second
And the third target is found last

func Any

func Any(e error, targets ...error) bool

Any returns true if errors.Is returns true for at least one target.

func Debug

func Debug(e error) (int, error)

Debug pretty prints the error stack trace to terminal for debugging purposes.

If e is nil then a message will be printed indicating so. This function is not designed for logging, just day to day manual debugging.

func DebugPanic

func DebugPanic(catch *error)

DebugPanic recovers from a panic, prints out the error using the Debug function, and finally sets it as the catch error's pointer value.

If nil is passed as the catch then the panic continues after printing.

If the panic value is not an error the panic will continue!

This function is not designed for logging, just day to day manual debugging.

func ErrorStack

func ErrorStack(e error) string

ErrorStack calls ErrorStackf with simple default formatting.

Workflow error
⤷ Failed to read data
⤷ Error handling CSV file
⤷ open splay/example/data/acid-rain.csv
⤷ no such file or directory

func ErrorStackf added in v0.12.0

func ErrorStackf(e error, f ErrorFormatter) string

ErrorStackf returns a human readable stack trace for the error. The format function f may be nil for no formatting.

alice := trackerr.Untracked("Alice's message")
bob := trackerr.Checkpoint(alice, "Bob's message")
charlie := trackerr.Wrap(bob, "Charlie's message")
dan := trackerr.Wrap(charlie, "Dan's message")

s := trackerr.ErrorStackf(e, func(errMsg string, err error, isFirst bool) string {
	if isFirst {
		return "ERROR: " + errMsg
	}
	if trackerr.IsCheckpoint(err) {
		return "*** " + errMsg + " ***"
	}
	return "Caused by: " + errMsg
}

// ERROR: Dan's message
// Caused by: Charlie's message
// *** Bob's message ***
// Caused by: Alice's message

func ErrorWithoutCause

func ErrorWithoutCause(e error) string

ErrorWithoutCause removes the cause from error messages that use the format '%s: %w'. Where s is the error message and w is the cause's message.

func HasTracked

func HasTracked(e error) bool

HasTracked returns true if the error or one of the underlying causes are tracked, i.e. those created via the New or Track functions.

func Initialised added in v0.12.0

func Initialised()

Initialised causes all future calls to New or Track to panic.

When called from an init function in the main package, it prevents creation of trackable errors after program initialisation.

package main

import "github.com/PaulioRandall/go-trackerr"

func init() {
	trackerr.Initialised()
}

func Is

func Is(e, target error) bool

Is is a proxy for errors.Is.

func IsTracked

func IsTracked(e error) bool

IsTracked returns true if the error is being tracked, i.e. those created via the New or Track functions.

func IsTrackerr added in v0.12.0

func IsTrackerr(e error) bool

IsTrackerr returns true if the error is either an UntrackedError or TrackedError from this package. That is, if it's an error defined by go-trackerr.

func SliceStack added in v0.17.0

func SliceStack(e error) []error

SliceStack recursively unwraps the error returning a slice of errors. The passed error e will be first and root cause last.

charlie := trackerr.Untracked("Charlie's message")
bob := trackerr.Wrap(charlie, "Bob's message")
alice := trackerr.Wrap(bob, "Alice's message")

result := SliceStack(alice)

// result: [
// 	alice,
// 	bob,
// 	charlie,
// ]

func Squash added in v0.19.0

func Squash(e error) error

Squash calls trackerr.ErrorStack with the error e then uses the result as the message for a new error; which is returned.

func Squashf added in v0.19.0

func Squashf(e error, f ErrorFormatter) error

Squashf is the same as squash but allows an ErrorFormatter to be used to format the error stack string.

func Stack added in v0.15.0

func Stack(e error, errs ...ErrorThatWraps) error

Stack accepts a an array of ErrorWrappers and converts it into a stack trace by recursively calling CasuedBy.

The first item is the root cause and the last item the head.

head := trackerr.New("head message")
mid := trackerr.New("mid level message")
root := trackerr.New("root cause message")

e := Stack(root, mid, head)

// head message
// ⤷ mid level message
// ⤷ root cause message

func Unwrap added in v0.13.0

func Unwrap(e error) error

Unwrap is a proxy for errors.Unwrap.

Types

type ErrorFormatter added in v0.12.0

type ErrorFormatter func(errMsg string, e error, isFirst bool) string

ErrorFormatter formats an error for stack trace printing.

Each error string will be printed on a line of its own so implementations should not prefix or suffix a linefeed unless they want gappy print outs.

type ErrorThatWraps added in v0.16.0

type ErrorThatWraps interface {
	error

	// CausedBy wraps the rootCause within the first item in causes. Then the
	// second item in causes wraps the first. Then the third item wraps the
	// second and so on. Finally, the receiving error wraps the result before
	// returning.
	CausedBy(rootCause error, causes ...ErrorThatWraps) error

	// Unwrap returns the error's underlying cause or nil if none exists.
	Unwrap() error
}

ErrorThatWraps represents an error that wraps new untracked errors.

type IntRealm

type IntRealm struct {
	// contains filtered or unexported fields
}

IntRealm is a Realm that uses a simple incrementing integer field as the pool of unique IDs.

realm := IntRealm{}

The recommended way to use this package is to ignore this struct and use the New or Track package functions. If this package's API is used as intended then it would be impossible to cause an integer overflow scenario in any real world use case. However, Realms were conceived for such an event and for those who really hate the idea of relying on a singleton they have no control over.

func (*IntRealm) New added in v0.15.0

func (r *IntRealm) New(msg string, args ...any) *TrackedError

New is an alias for Track.

func (*IntRealm) Track

func (r *IntRealm) Track(msg string, args ...any) *TrackedError

Track returns a new tracked error.

Calls to HasTracked, IsTracked, and IsTrackerr will all return true when the error is passed to them.

type Realm

type Realm interface {

	// New is an alias for Track.
	New(msg string, args ...any) *TrackedError

	// Track returns a new tracked error, that is, one with a tracking ID.
	Track(msg string, args ...any) *TrackedError
}

Realm represents a space where each trackable error (stack trace node) has its own unique ID.

There is a private package scooped Realm that will suffice for most purposes. It is used via the package scooped Track functions.

Receiving functions are designed to be called during package initialisation. This means it should only be used to initialise package global variables and within init functions. The exception is where Realms are in use.

Furthermore, all functions return a shallow copy of any passed or receiving errors creating a somewhat immutability based ecosystem.

This interface is primarily for documentation.

type TrackedError

type TrackedError struct {
	// contains filtered or unexported fields
}

TrackedError represents a trackable node in an error stack.

func New added in v0.15.0

func New(msg string, args ...any) *TrackedError

New is an alias for Track.

func Track

func Track(msg string, args ...any) *TrackedError

Track returns a new tracked error from this package's global Realm.

This is the recommended way to use to create all trackable errors.

func (TrackedError) Because added in v0.13.0

func (e TrackedError) Because(msg string, args ...any) error

Because constructs a cause from msg and args.

wrapper := trackerr.New("wrapper message")

e := wrapper.Because("cause message")

```
wrapper message
⤷ cause message
```

func (TrackedError) BecauseOf

func (e TrackedError) BecauseOf(rootCause error, msg string, args ...any) error

BecauseOf creates a new error using the msg, args, and cause as arguments then attaches the result as the cause of the receiving error.

Put another way, the cause argument becomes the root cause in the error stack.

top := trackerr.New("top message")
rootCause := trackerr.New("root cause message")

e := top.BecauseOf(rootCause, "middle message")

```
top message
⤷ middle message
⤷ root cause message
```

func (TrackedError) CausedBy added in v0.13.0

func (e TrackedError) CausedBy(rootCause error, causes ...ErrorThatWraps) error

CausedBy wraps the rootCause within the first item in causes. Then the second item in causes wraps the first. Then the third item wraps the second and so on. Finally, the receiving error wraps the result before returning.

head := trackerr.New("head message")
causeA := trackerr.New("cause message A")
causeB := trackerr.New("cause message B")
rootCause := trackerr.Untracked("root cause message")

e := head.CausedBy(rootCause, causeB, causeA)

```
head message
⤷ cause message A
⤷ cause message B
⤷ root cause message
```

CausedBy will very often be used to wrap a single error.

head := trackerr.New("head message")
cause := trackerr.Untracked("cause message")

e := head.CausedBy(cause)

```
head message
⤷ cause message
```

func (TrackedError) Error added in v0.13.0

func (e TrackedError) Error() string

Error satisfies the error interface.

func (TrackedError) Is

func (e TrackedError) Is(other error) bool

Is returns true if the passed error is equivalent to the receiving error. This is a shallow comparison so causes are not checked.

It satisfies the Is function referenced by errors.Is in the standard errors package.

func (TrackedError) Unwrap added in v0.13.0

func (e TrackedError) Unwrap() error

Unwrap returns the error's underlying cause or nil if none exists.

It is designed to work with errors.Is exposed by the standard errors package.

type UntrackedError

type UntrackedError struct {
	// contains filtered or unexported fields
}

UntrackedError represents an untracked error in an error stack.

func Untracked

func Untracked(msg string, args ...any) *UntrackedError

Untracked returns a new error without a tracking ID.

This is the same as calling errors.New except for the handy fmt.Sprintf function signature and the resultant error has a few extra receiving functions for attaching causal errors.

func (UntrackedError) Because

func (e UntrackedError) Because(msg string, args ...any) error

Because constructs a cause from msg and args.

wrapper := trackerr.New("wrapper message")

e := wrapper.Because("cause message")

```
wrapper message
⤷ cause message
```

func (UntrackedError) BecauseOf added in v0.12.0

func (e UntrackedError) BecauseOf(rootCause error, msg string, args ...any) error

BecauseOf creates a new error using the msg, args, and cause as arguments then attaches the result as the cause of the receiving error.

Put another way, the cause argument becomes the root cause in the error stack.

top := trackerr.New("top message")
rootCause := trackerr.New("root cause message")

e := top.BecauseOf(rootCause, "middle message")

```
top message
⤷ middle message
⤷ root cause message
```

func (UntrackedError) CausedBy

func (e UntrackedError) CausedBy(rootCause error, causes ...ErrorThatWraps) error

CausedBy wraps the rootCause within the first item in causes. Then the second item in causes wraps the first. Then the third item wraps the second and so on. Finally, the receiving error wraps the result before returning.

head := trackerr.New("head message")
causeA := trackerr.New("cause message A")
causeB := trackerr.New("cause message B")
rootCause := trackerr.Untracked("root cause message")

e := head.CausedBy(rootCause, causeB, causeA)

```
head message
⤷ cause message A
⤷ cause message B
⤷ root cause message
```

CausedBy will very often be used to wrap a single error.

head := trackerr.New("head message")
cause := trackerr.Untracked("cause message")

e := head.CausedBy(cause)

```
head message
⤷ cause message
```

func (UntrackedError) Error added in v0.12.0

func (e UntrackedError) Error() string

Error satisfies the error interface.

func (UntrackedError) Unwrap

func (e UntrackedError) Unwrap() error

Unwrap returns the error's underlying cause or nil if none exists.

It is designed to work with errors.Is exposed by the standard errors package.

Jump to

Keyboard shortcuts

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