Home

Awesome

Oops - Error handling with context, assertion, stack trace and source fragments

tag Go Version GoDoc Build Status Go report Coverage Contributors License

(Yet another) error handling library: oops.Errorf is a dead-simple drop-in replacement for built-in error, adding contextual information such as stack trace, extra attributes, error code, and bug-fixing hints...

[!WARNING]
This is NOT a logging library. oops should complement your existing logging toolchain (zap, zerolog, logrus, slog, go-sentry...).

🥷 Start hacking oops with this playground.

<div align="center"> <hr> <sup><b>Sponsored by:</b></sup> <br> <a href="https://quickwit.io?utm_campaign=github_sponsorship&utm_medium=referral&utm_content=samber-oops&utm_source=github"> <div> <img src="https://github.com/samber/oops/assets/2951285/49aaaa2b-b8c6-4f21-909f-c12577bb6a2e" width="240" alt="Quickwit"> </div> <div> Cloud-native search engine for observability - An OSS alternative to Splunk, Elasticsearch, Loki, and Tempo. </div> </a> <hr> </div> <img align="right" title="Oops gopher logo" alt="logo: thanks Gimp" width="280" src="assets/logo.png">

Jump:

🤔 Motivations

Loggers usually allow developers to build records with contextual attributes, that describe errors, such as:

Go recommends cascading error handling, which can cause the error to be triggered far away from the call to the logger. Returning context over X callers is painful, and to be meaningful, the stack trace must be gathered by the error builder instead of the logger.

This is why we need an error wrapper!

🥵 Why develop yet another library?

❌ Before samber/oops

In the following example, we try to propagate an error with contextual information and stack trace, to the caller function handler():

func c(token string) error {
    userID := ...   // <-- How do I transport `userID` and `role` from here...
    role := ...

    // ...

    return fmt.Errorf("an error")
}

func b() error {
    // ...
    return c()
}

func a() {
    err := b()
    if err != nil {
        // print log
        slog.Error(err.Error(),
            slog.String("user.id", "????"),      // <-- ...to here ??
            slog.String("user.role", "????"),    // <-- ...and here ??
            slog.String("stracktrace", generateStacktrace()))  // <-- this won't contain the exact error location 😩
    }
}

✅ Using samber/oops

I would rather write something like that:

func d() error {
    return oops.
        Code("iam_missing_permission").
        In("authz").
        Tags("authz").
        Time(time.Now()).
        With("user_id", 1234).
        With("permission", "post.create").
        Hint("Runbook: https://doc.acme.org/doc/abcd.md").
        User("user-123", "firstname", "john", "lastname", "doe").
        Errorf("permission denied")
}

func c() error {
	return d()
}

func b() error {
    // add more context
    return oops.
        In("iam").
        Tags("iam").
        Trace("e76031ee-a0c4-4a80-88cb-17086fdd19c0").
        With("hello", "world").
        Wrapf(c(), "something failed")
}

func a() error {
	return b()
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    err := a()
    if err != nil {
        logger.Error(
            err.Error(),
            slog.Any("error", err), // unwraps and flattens error context
        )
    }
}
<div style="text-align:center;"> <img alt="Why 'oops'?" src="./assets/motivation.png" style="max-width: 650px;"> </div>

Why "oops"?

Have you already heard a developer yelling at unclear error messages in Sentry, with no context, just before figuring out he wrote this piece of shit by himself?

Yes. Me too.

<div style="text-align:center;"> <img alt="oops!" src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExZDU2MjE1ZTk1ZjFmMWNkOGZlY2YyZGYzNjA4ZWIyZWU4NTI3MmE1OCZlcD12MV9pbnRlcm5hbF9naWZzX2dpZklkJmN0PWc/mvyvXwL26FfAtRCLPk/giphy.gif"> </div>

🚀 Install

go get github.com/samber/oops

This library is v1 and follows SemVer strictly.

No breaking changes will be made to APIs before v2.0.0.

This library has no dependencies outside the Go standard library.

💡 Quick start

This library provides a simple error builder for composing structured errors, with contextual attributes and stack trace.

Since oops.OopsError implements the error interface, you will be able to compose and wrap native errors with oops.OopsError.

🥷 Start hacking oops with this playground.

🧠 Spec

GoDoc: https://godoc.org/github.com/samber/oops

Error constructors

