Code Monkey home page Code Monkey logo

oops's Introduction

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.OopsError 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...

โš ๏ธ This is NOT a logging library. oops should be used as a complement to your existing logging toolchain (zap, zerolog, logrus, slog, go-sentry...).

๐Ÿฅท Start hacking oops with this playground.

logo: thanks Gimp

Jump:

๐Ÿค” Motivations

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

  • zap.Infow("failed to fetch URL", "url", url)
  • logrus.WithFields("url", url).Error("failed to fetch URL")).

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?

  • drop-in replacement to error
  • easy to integrate without large refactoring
  • separation of concern (logger vs error)
  • extra attributes
  • developer-friendly error builder
  • no extra code for output: can be used with loggers, printf syntax...
  • out-of-the-box stack trace and source fragments
  • one-line panic handling
  • one-line assertion

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.

oops!

Example

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
        )
    }
}
Why 'oops'?

๐Ÿš€ 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

Constructor Description
.Errorf(format string, args ...any) error Formats an error and returns oops.OopsError object that satisfies error
.Wrap(err error) error Wraps an error into an oops.OopsError object that satisfies error
.Wrapf(err error, format string, args ...any) error Wraps an error into an oops.OopsError object that satisfies error and formats an error message
.Recover(cb func()) error Handle panic and returns oops.OopsError object that satisfies error.
.Recoverf(cb func(), format string, args ...any) error Handle panic and returns oops.OopsError object that satisfies error and formats an error message.
.Assert(condition bool) OopsErrorBuilder Panics if condition is false. Assertions can be chained.
.Assertf(condition bool, format string, args ...any) OopsErrorBuilder Panics if condition is false and formats an error message. Assertions can be chained.

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(...) or .Wrapf(...).

Builder method Getter Description
.With(string, any) err.Context() map[string]any Supply a list of attributes key+value. Values of type func() any {} are accepted and evaluated lazily.
.WithContext(context.Context, ...any) err.Context() map[string]any Supply a list of values declared in context. Values of type func() any {} are accepted and evaluated lazily.
.Code(string) err.Code() string Set 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
.Time(time.Time) err.Time() time.Time Set the error time (default: time.Now())
.Since(time.Time) err.Duration() time.Duration Set the error duration
.Duration(time.Duration) err.Duration() time.Duration Set the error duration
.In(string) err.Domain() string Set the feature category or domain
.Tags(...string) err.Tags() []string Add multiple tags, describing the feature returning an error
.Trace(string) err.Trace() string Add a transaction id, trace id, correlation id... (default: ULID)
.Span(string) err.Span() string Add a span representing a unit of work or operation... (default: ULID)
.Hint(string) err.Hint() string Set 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.Request Supply http request
.Response(*http.Response, bool) err.Response() *http.Response Supply http response

Examples

// 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")

Other helpers

  • oops.AsError[MyError](error) (MyError, bool) as an alias to errors.As(...)

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())
Stacktrace

Wrapped errors will be reported as an annotated stack trace:

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

fmt.Println(err2.(oops.OopsError).Stacktrace())
Stacktrace

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())
Sources

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)
Output

JSON Marshal

b := json.MarshalIndent(err, "", "  ")
Output

slog.Valuer

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

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

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

๐Ÿ“ซ Loggers

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

Available loggers:

We are looking for contributions and examples for:

  • zap
  • go-sentry
  • other?

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

๐Ÿฅท Tips and best practices

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.

oops's People

Contributors

dependabot[bot] avatar joel-u410 avatar samber avatar ss49919201 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

oops's Issues

Zap example

Hi @samber,

I tried using oops with zap here is my example:

func main() {
	logger, _ := zap.NewProduction()
	err := oops.
		In("repository").
		Tags("database", "sql").
		With("deneme", "mehmet").
		Errorf("could not fetch user")

	logger.Error(err.Error(), zap.Error(err))
}
{"level":"error","ts":1684233642.017879,"caller":"test-api/main.go:17","msg":"could not fetch user","error":"could not fetch user","errorVerbose":"Oops: could not fetch user\nAt: 2023-05-16 10:40:42.017789 +0000 UTC\nDomain: repository\nTags: database, sql\nContext:\n  * deneme: mehmet\nStackstrace:\nOops: could not fetch user\n  --- at test-api/main.go:15 main()\n","stacktrace":"main.main/test-api/main.go:17"}

I couldn't achieve the log structure like your example in readme, would be great if I get errorVerbose as a json.

Structured stacktrace?

Current stacktrace just append strings, which is not good to read.
Can oops have some structured stacktrace implementation like https://github.com/rotisserie/eris ?

    "root":{
      "message":"error internal server",
      "stack":[
        "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.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"
      ]
    },

About trace and span

I added support for Trace and Span which are ULID by default.

For now, Span are not exported from ToMap(), Format(), Marshal() and LogValuer. Is this expected and how should we format it?

2 options come to my mind:

  • add Span to each the stack trace and source fragments
  • return a list of Span from the .Span() getter (not very useful since there is no context provided to each Span)

Currently, the .Span() getter only returns the Span of the parent oops.OopsError. The Trace is generated lazily by the getter only if none was provided.

Embed sources at compile time

It would be helpful to embed source code in binary, since the .Sources() getter read the sources at runtime, at location specified in the stack trace.

I don't see any developer-friendly way to do it, except by using an extra compilation step or codegen. ๐Ÿ™„

The best scenario would be to enable/disable it with a flag in the compilation line or with an environment variable.

Example:

OOPS_EMBED_SOURCES="none" // default
OOPS_EMBED_SOURCES="minimal"   // project
OOPS_EMBED_SOURCES="all"  // project + dependencies (very heavy!!)

Any idea ?

[Proposal] ๐ŸŽจ Beautiful human readable logs

Currently, the .Format() exporter does not return colorized output.

I wonder if we should automatically detect a tty mode or add a global parameter.

I'm not even sure this is our job to write a colorized and human-readable log, since this library is not intended to be a logger.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.