Skip to content

Commit

Permalink
Add custom errors package with stack trace functionality (#5239)
Browse files Browse the repository at this point in the history
* feat: a simple stacktrace utility

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* feat: custom errors package with new, errorf, wrapping, unwrapping and stacktrace

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* chore: update existing errors import (small subset)

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* chore: update comments

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* add errors into skip-files linter config

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* intoduce UnwrapTillCause to suffice the limitation of Unwrap

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* Revert "chore: update existing errors import (small subset)"

This reverts commit d27f017.

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* revert makefile && golangcilint file

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* apply PR feedbacks

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* stacktrace and errors test

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* fix typo

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* update stacktrace testing regex

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* add lint ignore for standard errors import inside errors pkg

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* [test files] add copyright headers

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* add no lint to avoid false misspell detection of keyword Tast

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* update stacktrace output test line number with regex pattern

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* return pc slice with reduced capacity

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* segregate formatted vs non formatted methods

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>

* update with only f functions

Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>
  • Loading branch information
bisakhmondal authored May 24, 2022
1 parent dd77331 commit 18b4dc3
Show file tree
Hide file tree
Showing 4 changed files with 368 additions and 0 deletions.
142 changes: 142 additions & 0 deletions pkg/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) The Thanos Authors.
// Licensed under the Apache License 2.0.

//nolint
// The idea of writing errors package in thanos is highly motivated from the Tast project of Chromium OS Authors. However, instead of
// copying the package, we end up writing our own simplified logic borrowing some ideas from the errors and github.com/pkg/errors.
// A big thanks to all of them.

// Package errors provides basic utilities to manipulate errors with a useful stacktrace. It combines the
// benefits of errors.New and fmt.Errorf world into a single package.
package errors

import (
//lint:ignore faillint Custom errors package needs to import standard library errors.
"errors"
"fmt"
"strings"
)

// base is the fundamental struct that implements the error interface and the acts as the backbone of this errors package.
type base struct {
// info contains the error message passed through calls like errors.Wrap, errors.New.
info string
// stacktrace stores information about the program counters - i.e. where this error was generated.
stack stacktrace
// err is the actual error which is being wrapped with a stacktrace and message information.
err error
}

// Error implements the error interface.
func (b *base) Error() string {
if b.err != nil {
return fmt.Sprintf("%s: %s", b.info, b.err.Error())
}
return b.info
}

// Unwrap implements the error Unwrap interface.
func (b *base) Unwrap() error {
return b.err
}

// Format implements the fmt.Formatter interface to support the formatting of an error chain with "%+v" verb.
// Whenever error is printed with %+v format verb, stacktrace info gets dumped to the output.
func (b *base) Format(s fmt.State, verb rune) {
if verb == 'v' && s.Flag('+') {
s.Write([]byte(formatErrorChain(b)))
return
}

s.Write([]byte(b.Error()))
}

// Newf formats according to a format specifier and returns a new error with a stacktrace
// with recent call frames. Each call to New returns a distinct error value even if the text is
// identical. An alternative of the errors.New function.
//
// If no args have been passed, it is same as `New` function without formatting. Character like
// '%' still has to be escaped in that scenario.
func Newf(format string, args ...interface{}) error {
return &base{
info: fmt.Sprintf(format, args...),
stack: newStackTrace(),
err: nil,
}
}

// Wrapf returns a new error by formatting the error message with the supplied format specifier
// and wrapping another error with a stacktrace containing recent call frames.
//
// If cause is nil, this is the same as fmt.Errorf. If no args have been passed, it is same as `Wrap`
// function without formatting. Character like '%' still has to be escaped in that scenario.
func Wrapf(cause error, format string, args ...interface{}) error {
return &base{
info: fmt.Sprintf(format, args...),
stack: newStackTrace(),
err: cause,
}
}

