diff --git a/Makefile b/Makefile index 495d667..9474c82 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,12 @@ lint: @echo Linting @golangci-lint run --no-config --issues-exit-code=0 --timeout=5m +## Format docs +docs: + @echo Formatting docs + @npm list -g markdown-toc > /dev/null 2>&1 || npm install -g markdown-toc > /dev/null 2>&1 + @markdown-toc -i README.md + ## Run the tests test: @echo Running tests diff --git a/README.md b/README.md index 1918445..3281b2d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Package `eris` provides a better way to handle, trace, and log errors in Go. * [Wrapping errors](#wrapping-errors) * [Formatting and logging errors](#formatting-and-logging-errors) * [Interpreting eris stack traces](#interpreting-eris-stack-traces) + * [Inverting the stack trace and error output](#inverting-the-stack-trace-and-error-output) * [Inspecting errors](#inspecting-errors) * [Formatting with custom separators](#formatting-with-custom-separators) * [Writing a custom output format](#writing-a-custom-output-format) @@ -29,7 +30,7 @@ Package `eris` provides a better way to handle, trace, and log errors in Go. Named after the Greek goddess of strife and discord, this package is designed to give you more control over error handling via error wrapping, stack tracing, and output formatting. `eris` was inspired by a simple question: what if you could fix a bug without wasting time replicating the issue or digging through the code? -`eris` is intended to help developers diagnose issues faster. The [example](https://github.com/rotisserie/eris/blob/master/examples/logging/example.go) that generated the output below simulates a realistic error handling scenario and demonstrates how to wrap and log errors with minimal effort. This specific error occurred because a user tried to access a file that can't be located, and the output shows a clear path from the source to the top of the call stack. +`eris` is intended to help developers diagnose issues faster. The [example](https://github.com/rotisserie/eris/blob/master/examples/logging/example.go) that generated the output below simulates a realistic error handling scenario and demonstrates how to wrap and log errors with minimal effort. This specific error occurred because a user tried to access a file that can't be located, and the output shows a clear path from the top of the call stack to the source. ```json { @@ -37,20 +38,20 @@ Named after the Greek goddess of strife and discord, this package is designed to "root":{ "message":"error internal server", "stack":[ - "main.GetRelPath:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:61", - "main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:82", + "main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143", "main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:85", - "main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143" + "main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:82", + "main.GetRelPath:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:61" ] }, "wrap":[ - { - "message":"Rel: can't make ./some/malformed/absolute/path/data.json relative to /Users/roti/", - "stack":"main.GetRelPath:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:61" - }, { "message":"failed to get relative path for resource 'res2'", "stack":"main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:85" + }, + { + "message":"Rel: can't make ./some/malformed/absolute/path/data.json relative to /Users/roti/", + "stack":"main.GetRelPath:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:61" } ] }, @@ -115,17 +116,17 @@ fmt.Println(formattedStr) ### Interpreting eris stack traces -Errors created with this package contain stack traces that are managed automatically. They're currently mandatory when creating and wrapping errors but optional when printing or logging. The stack trace and all wrapped layers follow the same order as Go's `runtime` package, which means that the root cause of the error is shown first. +Errors created with this package contain stack traces that are managed automatically. They're currently mandatory when creating and wrapping errors but optional when printing or logging. By default, the stack trace and all wrapped layers follow the opposite order of Go's `runtime` package, which means that the original calling method is shown first and the root cause of the error is shown last. ```golang { "root":{ "message":"error bad request", // root cause "stack":[ - "main.(*Request).Validate:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:28", // location of the root - "main.(*Request).Validate:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:29", // location of Wrap call + "main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143", // original calling method "main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:71", - "main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143" + "main.(*Request).Validate:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:29", // location of Wrap call + "main.(*Request).Validate:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:28" // location of the root ] }, "wrap":[ @@ -137,6 +138,31 @@ Errors created with this package contain stack traces that are managed automatic } ``` +### Inverting the stack trace and error output + +If you prefer some other order than the default, `eris` supports inverting both the stack trace and the entire error output. When both are inverted, the root error is shown first and the original calling method is shown last. + +```golang +// create a default format with error and stack inversion options +format := eris.NewDefaultStringFormat(eris.FormatOptions{ + InvertOutput: true, // flag that inverts the error output (wrap errors shown first) + WithTrace: true, // flag that enables stack trace output + InvertTrace: true, // flag that inverts the stack trace output (top of call stack shown first) +}) + +// format the error to a string and print it +formattedStr := eris.ToCustomString(err, format) +fmt.Println(formattedStr) + +// example output: +// error not found +// main.GetResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:52 +// main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:76 +// main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143 +// failed to get resource 'res1' +// main.GetResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:52 +``` + ### Inspecting errors The `eris` package provides a couple ways to inspect and compare error types. [`eris.Is`](https://godoc.org/github.com/rotisserie/eris#Is) returns true if a particular error appears anywhere in the error chain. Currently, it works simply by comparing error messages with each other. If an error contains a particular message (e.g. `"error not found"`) anywhere in its chain, it's defined to be that error type. @@ -165,12 +191,14 @@ if eris.Cause(err) == ErrNotFound { ### Formatting with custom separators -For users who need more control over the error output, `eris` allows for some control over the separators between each piece of the output via the [`eris.Format`](https://godoc.org/github.com/rotisserie/eris#Format) type. Currently, the default order of the error and stack trace output is rigid. If this isn't flexible enough for your needs, see the [custom output format](#writing-a-custom-output-format) section below. To format errors with custom separators, you can define and pass a format object to [`eris.ToCustomString`](https://godoc.org/github.com/rotisserie/eris#ToCustomString) or [`eris.ToCustomJSON`](https://godoc.org/github.com/rotisserie/eris#ToCustomJSON). +For users who need more control over the error output, `eris` allows for some control over the separators between each piece of the output via the [`eris.Format`](https://godoc.org/github.com/rotisserie/eris#Format) type. If this isn't flexible enough for your needs, see the [custom output format](#writing-a-custom-output-format) section below. To format errors with custom separators, you can define and pass a format object to [`eris.ToCustomString`](https://godoc.org/github.com/rotisserie/eris#ToCustomString) or [`eris.ToCustomJSON`](https://godoc.org/github.com/rotisserie/eris#ToCustomJSON). ```golang // format the error to a string with custom separators formattedStr := eris.ToCustomString(err, Format{ - WithTrace: true, // flag that enables stack trace output + FormatOptions: eris.FormatOptions{ + WithTrace: true, // flag that enables stack trace output + }, MsgStackSep: "\n", // separator between error messages and stack frame data PreStackSep: "\t", // separator at the beginning of each stack frame StackElemSep: " | ", // separator between elements of each stack frame @@ -179,11 +207,11 @@ formattedStr := eris.ToCustomString(err, Format{ fmt.Println(formattedStr) // example output: -// unexpected EOF +// error reading file 'example.json' // main.readFile | .../example/main.go | 6 -// main.parseFile | .../example/main.go | 12 +// unexpected EOF // main.main | .../example/main.go | 20 -// error reading file 'example.json' +// main.parseFile | .../example/main.go | 12 // main.readFile | .../example/main.go | 6 ``` @@ -227,15 +255,15 @@ sentry.CaptureMessage(uErr.ErrRoot.Msg) Readability is a major design requirement for `eris`. In addition to the JSON output shown above, `eris` also supports formatting errors to a simple string. ``` -error not found +failed to get resource 'res1' main.GetResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:52 - main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:76 +error not found main.main:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:143 -failed to get resource 'res1' + main.ProcessResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:76 main.GetResource:/Users/roti/go/src/github.com/rotisserie/eris/examples/logging/example.go:52 ``` -The `eris` error stack is designed to be easier to interpret than other error handling packages, and it achieves this by omitting extraneous information and avoiding unnecessary repetition. The stack trace above omits calls from Go's `runtime` package and includes just a single frame for wrapped layers which are inserted into the root error stack trace in the correct order. `eris` also correctly handles and updates stack traces for global error values. +The `eris` error stack is designed to be easier to interpret than other error handling packages, and it achieves this by omitting extraneous information and avoiding unnecessary repetition. The stack trace above omits calls from Go's `runtime` package and includes just a single frame for wrapped layers which are inserted into the root error stack trace in the correct order. `eris` also correctly handles and updates stack traces for global error values in a transparent way. The output of `pkg/errors` for the same error is shown below. In this case, the root error stack trace is incorrect because it was declared as a global value, and it includes several extraneous lines from the `runtime` package. The output is also much more difficult to read and does not allow for custom formatting. diff --git a/eris_test.go b/eris_test.go index 28523f3..75423fa 100644 --- a/eris_test.go +++ b/eris_test.go @@ -41,27 +41,27 @@ func TestErrorWrapping(t *testing.T) { "standard error wrapping with a global root cause": { cause: globalErr, input: []string{"additional context", "even more context"}, - output: "global error: additional context: even more context", + output: "even more context: additional context: global error", }, "formatted error wrapping with a global root cause": { cause: formattedGlobalErr, input: []string{"additional context", "even more context"}, - output: "formatted global error: additional context: even more context", + output: "even more context: additional context: formatted global error", }, "standard error wrapping with a local root cause": { cause: eris.New("root error"), input: []string{"additional context", "even more context"}, - output: "root error: additional context: even more context", + output: "even more context: additional context: root error", }, "standard error wrapping with a local root cause (eris.Errorf)": { cause: eris.Errorf("%v root error", "formatted"), input: []string{"additional context", "even more context"}, - output: "formatted root error: additional context: even more context", + output: "even more context: additional context: formatted root error", }, "standard error wrapping with a third-party root cause (errors.New)": { cause: errors.New("external error"), input: []string{"additional context", "even more context"}, - output: "external error: additional context: even more context", + output: "even more context: additional context: external error", }, "no error wrapping with a local root cause (eris.Errorf)": { cause: eris.Errorf("%v root error", "formatted"), @@ -95,8 +95,8 @@ func TestErrorUnwrap(t *testing.T) { cause: eris.New("root error"), input: []string{"additional context", "even more context"}, output: []string{ - "root error: additional context: even more context", - "root error: additional context", + "even more context: additional context: root error", + "additional context: root error", "root error", }, }, @@ -104,8 +104,8 @@ func TestErrorUnwrap(t *testing.T) { cause: errors.New("external error"), input: []string{"additional context", "even more context"}, output: []string{ - "external error: additional context: even more context", - "external error: additional context", + "even more context: additional context: external error", + "additional context: external error", "external error", }, }, @@ -242,12 +242,12 @@ func TestErrorFormatting(t *testing.T) { "standard error wrapping with internal root cause (eris.New)": { cause: eris.New("root error"), input: []string{"additional context", "even more context"}, - output: "root error: additional context: even more context", + output: "even more context: additional context: root error", }, "standard error wrapping with external root cause (errors.New)": { cause: errors.New("external error"), input: []string{"additional context", "even more context"}, - output: "external error: additional context: even more context", + output: "even more context: additional context: external error", }, } diff --git a/examples_test.go b/examples_test.go index db2197e..8637e1d 100644 --- a/examples_test.go +++ b/examples_test.go @@ -70,9 +70,9 @@ func ExampleToJSON_global() { // "root": { // "message": "unexpected EOF", // "stack": [ - // "main.readFile:.../example/main.go:6", - // "main.parseFile:.../example/main.go:12", // "main.main:.../example/main.go:20", + // "main.parseFile:.../example/main.go:12", + // "main.readFile:.../example/main.go:6" // ] // }, // "wrap": [ @@ -140,23 +140,23 @@ func ExampleToJSON_local() { // "root": { // "message": "unexpected EOF", // "stack": [ - // "main.readFile:.../example/main.go:3", - // "main.parseFile:.../example/main.go:9", - // "main.parseFile:.../example/main.go:11", - // "main.processFile:.../example/main.go:19", - // "main.printFile:.../example/main.go:29", - // "main.printFile:.../example/main.go:31", // "main.main:.../example/main.go:37", + // "main.printFile:.../example/main.go:31", + // "main.printFile:.../example/main.go:29", + // "main.processFile:.../example/main.go:19", + // "main.parseFile:.../example/main.go:11", + // "main.parseFile:.../example/main.go:9", + // "main.readFile:.../example/main.go:3" // ] // }, // "wrap": [ // { - // "message": "error reading file 'example.json'", - // "stack": "main.parseFile: .../example/main.go: 11" - // }, - // { // "message": "error printing file 'example.json'", // "stack": "main.printFile:.../example/main.go:31" + // }, + // { + // "message": "error reading file 'example.json'", + // "stack": "main.parseFile: .../example/main.go: 11" // } // ] // } @@ -210,8 +210,18 @@ func ExampleToString_global() { return nil } - // call parseFile and catch the error - err := parseFile("example.json") // line 20 + // example func that just catches and returns an error + processFile := func(fname string) error { + // parse the file + err := parseFile(fname) // line 22 + if err != nil { + return eris.Wrapf(err, "error processing file '%v'", fname) // line 24 + } + return nil + } + + // call processFile and catch the error + err := processFile("example.json") // line 30 // print the error via fmt.Printf fmt.Printf("%v\n", err) // %v: omit stack trace @@ -223,11 +233,13 @@ func ExampleToString_global() { fmt.Printf("%v\n", eris.ToString(err, true)) // true: include stack trace // example output: - // unexpected EOF + // error reading file 'example.json' // main.readFile:.../example/main.go:6 + // unexpected EOF + // main.main:.../example/main.go:30 + // main.processFile:.../example/main.go:24 + // main.processFile:.../example/main.go:22 // main.parseFile:.../example/main.go:12 - // main.main:.../example/main.go:20 - // error reading file 'example.json' // main.readFile:.../example/main.go:6 } @@ -270,13 +282,13 @@ func ExampleToString_local() { fmt.Println(eris.ToString(err, true)) // true: include stack trace // example output: - // unexpected EOF - // main.readFile:.../example/main.go:3 - // main.parseFile:.../example/main.go:9 + // error reading file 'example.json' // main.parseFile:.../example/main.go:11 + // unexpected EOF // main.main:.../example/main.go:17 - // error reading file 'example.json' // main.parseFile:.../example/main.go:11 + // main.parseFile:.../example/main.go:9 + // main.readFile:.../example/main.go:3 } func TestExampleToString_local(t *testing.T) { diff --git a/format.go b/format.go index 525fba6..0090871 100644 --- a/format.go +++ b/format.go @@ -4,122 +4,150 @@ import ( "fmt" ) -// UnpackedError represents complete information about an error. -// -// This type can be used for custom error logging and parsing. Use `eris.Unpack` to build an UnpackedError -// from any error type. The ErrChain and ErrRoot fields correspond to `wrapError` and `rootError` types, -// respectively. If any other error type is unpacked, it will appear in the ExternalErr field. -type UnpackedError struct { - ErrRoot ErrRoot - ErrChain []ErrLink - ExternalErr string +// FormatOptions defines output options like omitting stack traces and inverting the error or stack order. +type FormatOptions struct { + InvertOutput bool // Flag that inverts the error output (wrap errors shown first). + WithTrace bool // Flag that enables stack trace output. + InvertTrace bool // Flag that inverts the stack trace output (top of call stack shown first). + // todo: maybe allow users to hide wrap frames if desired } -// Unpack returns UnpackedError type for a given golang error type. -func Unpack(err error) UnpackedError { - var upErr UnpackedError - for err != nil { - switch err := err.(type) { - case *rootError: - upErr.ErrRoot.Msg = err.msg - upErr.ErrRoot.Stack = err.stack.get() - case *wrapError: - // prepend links in stack trace order - link := ErrLink{Msg: err.msg} - link.Frame = err.frame.get() - upErr.ErrChain = append([]ErrLink{link}, upErr.ErrChain...) - default: - upErr.ExternalErr = err.Error() - } - err = Unwrap(err) - } - return upErr +// StringFormat defines a string error format. +type StringFormat struct { + Options FormatOptions // Format options (e.g. omitting stack trace or inverting the output order). + MsgStackSep string // Separator between error messages and stack frame data. + PreStackSep string // Separator at the beginning of each stack frame. + StackElemSep string // Separator between elements of each stack frame. + ErrorSep string // Separator between each error in the chain. } -// Format defines an error output format to be used with the default formatter. -type Format struct { - WithTrace bool // Flag that enables stack trace output. - MsgStackSep string // Separator between error messages and stack frame data. - PreStackSep string // Separator at the beginning of each stack frame. - StackElemSep string // Separator between elements of each stack frame. - ErrorSep string // Separator between each error in the chain. -} - -// NewDefaultFormat conveniently returns a basic format for the default string formatter. -func NewDefaultFormat(withTrace bool) Format { - stringFmt := Format{ - WithTrace: withTrace, - ErrorSep: ":", +// NewDefaultStringFormat returns a default string output format. +func NewDefaultStringFormat(options FormatOptions) StringFormat { + stringFmt := StringFormat{ + Options: options, } - if withTrace { + if options.WithTrace { stringFmt.MsgStackSep = "\n" stringFmt.PreStackSep = "\t" stringFmt.StackElemSep = ":" stringFmt.ErrorSep = "\n" + } else { + stringFmt.ErrorSep = ": " } return stringFmt } -// ToCustomString returns a custom formatted string for a given eris error. +// ToString returns a default formatted string for a given error. +// +// An error without trace will be formatted as following: +// +// : +// +// An error with trace will be formatted as following: +// +// +// :: +// +// :: +// :: +func ToString(err error, withTrace bool) string { + return ToCustomString(err, NewDefaultStringFormat(FormatOptions{ + WithTrace: withTrace, + })) +} + +// ToCustomString returns a custom formatted string for a given error. // // To declare custom format, the Format object has to be passed as an argument. // An error without trace will be formatted as following: // -// [Format.ErrorSep] +// [Format.ErrorSep] // // An error with trace will be formatted as following: // -// [Format.MsgStackSep] -// [Format.PreStackSep][Format.StackElemSep][Format.StackElemSep][Format.ErrorSep] -// [Format.PreStackSep][Format.StackElemSep][Format.StackElemSep][Format.ErrorSep] // [Format.MsgStackSep] // [Format.PreStackSep][Format.StackElemSep][Format.StackElemSep][Format.ErrorSep] -func ToCustomString(err error, format Format) string { +// [Format.MsgStackSep] +// [Format.PreStackSep][Format.StackElemSep][Format.StackElemSep][Format.ErrorSep] +// [Format.PreStackSep][Format.StackElemSep][Format.StackElemSep][Format.ErrorSep] +func ToCustomString(err error, format StringFormat) string { upErr := Unpack(err) - if !format.WithTrace { - format.ErrorSep = ": " + + // return early if this is a third-party error + if upErr.ExternalErr != "" { + return fmt.Sprint(upErr.ExternalErr) } + var str string - if upErr.ErrRoot.Msg != "" || len(upErr.ErrRoot.Stack) > 0 { + if format.Options.InvertOutput { str += upErr.ErrRoot.formatStr(format) - if format.WithTrace && len(upErr.ErrChain) > 0 { - str += format.ErrorSep + for _, eLink := range upErr.ErrChain { + str += format.ErrorSep + eLink.formatStr(format) } - } - - for _, eLink := range upErr.ErrChain { - if !format.WithTrace { - str += format.ErrorSep + } else { + for i := len(upErr.ErrChain) - 1; i >= 0; i-- { + str += upErr.ErrChain[i].formatStr(format) + format.ErrorSep } - str += eLink.formatStr(format) - str += format.MsgStackSep - } - - if upErr.ExternalErr != "" { - str += fmt.Sprint(upErr.ExternalErr) + str += upErr.ErrRoot.formatStr(format) } return str } -// ToString returns a default formatted string for a given eris error. +// JSONFormat defines a JSON error format. +type JSONFormat struct { + Options FormatOptions // Format options (e.g. omitting stack trace or inverting the output order). + // todo: maybe allow setting of wrap/root keys in the output map as well + StackElemSep string // Separator between elements of each stack frame. +} + +// NewDefaultJSONFormat returns a default JSON output format. +func NewDefaultJSONFormat(options FormatOptions) JSONFormat { + return JSONFormat{ + Options: options, + StackElemSep: ":", + } +} + +// ToJSON returns a JSON formatted map for a given error. // // An error without trace will be formatted as following: // -// : +// { +// "root": [ +// { +// "message": "Root error msg" +// } +// ], +// "wrap": { +// "message": "Wrap error msg" +// } +// } // // An error with trace will be formatted as following: // -// -// :: -// :: -// -// :: -func ToString(err error, withTrace bool) string { - return ToCustomString(err, NewDefaultFormat(withTrace)) +// { +// "root": [ +// { +// "message": "Root error msg", +// "stack": [ +// "::", +// "::" +// ] +// } +// ], +// "wrap": { +// "message": "Wrap error msg", +// "stack": "::" +// } +// } +func ToJSON(err error, withTrace bool) map[string]interface{} { + return ToCustomJSON(err, NewDefaultJSONFormat(FormatOptions{ + WithTrace: withTrace, + })) } -// ToCustomJSON returns a JSON formatted map for a given eris error. +// ToCustomJSON returns a JSON formatted map for a given error. // // To declare custom format, the Format object has to be passed as an argument. // An error without trace will be formatted as following: @@ -141,8 +169,8 @@ func ToString(err error, withTrace bool) string { // "root": { // "message": "Root error msg", // "stack": [ -// "[Format.StackElemSep][Format.StackElemSep]", -// "[Format.StackElemSep][Format.StackElemSep]" +// "[Format.StackElemSep][Format.StackElemSep]", +// "[Format.StackElemSep][Format.StackElemSep]" // ] // } // "wrap": [ @@ -152,12 +180,16 @@ func ToString(err error, withTrace bool) string { // } // ] // } -func ToCustomJSON(err error, format Format) map[string]interface{} { +func ToCustomJSON(err error, format JSONFormat) map[string]interface{} { upErr := Unpack(err) - if !format.WithTrace { - format.ErrorSep = ": " - } + + // return early if this is a third-party error jsonMap := make(map[string]interface{}) + if upErr.ExternalErr != "" { + jsonMap["external"] = fmt.Sprint(upErr.ExternalErr) + return jsonMap + } + if upErr.ErrRoot.Msg != "" || len(upErr.ErrRoot.Stack) > 0 { jsonMap["root"] = upErr.ErrRoot.formatJSON(format) } @@ -166,52 +198,48 @@ func ToCustomJSON(err error, format Format) map[string]interface{} { var wrapArr []map[string]interface{} for _, eLink := range upErr.ErrChain { wrapMap := eLink.formatJSON(format) - wrapArr = append(wrapArr, wrapMap) + if format.Options.InvertOutput { + wrapArr = append(wrapArr, wrapMap) + } else { + wrapArr = append([]map[string]interface{}{wrapMap}, wrapArr...) + } } jsonMap["wrap"] = wrapArr } - if upErr.ExternalErr != "" { - jsonMap["external"] = fmt.Sprint(upErr.ExternalErr) - } - return jsonMap } -// ToJSON returns a JSON formatted map for a given eris error. -// -// An error without trace will be formatted as following: -// -// { -// "root": [ -// { -// "message": "Root error msg" -// } -// ], -// "wrap": { -// "message": "Wrap error msg" -// } -// } -// -// An error with trace will be formatted as following: +// Unpack returns a human-readable UnpackedError type for a given error. +func Unpack(err error) UnpackedError { + var upErr UnpackedError + for err != nil { + switch err := err.(type) { + case *rootError: + upErr.ErrRoot.Msg = err.msg + upErr.ErrRoot.Stack = err.stack.get() + case *wrapError: + // prepend links in stack trace order + link := ErrLink{Msg: err.msg} + link.Frame = err.frame.get() + upErr.ErrChain = append([]ErrLink{link}, upErr.ErrChain...) + default: + upErr.ExternalErr = err.Error() + } + err = Unwrap(err) + } + return upErr +} + +// UnpackedError represents complete information about an error. // -// { -// "root": [ -// { -// "message": "Root error msg", -// "stack": [ -// "::", -// "::" -// ] -// } -// ], -// "wrap": { -// "message": "Wrap error msg", -// "stack": "::" -// } -// } -func ToJSON(err error, withTrace bool) map[string]interface{} { - return ToCustomJSON(err, NewDefaultFormat(withTrace)) +// This type can be used for custom error logging and parsing. Use `eris.Unpack` to build an UnpackedError +// from any error type. The ErrChain and ErrRoot fields correspond to `wrapError` and `rootError` types, +// respectively. If any other error type is unpacked, it will appear in the ExternalErr field. +type UnpackedError struct { + ErrRoot ErrRoot + ErrChain []ErrLink + ExternalErr string } // ErrRoot represents an error stack and the accompanying message. @@ -220,14 +248,15 @@ type ErrRoot struct { Stack Stack } -func (err *ErrRoot) formatStr(format Format) string { - str := err.Msg - str += format.MsgStackSep - if format.WithTrace { - stackArr := err.Stack.format(format.StackElemSep) +func (err *ErrRoot) formatStr(format StringFormat) string { + if err.Msg == "" { + return "" + } + str := err.Msg + format.MsgStackSep + if format.Options.WithTrace { + stackArr := err.Stack.format(format.StackElemSep, format.Options.InvertTrace) for i, frame := range stackArr { - str += format.PreStackSep - str += frame + str += format.PreStackSep + frame if i < len(stackArr)-1 { str += format.ErrorSep } @@ -236,11 +265,11 @@ func (err *ErrRoot) formatStr(format Format) string { return str } -func (err *ErrRoot) formatJSON(format Format) map[string]interface{} { +func (err *ErrRoot) formatJSON(format JSONFormat) map[string]interface{} { rootMap := make(map[string]interface{}) rootMap["message"] = fmt.Sprint(err.Msg) - if format.WithTrace { - rootMap["stack"] = err.Stack.format(format.StackElemSep) + if format.Options.WithTrace { + rootMap["stack"] = err.Stack.format(format.StackElemSep, format.Options.InvertTrace) } return rootMap } @@ -251,20 +280,18 @@ type ErrLink struct { Frame StackFrame } -func (eLink *ErrLink) formatStr(format Format) string { - str := eLink.Msg - str += format.MsgStackSep - if format.WithTrace { - str += format.PreStackSep - str += eLink.Frame.format(format.StackElemSep) +func (eLink *ErrLink) formatStr(format StringFormat) string { + str := eLink.Msg + format.MsgStackSep + if format.Options.WithTrace { + str += format.PreStackSep + eLink.Frame.format(format.StackElemSep) } return str } -func (eLink *ErrLink) formatJSON(format Format) map[string]interface{} { +func (eLink *ErrLink) formatJSON(format JSONFormat) map[string]interface{} { wrapMap := make(map[string]interface{}) wrapMap["message"] = fmt.Sprint(eLink.Msg) - if format.WithTrace { + if format.Options.WithTrace { wrapMap["stack"] = eLink.Frame.format(format.StackElemSep) } return wrapMap diff --git a/format_test.go b/format_test.go index 2d9814d..ab4eee0 100644 --- a/format_test.go +++ b/format_test.go @@ -117,7 +117,15 @@ func TestFormatStr(t *testing.T) { }, "basic wrapped error": { input: eris.Wrap(eris.Wrap(eris.New("root error"), "additional context"), "even more context"), - output: "root error: additional context: even more context", + output: "even more context: additional context: root error", + }, + "external error": { + input: errors.New("external error"), + output: "external error", + }, + "empty error": { + input: eris.New(""), + output: "", }, } for desc, tt := range tests { @@ -130,6 +138,29 @@ func TestFormatStr(t *testing.T) { } } +func TestInvertedFormatStr(t *testing.T) { + tests := map[string]struct { + input error + output string + }{ + "basic wrapped error": { + input: eris.Wrap(eris.Wrap(eris.New("root error"), "additional context"), "even more context"), + output: "root error: additional context: even more context", + }, + } + for desc, tt := range tests { + // without trace + t.Run(desc, func(t *testing.T) { + format := eris.NewDefaultStringFormat(eris.FormatOptions{ + InvertOutput: true, + }) + if got := eris.ToCustomString(tt.input, format); !reflect.DeepEqual(got, tt.output) { + t.Errorf("ToString() got\n'%v'\nwant\n'%v'", got, tt.output) + } + }) + } +} + func TestFormatJSON(t *testing.T) { tests := map[string]struct { input error @@ -141,7 +172,11 @@ func TestFormatJSON(t *testing.T) { }, "basic wrapped error": { input: eris.Wrap(eris.Wrap(eris.New("root error"), "additional context"), "even more context"), - output: `{"root":{"message":"root error"},"wrap":[{"message":"additional context"},{"message":"even more context"}]}`, + output: `{"root":{"message":"root error"},"wrap":[{"message":"even more context"},{"message":"additional context"}]}`, + }, + "external error": { + input: errors.New("external error"), + output: `{"external":"external error"}`, }, } for desc, tt := range tests { @@ -153,3 +188,89 @@ func TestFormatJSON(t *testing.T) { }) } } + +func TestInvertedFormatJSON(t *testing.T) { + tests := map[string]struct { + input error + output string + }{ + "basic wrapped error": { + input: eris.Wrap(eris.Wrap(eris.New("root error"), "additional context"), "even more context"), + output: `{"root":{"message":"root error"},"wrap":[{"message":"additional context"},{"message":"even more context"}]}`, + }, + } + for desc, tt := range tests { + t.Run(desc, func(t *testing.T) { + format := eris.NewDefaultJSONFormat(eris.FormatOptions{ + InvertOutput: true, + }) + result, _ := json.Marshal(eris.ToCustomJSON(tt.input, format)) + if got := string(result); !reflect.DeepEqual(got, tt.output) { + t.Errorf("ToJSON() = %v, want %v", got, tt.output) + } + }) + } +} + +func TestFormatJSONWithStack(t *testing.T) { + tests := map[string]struct { + input error + rootOutput map[string]interface{} + wrapOutput []map[string]interface{} + }{ + "basic wrapped error": { + input: eris.Wrap(eris.Wrap(eris.New("root error"), "additional context"), "even more context"), + rootOutput: map[string]interface{}{ + "message": "root error", + }, + wrapOutput: []map[string]interface{}{ + {"message": "even more context"}, + {"message": "additional context"}, + }, + }, + } + for desc, tt := range tests { + t.Run(desc, func(t *testing.T) { + format := eris.NewDefaultJSONFormat(eris.FormatOptions{ + WithTrace: true, + InvertTrace: true, + }) + errJSON := eris.ToCustomJSON(tt.input, format) + + // make sure messages are correct and stack elements exist (actual stack validation is in stack_test.go) + if rootMap, ok := errJSON["root"].(map[string]interface{}); ok { + if _, exists := rootMap["message"]; !exists { + t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) + } + if rootMap["message"] != tt.rootOutput["message"] { + t.Errorf("%v: expected { %v } got { %v }", desc, rootMap["message"], tt.rootOutput["message"]) + } + if _, exists := rootMap["stack"]; !exists { + t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) + } + } else { + t.Errorf("%v: expected root error is malformed { %v }", desc, errJSON) + } + + // make sure messages are correct and stack elements exist (actual stack validation is in stack_test.go) + if wrapMap, ok := errJSON["wrap"].([]map[string]interface{}); ok { + if len(tt.wrapOutput) != len(wrapMap) { + t.Fatalf("%v: expected number of wrap layers { %v } doesn't match actual { %v }", desc, len(tt.wrapOutput), len(wrapMap)) + } + for i := 0; i < len(wrapMap); i++ { + if _, exists := wrapMap[i]["message"]; !exists { + t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) + } + if wrapMap[i]["message"] != tt.wrapOutput[i]["message"] { + t.Errorf("%v: expected { %v } got { %v }", desc, wrapMap[i]["message"], tt.wrapOutput[i]["message"]) + } + if _, exists := wrapMap[i]["stack"]; !exists { + t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) + } + } + } else { + t.Errorf("%v: expected wrap error is malformed { %v }", desc, errJSON) + } + }) + } +} diff --git a/stack.go b/stack.go index 3b14345..bdbd4a5 100644 --- a/stack.go +++ b/stack.go @@ -9,33 +9,15 @@ import ( // Stack is an array of stack frames stored in a human readable format. type Stack []StackFrame -// insertPC inserts a wrap error program counter (pc) into the correct place of the root error stack trace. -// TODO: this function can be optimized -func (rootPCs *stack) insertPC(wrapPCs stack) { - if rootPCs == nil || len(wrapPCs) == 0 { - return - } else if len(wrapPCs) == 1 { - // append the pc to the end if there's only one - *rootPCs = append(*rootPCs, wrapPCs[0]) - return - } - for at, f := range *rootPCs { - if f == wrapPCs[0] { - // break if the stack already contains the pc - break - } else if f == wrapPCs[1] { - // insert the first pc into the stack if the second pc is found - *rootPCs = insert(*rootPCs, wrapPCs[0], at) - break - } - } -} - // format returns an array of formatted stack frames. -func (s Stack) format(sep string) []string { +func (s Stack) format(sep string, invert bool) []string { var str []string for _, f := range s { - str = append(str, f.format(sep)) + if invert { + str = append(str, f.format(sep)) + } else { + str = append([]string{f.format(sep)}, str...) + } } return str } @@ -92,6 +74,27 @@ func callers(skip int) *stack { // stack is an array of program counters. type stack []uintptr +// insertPC inserts a wrap error program counter (pc) into the correct place of the root error stack trace. +func (s *stack) insertPC(wrapPCs stack) { + if s == nil || len(wrapPCs) == 0 { + return + } else if len(wrapPCs) == 1 { + // append the pc to the end if there's only one + *s = append(*s, wrapPCs[0]) + return + } + for at, f := range *s { + if f == wrapPCs[0] { + // break if the stack already contains the pc + break + } else if f == wrapPCs[1] { + // insert the first pc into the stack if the second pc is found + *s = insert(*s, wrapPCs[0], at) + break + } + } +} + // get returns a human readable stack trace. func (s *stack) get() []StackFrame { var stackFrames []StackFrame