Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# needed for stacktrace tests
path: github.com/quantumcycle/metaerr

- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: "1.20"
go-version-file: ./github.com/quantumcycle/metaerr/go.mod
cache-dependency-path: ./github.com/quantumcycle/metaerr/go.sum
cache: true

- name: Test
working-directory: ./github.com/quantumcycle/metaerr
run: go test -covermode=atomic -coverprofile=coverage.out -v ./...

- uses: codecov/codecov-action@v3
with:
files: ./coverage.out
working-directory: ./github.com/quantumcycle/metaerr
files: ./github.com/quantumcycle/metaerr/coverage.out
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
108 changes: 77 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ Metaerr is a golang package to create or wrap errors with custom metadata and lo
## Why

I used github.com/pkg/errors before, and the stack traces were extensive (like Java) and not very useful. Then, I came across the [Fault library](https://github.com/Southclaws/fault), which was amazing, but the way I wanted to use it clashed with some of the opinions embedded in the library.
There is also [samber oops library](https://github.com/samber/oops), but the problem was that it's not extendable to have custom metadata.

This is why I decided to create this simple library. It utilizes the same "stack trace" model as Fault, in the sense that you will see the stack pertaining to the locations of error creation, and not the stack trace that led to the error's creation.
This is why I decided to create this simple library. It utilizes the same "stack trace" model as Fault, in the sense that you will see the stack pertaining to the locations of error creation, but also adds regular stacktraces on top of that as an option.

The next feature it offers is the ability to add any number of key-value metadata entries to each error, including wrapped errors. This is useful if you want to attach metadata at the time of error creation and then leverage that metadata during resolution. A common use case is having a generic HTTP error handler for an API that can use the metadata to determine the HTTP status or construct an error payload to send to the user. Another use case would be logging and alerting. If you convert the metadata into fields in a JSON logger, you could have different alerting rules for logged ERRORS based on the metadata; for example, errors with the metadata tag containing "security" could trigger an immediate alert.

Expand All @@ -24,6 +25,10 @@ go get -u github.com/quantumcycle/metaerr

Metaerr can be used with the Go standard errors package, and they are also compatible with error wrapping introduced in Go 1.13.

There is 2 ways to use the library. Using the errors directly, or using the builder. **The builder approach is recommended.**

### using the errors directly

To create an new MetaErr from a string, use

```golang
Expand All @@ -36,53 +41,73 @@ To create a new MetaErr by wrapping an existing error, use
err := metaerr.Wrap(err, "failure")
```

The next step, once you have a Metaerr, is to add metadata to it. You need to create a function that matches the `metaerr.ErrorMetadata` signature. For your convenience, you can use `metaerr.StringMeta`, but you can also create your own. Ultimately, all metadata entries are stored as strings.
The if you want to add metadata, you first create the metadata, and pass it as an option.

```golang
//Create an metadata called ErrorCode
var ErrorCode = metaerr.StringMeta("error_code")

func main() {
rootCause := metaerr.New("failure")
err := metaerr.Wrap(rootCause, "cannot fetch content").Meta(ErrorCode("x01"))
rootCause := metaerr.New("failure", metaerr.WithMeta(ErrorCode("x01"))
err := metaerr.Wrap(rootCause, "cannot fetch content")
fmt.Printf("%+v", err)
}
```

will print (... will be your project location)

will print
```
cannot fetch content [error_code=x01]
at .../quantumcycle/metaerr/cmd/main.go:13
failure
cannot fetch content
at .../quantumcycle/metaerr/cmd/main.go:12
failure [error_code=x01]
at .../quantumcycle/metaerr/cmd/main.go:11

```

### How to use this library in your project
### using the builder

You can start with the example above, but if for example you want stacktraces, having this code everywhere is not really convenient
Using the errors directly is ok, but it's a bit verbose just to create errors. Using the builder is a better approach
and reduce boilerplate.

To use the builder, just create an instance of the builder with the relevant options for you, and then use it.
```golang
var ErrorCode = metaerr.StringMeta("error_code")
package main

import (
"fmt"
"github.com/quantumcycle/metaerr"
)

...
...
...
var errors = metaerr.NewBuilder(metaerr.WithStackTrace(0, 2))
var ErrorCode = metaerr.StringMeta("error_code")

err := metaerr.New("failure", metaerr.WithStackTrace(0, 3)).Meta(ErrorCode("x01"))
func main() {
err := errors.Meta(ErrorCode("test")).Newf("failure with user %s", "test")
fmt.Printf("%+v\n", err)
}
```

To solve this, metaerr provides a base builder to help build your own error builder. You should create your own error builder in your project, adding any potential configuration options and metadata to the builder and then levering this to create your errors.
Look at [this file](./example/errors/builder.go) for an example of a builder with 2 possible metadata (errorCode and tags), and [this file](./example/main.go) as an example of using this builder.
### creating your own customized builder

The builder this library provides can be use as a standalone builder, but you should consider creating your own builder
by decorating the provided builder. The reason is that it's still very verbose to pass each instance of the metadata
when creating an error.

Look at [this file](./example/errors/builder.go) for an example of a builder with 2 possible metadata (errorCode and
tags), and [this file](./example/main.go) as an example of using this builder.

The builder is immutable/thread safe, so you can have a base builder and then call `.Context(ctx)` on it without impacting the
rest of your code using the same builder. It does share the same metadata slice though, but there is no way to modify
the slice after creation, so it's safe.

### Getting the err message, location, and metadata

In the example above, we use the Printf formatting to display the error, metadata and location all in one gulp. You can however use the provided helper function to get the individual parts
In the example above, we use the Printf formatting to display the error, metadata and location all in one gulp.
You can however use the provided helper function to get the individual parts

```golang
err := metaerr.New("failure")
err.Error() //returns failure
err.Location() //returns .../mysource/mypackage/file.go:22
merr := metaerr.AsMetaErr(err)
merr.Location() //returns .../mysource/mypackage/file.go:22

// will print error_code:x01
meta := metaerr.GetMeta(err, false)
Expand All @@ -98,8 +123,22 @@ for k, values := range meta {

You can provide options to modify the errors during creation.

#### WithMeta

This is the main option you would be using. It allows you to add metadata to the error. You can add as many metadata as you want.
The library propose 4 built-in metadata builders:
- StringMeta: to add a string metadata
- StringsMeta: to add a slice of string metadata
- StringerMeta: to add any type that implements the Stringer interface as metadata
- StringMetaFromContext: to add a string metadata from a context (see `WithContext` below)

#### WithLocationSkip
By default, when creating an error, Metaerr will skip 2 call stack frames to determine the error's creation location. This works well when you call Metaerr directly at the place where the error is created in your codebase. However, there is a use case where you might want to create an error factory function for common scenarios to initialize the error with some standard metadata. In this case, if you use the standard `metaerr.New` function, the reported location will be the line where `metaerr.New` is called, which may be within your error factory function. You probably don't want to have all your locations pointing to the same line. To address this, you can use the `metaerr.WithLocationSkip` option to add additional call stack skips to determine the location. Here is an example:
By default, when creating an error, Metaerr will skip all stack frames related to metaerr to determine the error's creation location.
This works well when you call Metaerr directly at the place where the error is created in your codebase. However, there is a use case
where you use a factory, or your own builder to create errors. In this case, if you use the standard `metaerr.New` function, the reported
location will be the line where metaerr is called to create the error, which may be within your error factory or builder function.
You probably don't want to have all your locations pointing to the same line. To address this, you can use the `metaerr.WithLocationSkip`
option to add additional call stack skips to determine the location. Here is an example:

```golang
package main
Expand All @@ -113,7 +152,7 @@ import (
var Tag = metaerr.StringMeta("tag")

func CreateDatabaseError(reason string) error {
return metaerr.New(reason, metaerr.WithLocationSkip(1)).Meta(Tag("database"))
return metaerr.New(reason, metaerr.WithLocationSkip(1), metaerr.WithMeta(Tag("database")))
}

func main() {
Expand All @@ -130,18 +169,25 @@ no such table [User] [tag=database]
at .../github.com/quantumcycle/metaerr/cmd/main.go:16
```

Without the `WithLocationSkip` option, the reported location would be line 12, inside the `CreateDatabaseError` function. Having all our errors pointing to this specific line would ne useless.
Without the `WithLocationSkip` option, the reported location would be line 12, inside the `CreateDatabaseError` function.
Having all our errors pointing to this specific line would ne useless.

#### WithStacktrace

Usually the error creation location is enough to get by and find the context during which the error was created, but if the error is created in some central location called from multiple places, it might be useful to have a stacktrace to be able to find the caller that led to the error creation.
For these cases, use `WithStacktrace`, either when creating the error or when wrapping an existing error. When doing so, it will print something like this
Usually the error creation location is enough to get by and find the context during which the error was created, but if
the error is created in some central location called from multiple places, it might be useful to have a stacktrace to be
able to find the caller that led to the error creation.
For these cases, use `WithStacktrace`, either when creating the error or when wrapping an existing error. When doing so,
it will print something like this
```
failure
at .../github.com/quantumcycle/metaerr/errors_test.go:46
Stacktrace:
.../github.com/quantumcycle/metaerr/errors_test.go:64
.../github.com/quantumcycle/metaerr/errors_test.go:297
at .../github.com/quantumcycle/metaerr/errors_test.go:46 //<-- this is the error default location
at .../github.com/quantumcycle/metaerr/errors_test.go:64 //<-- this is added by the WithStacktrace option
at .../github.com/quantumcycle/metaerr/errors_test.go:297 //<-- this is added by the WithStacktrace option
```

If you explore the unit test file, you will notice that the first line of the stacktrace is actually the error creation location, in this case, line 46 of the errors_test.go, but instead of repeating the same line twice, once for location and once at the start of the stacktrace, we skip it in the stacktrace.
#### WithContext

This option allows you to attach a context to the error. Then you can use `StringMetaFromContext` to retrieve data from
the context and set some metadata. This is useful if for example you have a user in your context and want to add user
information to each error.
20 changes: 10 additions & 10 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ type Builder struct {
}

func (b Builder) Meta(meta ...ErrorMetadata) Builder {
metas := make([]ErrorMetadata, len(b.metas)+len(meta))
copy(metas, b.metas)
copy(metas[len(b.metas):], meta)
return Builder{
opts: b.opts,
context: b.context,
metas: append(b.metas, meta...),
metas: meta,
}
}

Expand All @@ -27,17 +30,14 @@ func (b Builder) Context(ctx context.Context) Builder {
}
}

func (b Builder) Newf(format string, args ...any) Error {
return New(fmt.Sprintf(format, args...), b.opts...).WithMeta(b.metas...).WithContext(b.context)
func (b Builder) Newf(format string, args ...any) error {
opts := append(b.opts, WithMeta(b.metas...), WithContext(b.context))
return New(fmt.Sprintf(format, args...), opts...)
}

func (b Builder) Wrapf(err error, format string, args ...any) *Error {
w := Wrap(err, fmt.Sprintf(format, args...), b.opts...)
if w == nil {
return nil
}
w2 := (*w).WithMeta(b.metas...).WithContext(b.context)
return &w2
func (b Builder) Wrapf(err error, format string, args ...any) error {
opts := append(b.opts, WithMeta(b.metas...), WithContext(b.context))
return Wrap(err, fmt.Sprintf(format, args...), opts...)
}

func NewBuilder(opt ...Option) Builder {
Expand Down
2 changes: 1 addition & 1 deletion builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestBuilderNewWithMeta(t *testing.T) {
func TestBuilderNewWithContext(t *testing.T) {
a := assert.New(t)

ctxTagMeta := metaerr.StringFromContextMeta("tag", "tag")
ctxTagMeta := metaerr.StringMetaFromContext("tag", "tag")
builder := metaerr.NewBuilder().Meta(ctxTagMeta())

ctx := context.WithValue(context.Background(), "tag", "security")
Expand Down
Loading
Loading