// Cause returns the result of repeatedly calling the Unwrap method on err, if err's
// type implements an Unwrap method. Otherwise, Cause returns the last encountered error.
// The difference between Unwrap and Cause is the first one performs unwrapping of one level
// but Cause returns the last err (whether it's nil or not) where it failed to assert
// the interface containing the Unwrap method.
// This is a replacement of errors.Cause without the causer interface from pkg/errors which
// actually can be sufficed through the errors.Is function. But considering some use cases
// where we need to peel off all the external layers applied through errors.Wrap family,
// it is useful ( where external SDK doesn't use errors.Is internally).
func Cause(err error) error {
for err != nil {
e, ok := err.(interface {
Unwrap() error
})
if !ok {
return err
}
err = e.Unwrap()
}
return nil
}

// formatErrorChain formats an error chain.
func formatErrorChain(err error) string {
var buf strings.Builder
for err != nil {
if e, ok := err.(*base); ok {
buf.WriteString(fmt.Sprintf("%s\n%v", e.info, e.stack))
err = e.err
} else {
buf.WriteString(fmt.Sprintf("%s\n", err.Error()))
err = nil
}
}
return buf.String()
}

// The functions `Is`, `As` & `Unwrap` provides a thin wrapper around the builtin errors
// package in go. Just for sake of completeness and correct autocompletion behaviors from
// IDEs they have been wrapped using functions instead of using variable to reference them
// as first class functions (eg: var Is = errros.Is ).

// Is is a wrapper of built-in errors.Is. It reports whether any error in err's
// chain matches target. The chain consists of err itself followed by the sequence
// of errors obtained by repeatedly calling Unwrap.
func Is(err, target error) bool {
return errors.Is(err, target)
}

// As is a wrapper of built-in errors.As. It finds the first error in err's
// chain that matches target, and if one is found, sets target to that error
// value and returns true. Otherwise, it returns false.
func As(err error, target interface{}) bool {
return errors.As(err, target)
}

// Unwrap is a wrapper of built-in errors.Unwrap. Unwrap returns the result of
// calling the Unwrap method on err, if err's type contains an Unwrap method
// returning error. Otherwise, Unwrap returns nil.
func Unwrap(err error) error {
return errors.Unwrap(err)
}
143 changes: 143 additions & 0 deletions pkg/errors/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) The Thanos Authors.
// Licensed under the Apache License 2.0.

package errors

import (
//lint:ignore faillint Custom errors package tests need to import standard library errors.
stderrors "errors"
"fmt"
"regexp"
"strconv"
"testing"

"github.com/thanos-io/thanos/pkg/testutil"
)

const msg = "test_error_message"
const wrapper = "test_wrapper"

func TestNewf(t *testing.T) {
err := Newf(msg)
testutil.Equals(t, err.Error(), msg, "the root error message must match")

reg := regexp.MustCompile(msg + `[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestNewf .*\/pkg\/errors\/errors_test\.go:\d+`)
testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.New")
}

func TestNewfFormatted(t *testing.T) {
fmtMsg := msg + " key=%v"
expectedMsg := msg + " key=value"

err := Newf(fmtMsg, "value")
testutil.Equals(t, err.Error(), expectedMsg, "the root error message must match")
reg := regexp.MustCompile(expectedMsg + `[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestNewfFormatted .*\/pkg\/errors\/errors_test\.go:\d+`)
testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.New with format string")
}

func TestWrapf(t *testing.T) {
err := Newf(msg)
err = Wrapf(err, wrapper)

expectedMsg := wrapper + ": " + msg
testutil.Equals(t, err.Error(), expectedMsg, "the root error message must match")

reg := regexp.MustCompile(`test_wrapper[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestWrapf .*\/pkg\/errors\/errors_test\.go:\d+
[[:ascii:]]+test_error_message[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestWrapf .*\/pkg\/errors\/errors_test\.go:\d+`)

testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.Wrap")
}

func TestUnwrap(t *testing.T) {
// test with base error
err := Newf(msg)

for i, tc := range []struct {
err error
expected string
isNil bool
}{
{
// no wrapping
err: err,
isNil: true,
},
{
err: Wrapf(err, wrapper),
expected: "test_error_message",
},
{
err: Wrapf(Wrapf(err, wrapper), wrapper),
expected: "test_wrapper: test_error_message",
},
// check primitives errors
{
err: stderrors.New("std-error"),
isNil: true,
},
{
err: Wrapf(stderrors.New("std-error"), wrapper),
expected: "std-error",
},
{
err: nil,
isNil: true,
},
} {
t.Run("TestCase"+strconv.Itoa(i), func(t *testing.T) {
unwrapped := Unwrap(tc.err)
if tc.isNil {
testutil.Equals(t, unwrapped, nil)
return
}
testutil.Equals(t, unwrapped.Error(), tc.expected, "Unwrap must match expected output")
})
}
}