ConstructorDescription
.Errorf(format string, args ...any) errorFormats an error and returns oops.OopsError object that satisfies error
.Wrap(err error) errorWraps an error into an oops.OopsError object that satisfies error
.Wrapf(err error, format string, args ...any) errorWraps an error into an oops.OopsError object that satisfies error and formats an error message
.Recover(cb func()) errorHandle panic and returns oops.OopsError object that satisfies error.
.Recoverf(cb func(), format string, args ...any) errorHandle panic and returns oops.OopsError object that satisfies error and formats an error message.
.Assert(condition bool) OopsErrorBuilderPanics if condition is false. Assertions can be chained.
.Assertf(condition bool, format string, args ...any) OopsErrorBuilderPanics if condition is false and formats an error message. Assertions can be chained.
.Join(err1 error, err2 error, ...) errorJoin returns an error that wraps the given errors.

Examples

// with error wrapping
err0 := oops.
    In("repository").
    Tags("database", "sql").
    Wrapf(sql.Exec(query), "could not fetch user")  // Wrapf returns nil when sql.Exec() is nil

// with panic recovery
err1 := oops.
    In("repository").
    Tags("database", "sql").
    Recover(func () {
        panic("caramba!")
    })

// with assertion
err2 := oops.
    In("repository").
    Tags("database", "sql").
    Recover(func () {
        // ...
        oops.Assertf(time.Now().Weekday() == 1, "This code should run on Monday only.")
        // ...
    })

Context

The library provides an error builder. Each method can be used standalone (eg: oops.With(...)) or from a previous builder instance (eg: oops.In("iam").User("user-42")).

The oops.OopsError builder must finish with either .Errorf(...), .Wrap(...), .Wrapf(...), .Join(...), .Recover(...) or .Recoverf(...).

Builder methodGetterDescription
.With(string, any)err.Context() map[string]anySupply a list of attributes key+value. Values of type func() any {} are accepted and evaluated lazily.
.WithContext(context.Context, ...any)err.Context() map[string]anySupply a list of values declared in context. Values of type func() any {} are accepted and evaluated lazily.
.Code(string)err.Code() stringSet a code or slug that describes the error. Error messages are intented to be read by humans, but such code is expected to be read by machines and be transported over different services
.Public(string)err.Public() stringSet a message that is safe to show to an end user
.Time(time.Time)err.Time() time.TimeSet the error time (default: time.Now())
.Since(time.Time)err.Duration() time.DurationSet the error duration
.Duration(time.Duration)err.Duration() time.DurationSet the error duration
.In(string)err.Domain() stringSet the feature category or domain
.Tags(...string)err.Tags() []stringAdd multiple tags, describing the feature returning an error
.Trace(string)err.Trace() stringAdd a transaction id, trace id, correlation id... (default: ULID)
.Span(string)err.Span() stringAdd a span representing a unit of work or operation... (default: ULID)
.Hint(string)err.Hint() stringSet a hint for faster debugging
.Owner(string)err.Owner() (string)Set the name/email of the collegue/team responsible for handling this error. Useful for alerting purpose
.User(string, any...)err.User() (string, map[string]any)Supply user id and a chain of key/value
.Tenant(string, any...)err.Tenant() (string, map[string]any)Supply tenant id and a chain of key/value
.Request(*http.Request, bool)err.Request() *http.RequestSupply http request
.Response(*http.Response, bool)err.Response() *http.ResponseSupply http response
.FromContext(context.Context)Reuse an existing OopsErrorBuilder transported in a Go context

Examples

// simple error with public facing message
err0 := oops.
    Public("Could not fetch user.").
    Errorf("sql: bad connection")

// simple error with stacktrace
err1 := oops.Errorf("could not fetch user")

// with optional domain
err2 := oops.
    In("repository").
    Tags("database", "sql").
    Errorf("could not fetch user")

// with custom attributes
ctx := context.WithContext(context.Background(), "a key", "value")
err3 := oops.
    With("driver", "postgresql").
    With("query", query).
    With("query.duration", queryDuration).
    With("lorem", func() string { return "ipsum" }).	// lazy evaluation
    WithContext(ctx, "a key", "another key").
    Errorf("could not fetch user")

// with trace+span
err4 := oops.
    Trace(traceID).
    Span(spanID).
    Errorf("could not fetch user")

// with hint and ownership, for helping developer to solve the issue
err5 := oops.
    Hint("The user could have been removed. Please check deleted_at column.").
    Owner("Slack: #api-gateway").
    Errorf("could not fetch user")

// with optional userID
err6 := oops.
    User(userID).
    Errorf("could not fetch user")

// with optional user data
err7 := oops.
    User(userID, "firstname", "Samuel").
    Errorf("could not fetch user")

// with optional user and tenant
err8 := oops.
    User(userID, "firstname", "Samuel").
    Tenant(workspaceID, "name", "my little project").
    Errorf("could not fetch user")

// with optional http request and response
err9 := oops.
    Request(req, false).
    Response(res, true).
    Errorf("could not fetch user")

// reuse an existing OopsErrorBuilder transported in a Go context
ctx := oops.WithBuilder(context.TODO(), err9)
// [...]
err10 := oops.
    FromContext(ctx).
    Errorf("could not fetch user")

Other helpers

Stack trace

This library provides a pretty printed stack trace for each generated error.

The stack trace max depth can be set using:

// default: 10
oops.StackTraceMaxDepth = 42

The stack trace will be printed this way:

err := oops.Errorf("permission denied")

fmt.Println(err.(oops.OopsError).Stacktrace())
<div style="text-align:center;"> <img alt="Stacktrace" src="./assets/stacktrace1.png" style="max-width: 650px;"> </div>

Wrapped errors will be reported as an annotated stack trace:

err1 := oops.Errorf("permission denied")
// ...
err2 := oops.Wrapf(err1, "something failed")

fmt.Println(err2.(oops.OopsError).Stacktrace())
<div style="text-align:center;"> <img alt="Stacktrace" src="./assets/stacktrace2.png" style="max-width: 650px;"> </div>

Source fragments

The exact error location can be provided in a Go file extract.

Source fragments are hidden by default. You must run oops.SourceFragmentsHidden = false to enable this feature. Go source files being read at run time, you have to keep the source code at the same location.

In a future release, this library is expected to output a colorized extract. Please contribute!

oops.SourceFragmentsHidden = false

err1 := oops.Errorf("permission denied")
// ...
err2 := oops.Wrapf(err, "something failed")

fmt.Println(err2.(oops.OopsError).Sources())
<div style="text-align:center;"> <img alt="Sources" src="./assets/sources1.png" style="max-width: 650px;"> </div>

Panic handling

oops library is delivered with a try/catch -ish error handler. 2 handlers variants are available: oops.Recover() and oops.Recoverf(). Both can be used in the oops error builder with usual methods.

🥷 Start hacking oops.Recover() with this playground.

func mayPanic() {
	panic("permission denied")
}

func handlePanic() error {
    return oops.
        Code("iam_authz_missing_permission").
        In("authz").
        With("permission", "post.create").
        Trace("6710668a-2b2a-4de6-b8cf-3272a476a1c9").
        Hint("Runbook: https://doc.acme.org/doc/abcd.md").
        Recoverf(func() {
            // ...
            mayPanic()
            // ...
        }, "unexpected error %d", 42)
}

Assertions

Assertions may be considered an anti-pattern for Golang since we only call panic() for unexpected and critical errors. In this situation, assertions might help developers to write safer code.

func mayPanic() {
    x := 42

    oops.
        Trace("6710668a-2b2a-4de6-b8cf-3272a476a1c9").
        Hint("Runbook: https://doc.acme.org/doc/abcd.md").
        Assertf(time.Now().Weekday() == 1, "This code should run on Monday only.").
        With("x", x).
        Assertf(x == 42, "expected x to be equal to 42, but got %d", x)

    oops.Assert(re.Match(email))

    // ...
}

func handlePanic() error {
    return oops.
        Code("iam_authz_missing_permission").
        In("authz").
        Recover(func() {
            // ...
            mayPanic()
            // ...
        })
}

Output

Errors can be printed in many ways. Logger formatters provided in this library use these methods.

Errorf %w

str := fmt.Errorf("something failed: %w", oops.Errorf("permission denied"))

fmt.Println(err.Error())
// Output:
// something failed: permission denied

printf %v

err := oops.Errorf("permission denied")

fmt.Printf("%v", err)
// Output:
// permission denied

printf %+v

err := oops.Errorf("permission denied")

fmt.Printf("%+v", err)
<div style="text-align:center;"> <img alt="Output" src="./assets/output-printf-plusv.png" style="max-width: 650px;"> </div>