func TestCause(t *testing.T) {
// test with base error that implements interface containing Unwrap method
err := Newf(msg)

for i, tc := range []struct {
err error
expected string
isNil bool
}{
{
// no wrapping
err: err,
isNil: true,
},
{
err: Wrapf(err, wrapper),
isNil: true,
},
{
err: Wrapf(Wrapf(err, wrapper), wrapper),
isNil: true,
},
// check primitives errors
{
err: stderrors.New("std-error"),
expected: "std-error",
},
{
err: Wrapf(stderrors.New("std-error"), wrapper),
expected: "std-error",
},
{
err: nil,
isNil: true,
},
} {
t.Run("TestCase"+strconv.Itoa(i), func(t *testing.T) {
cause := Cause(tc.err)
if tc.isNil {
testutil.Equals(t, cause, nil)
return
}
testutil.Equals(t, cause.Error(), tc.expected, "Cause must match expected output")
})
}
}
53 changes: 53 additions & 0 deletions pkg/errors/stacktrace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) The Thanos Authors.
// Licensed under the Apache License 2.0.

package errors

import (
"fmt"
"runtime"
"strings"
)

// stacktrace holds a snapshot of program counters.
type stacktrace []uintptr

// newStackTrace captures a stack trace. It skips first 3 frames to record the
// snapshot of the stack trace at the origin of a particular error. It tries to
// record maximum 16 frames (if available).
func newStackTrace() stacktrace {
const stackDepth = 16 // record maximum 16 frames (if available).

pc := make([]uintptr, stackDepth)
// using skip=3 for not to count the program counter address of
// 1. the respective function from errors package (eg. errors.New)
// 2. newStacktrace itself
// 3. the function used in runtime.Callers
n := runtime.Callers(3, pc)

// this approach is taken to reduce long term memory footprint (obtained through escape analysis).
// We are returning a new slice by re-slicing the pc with the required length and capacity (when the
// no of returned callFrames is less that stackDepth). This uses less memory compared to pc[:n] as
// the capacity of new slice is inherited from the parent slice if not specified.
return stacktrace(pc[:n:n])
}

// String implements the fmt.Stringer interface to provide formatted text output.
func (s stacktrace) String() string {
var buf strings.Builder

// CallersFrames takes the slice of Program Counter addresses returned by Callers to
// retrieve function/file/line information.
cf := runtime.CallersFrames(s)
for {
// more indicates if the next call will be successful or not.
frame, more := cf.Next()
// used formatting scheme <`>`space><function name><tab><filepath><:><line><newline> for example:
// > testing.tRunner /home/go/go1.17.8/src/testing/testing.go:1259
buf.WriteString(fmt.Sprintf("> %s\t%s:%d\n", frame.Func.Name(), frame.File, frame.Line))
if !more {
break
}
}
return buf.String()
}
30 changes: 30 additions & 0 deletions pkg/errors/stacktrace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) The Thanos Authors.
// Licensed under the Apache License 2.0.

package errors

import (
"strings"
"testing"
)

func caller() stacktrace {
return newStackTrace()
}

func TestStacktraceOutput(t *testing.T) {
st := caller()
expectedPhrase := "/pkg/errors/stacktrace_test.go:16"
if !strings.Contains(st.String(), expectedPhrase) {
t.Fatalf("expected %v phrase into the stacktrace, received stacktrace: \n%v", expectedPhrase, st.String())
}
}

func TestStacktraceProgramCounterLen(t *testing.T) {
st := caller()
output := st.String()
lines := len(strings.Split(strings.TrimSuffix(output, "\n"), "\n"))
if len(st) != lines {
t.Fatalf("output lines vs program counter size mismatch: program counter size %v, output lines %v", len(st), lines)
}
}

0 comments on commit 18b4dc3

Please sign in to comment.