JSON Marshal

b := json.MarshalIndent(err, "", "  ")
<div style="text-align:center;"> <img alt="Output" src="./assets/output-json.png" style="max-width: 650px;"> </div>

slog.Valuer

err := oops.Errorf("permission denied")

attr := slog.Error(err.Error(),
    slog.Any("error", err))

// Output:
// slog.Group("error", ...)

Custom timezone

loc, _ := time.LoadLocation("Europe/Paris")
oops.Local = loc

Go context

An OopsErrorBuilder can be transported in a go context.Context to reuse later.

func myFunc(ctx context.Context) {
    oops.
        FromContext(ctx).
        Tag("auth").
        Errorf("not permitted")
}

func main() {
    err := oops.
        In("my domain").
        User("user-123")
    ctx := oops.WithBuilder(context.TODO(), err)

    myFunc(ctx)
}

📫 Loggers

Some loggers may need a custom formatter to extract attributes from oops.OopsError.

Available loggers:

We are looking for contributions and examples for:

Examples of formatters can be found in ToMap(), Format(), Marshal() and LogValuer methods of oops.OopsError.

🥷 Tips and best practices

Public facing error message

Humans do not like technical errors. The oops container can bring an additional human-readable message.

err := oops.
    Public("Could not fetch user.").
    Errorf("sql: bad connection")

userMessage := oops.GetPublic(err, "Unexpected error")

Wrap/Wrapf shortcut

oops.Wrap(...) and oops.Wrapf(...) returns nil if the provided error is nil.

❌ So don't write:

err := mayFail()
if err != nil {
    return oops.Wrapf(err, ...)
}

return nil

✅ but write:

return oops.Wrapf(mayFail(), ...)

Reuse error builder

Writing a full contextualized error can be painful and very repetitive. But a single context can be used for multiple errors in a single function:

❌ So don't write:

err := mayFail1()
if err != nil {
    return oops.
        In("iam").
        Trace("77cb6664").
        With("hello", "world").
        Wrap(err)
}

err = mayFail2()
if err != nil {
    return oops.
        In("iam").
        Trace("77cb6664").
        With("hello", "world").
        Wrap(err)
}

return oops.
    In("iam").
    Trace("77cb6664").
    With("hello", "world").
    Wrap(mayFail3())

✅ but write:

errorBuilder := oops.
    In("iam").
    Trace("77cb6664").
    With("hello", "world")

err := mayFail1()
if err != nil {
    return errorBuilder.Wrap(err)
}

err = mayFail2()
if err != nil {
    return errorBuilder.Wrap(err)
}

return errorBuilder.Wrap(mayFail3())

Caller/callee attributes

Also, think about feeding error context in every caller, instead of adding extra information at the last moment.

❌ So don't write:

func a() error {
    return b()
}

func b() error {
    return c()
}

func c() error {
    return d()
}

func d() error {
    return oops.
        Code("iam_missing_permission").
        In("authz").
        Trace("4ea76885-a371-46b0-8ce0-b72b277fa9af").
        Time(time.Now()).
        With("hello", "world").
        With("permission", "post.create").
        Hint("Runbook: https://doc.acme.org/doc/abcd.md").
        User("user-123", "firstname", "john", "lastname", "doe").
        Tenant("organization-123", "name", "Microsoft").
        Errorf("permission denied")
}

✅ but write:

func a() error {
	return b()
}

func b() error {
    return oops.
        In("iam").
        Trace("4ea76885-a371-46b0-8ce0-b72b277fa9af").
        With("hello", "world").
        Wrapf(c(), "something failed")
}

func c() error {
    return d()
}

func d() error {
    return oops.
        Code("iam_missing_permission").
        In("authz").
        Time(time.Now()).
        With("permission", "post.create").
        Hint("Runbook: https://doc.acme.org/doc/abcd.md").
        User("user-123", "firstname", "john", "lastname", "doe").
        Tenant("organization-123", "name", "Microsoft").
        Errorf("permission denied")
}

🤝 Contributing

Don't hesitate ;)

# Install some dev dependencies
make tools

# Run tests
make test
# or
make watch-test

👤 Contributors

Contributors

💫 Show your support

Give a ⭐️ if this project helped you!

GitHub Sponsors

📝 License

Copyright © 2023 Samuel Berthe.

This project is MIT licensed.