stacked

package module
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: BSD-3-Clause Imports: 6 Imported by: 0

README

stacked

Go Reference License

stacked is a Go library that attaches stack traces to your errors right at their source, with a linter that enforces wrapping at every error site. While standard Go error handling often leaves you guessing where an issue actually originated as it bubbles up through intermediate functions, stacked captures the context the moment the error is produced.

The linter enforces this "wrap at the source" policy across your entire codebase, offering a seamless debugging experience:

  • Zero Guesswork: By capturing the exact function, file, and line number where the error occurred, it cuts down debugging time drastically.
  • Foolproof Coverage: The linter acts as a safety net, guaranteeing that no error is left unwrapped.
  • Frictionless Integration: Wrapping is idempotent (the first wrap wins) and fully compatible with the standard library (errors.Is, errors.AsType, and errors.Unwrap), meaning your existing error-handling logic remains completely intact.
  • Effortless Adoption: Migrating an existing codebase doesn't require a tedious manual rewrite. Run the linter with the -fix flag to automatically apply wrapping everywhere in a single pass.

Beyond standard errors, stacked provides a Recover utility to catch panics and convert them into stacked errors. This allows you to log the panic's stack trace using your own custom format.

Install

go get github.com/tbeati/stacked

Usage

Wrap and log

Wrap the call that produces the error, then log it with slog, attaching the error and its stack trace as structured fields:

// Wrap at the source: the trace is captured here, pinning os.Chdir.
err := stacked.Wrap(os.Chdir("/no/such/directory"))
if err != nil {
	slog.Error("failed to change directory",
		slog.Any("error", err),
		slog.Any("stack", stacked.StackTrace(err)),
	)
	return
}

stacked.StackTrace(err) returns []stacked.StackFrame, and each frame has Function, File, and Line fields with JSON tags — so with slog.NewJSONHandler the trace serializes as a clean array of frames ready for your log pipeline.

Higher arity

Wrap2Wrap5 cover calls that return extra values alongside the error — the result passes straight through:

data, err := stacked.Wrap2(os.ReadFile("/no/such/file"))
Iterators

For range-over-func iterators, wrap the sequence with WrapSeq (or WrapSeq2 for iter.Seq2[T, error]) — the trace is captured at the yield site:

for err := range stacked.WrapSeq(produceErrors()) {
	slog.Error("step failed",
		slog.Any("error", err),
		slog.Any("stack", stacked.StackTrace(err)),
	)
}

When you drive a sequence with iter.Pull, wrap each pull with WrapPull (or WrapPull2) so the trace points at the next() call site instead:

next, stop := iter.Pull(produceErrors())
defer stop()
for {
	err, ok := stacked.WrapPull(next())
	if !ok {
		break
	}
	slog.Error("step failed", slog.Any("stack", stacked.StackTrace(err)))
}
Reformat panic stack traces

Recover runs a function and turns any panic (or runtime.Goexit) into a stacked error, with the trace pinned at the panic site:

stacked.Recover(
    func() {
		// code to run
    },
	func(err error) {
        slog.Error("panic occurred",
            slog.Any("panic", true),
            slog.Any("error", err),
            slog.Any("stack", stacked.StackTrace(err)),
        )
    },
	true,
)
Ignore sentinel errors

Some errors are expected control flow, not failures, and shouldn't carry a trace. Register them so every Wrap call returns them untouched (io.EOF is registered by default):

// By value: future Wrap calls return this error — or any error that
// wraps it (matched with errors.Is) — unchanged.
stacked.Ignore(sql.ErrNoRows)

// By predicate: ignore a whole class of errors by type.
stacked.IgnoreFunc(func(err error) bool {
	_, ok := errors.AsType[*os.PathError](err)
	return ok
})

// Now wrapping these is a no-op — no stack trace attached.
err := stacked.Wrap(row.Scan(&v)) // sql.ErrNoRows passes straight through

Linter

stacked-linter reports every error your code leaves unwrapped.

It only flags errors at the point they cross into your code. That covers:

  • Errors returned by functions outside your module (third-party or standard-library packages).
  • Errors returned by methods called through an interface, since the concrete implementation behind it is unknown.
  • Errors used from a constant or package-level variable, such as returning sql.ErrNoRows or your own ErrNotFound.
  • Errors built from a literal, such as &MyError{…}.
  • Errors received from channels.

Run it with -fix to apply the wrapping automatically, making adoption of stacked in an existing codebase a single-pass operation.

There are two ways to run the linter: as a standalone binary, or as a golangci-lint plugin. Both apply the same rules and accept the same configuration.

Example
func loadConfig() ([]byte, error) {
	return os.ReadFile("/etc/app/config.yaml") // reported: error returned by os.ReadFile is not wrapped with stacked
}

Applying the suggested fix yields:

func loadConfig() ([]byte, error) {
	return stacked.Wrap2(os.ReadFile("/etc/app/config.yaml"))
}
Suppressing a diagnostic

With the standalone binary, use the //stacked:disable directive:

err := tx.Rollback() //stacked:disable

Under golangci-lint, use the standard nolint directive with the linter name:

err := tx.Rollback() //nolint:stacked
Configuration

Three options tune what the linter considers worth wrapping. The options are the same whether you run the standalone binary or the golangci-lint plugin, but the configuration file differs, as shown in each section below.

packages-treated-as-external

Packages treated as third-party even though they're in your module — typically generated code. The linter ignores these packages, but treats errors they return to your code as crossing in from outside, so those calls still need wrapping.

Type: list of package import paths.

["your-module/generated"]
ignored-functions

Functions whose returned error never needs wrapping — typically error-decorating helpers like connectrpc.com/connect.NewError that take an already-wrapped error and return it, so the trace is already attached.

errors.AsType, errors.Join, errors.Unwrap, are ignored by default.

Type: list of fully-qualified function names, formatted <import-path>.<Func> or <import-path>.<Type>.<Method>.

["connectrpc.com/connect.NewError"]
check-function-arguments

Marks a specific function argument as an error supplied for comparison rather than produced by the program, so the linter leaves that argument unwrapped. Use it for arguments that receive an existing sentinel error to check against, like the target argument of errors.Is.

Type: list of objects with function (a fully-qualified name in the same format as above) and argument (the 1-based position of the error argument).

[{ "function": "github.com/stretchr/testify/require.ErrorIs", "argument": 3 }]

The target arguments of errors.Is and errors.As are ignored by default.

Standalone binary

Install and run the singlechecker binary:

go install github.com/tbeati/stacked/linter/cmd/stacked-linter@latest

stacked-linter ./...        # report
stacked-linter -fix ./...   # report and apply suggested fixes

Configuration goes in an optional stacked.json in the working directory:

{
    "packages-treated-as-external": ["example.com/generated"],
    "ignored-functions": ["connectrpc.com/connect.NewError"],
    "check-function-arguments": [
        { "function": "github.com/stretchr/testify/require.ErrorIs", "argument": 3 }
    ]
}
golangci-lint plugin

stacked ships as a golangci-lint module plugin. Reference the plugin in .custom-gcl.yml:

version: v2.12.2
plugins:
  - module: github.com/tbeati/stacked/linter
    import: github.com/tbeati/stacked/linter/gclplugin
    version: latest

Build the custom binary and enable the linter (named stacked) in .golangci.yml:

golangci-lint custom

Configuration goes under the plugin's settings:

version: "2"

linters:
  default: none
  enable:
    - stacked
  settings:
    custom:
      stacked:
        type: module
        description: Reports errors not wrapped with stacked.
        settings:
          packages-treated-as-external: ["example.com/generated"]
          ignored-functions: ["connectrpc.com/connect.NewError"]
          check-function-arguments:
            - function: github.com/stretchr/testify/require.ErrorIs
              argument: 3

Run the resulting ./custom-gcl run ./... as usual; --fix applies the suggested fixes.

Documentation

Overview

Package stacked attaches stack traces to errors. Wrap captures the call stack at its invocation site and stores it alongside the error; StackTrace retrieves the frames later. Wrapped errors satisfy errors.Is, errors.As, and errors.Unwrap, so they compose with standard error handling.

For the captured frames to point at where an error actually originated rather than at intermediate forwarders, wrap errors at their source — the call or expression that produces them:

err := stacked.Wrap(os.Chdir("/"))

Wrapping is idempotent: Wrap returns nil, already-wrapped errors, and ignored errors unchanged, so the first wrap wins. io.EOF is ignored by default; register additional ignored errors with Ignore or IgnoreFunc.

Recover converts panics and runtime.Goexit into stacked errors.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNilPanicValue is reported by [Recover] when the recovered panic value
	// is nil. On Go ≥ 1.21 this is unreachable without GODEBUG=panicnil=1; the
	// runtime converts panic(nil) into a *[runtime.PanicNilError] instead.
	ErrNilPanicValue = errors.New("panic with nil value")
	// ErrGoexitCalled is reported by [Recover] when its function exits via
	// [runtime.Goexit].
	ErrGoexitCalled = errors.New("runtime.Goexit called")
)

Functions

func Ignore added in v0.7.0

func Ignore(err error)

Ignore registers err so future Wrap, WrapSeq, or WrapPull calls return it unchanged instead of attaching a stack trace. Calls are deduplicated by identity; registering the same error twice is a no-op. io.EOF is registered by default.

func IgnoreFunc added in v0.7.0

func IgnoreFunc(ignoreFunc func(error) bool)

IgnoreFunc registers a predicate consulted alongside Ignore's list. If any registered predicate returns true for an error, the error is left unwrapped.

func Recover

func Recover(f func(), onPanic func(err error), exitOnPanic bool)

Recover runs f and routes its outcome through onPanic:

  • f returns normally: onPanic is not called.
  • f panics: onPanic is called with a *Error whose Err is the panic value (if it satisfies error) or fmt.Errorf("%v", value) otherwise, and whose stack trace points at the panic site.
  • f exits via runtime.Goexit: onPanic is called with a *Error wrapping ErrGoexitCalled.

onPanic may be nil. If exitOnPanic is true, Recover calls os.Exit(1) after onPanic returns.

Example
package main

import (
	"fmt"

	"github.com/tbeati/stacked"
)

func main() {
	stacked.Recover(
		func() {
			panic("something went wrong")
		},
		func(err error) {
			fmt.Println(err)

			stackTrace := stacked.StackTrace(err)
			_ = stackTrace // stack trace at the panic site
		},
		false,
	)

}
Output:
something went wrong

func Wrap

func Wrap(err error) error

Wrap attaches a stack trace to err captured at this call site. It returns nil unchanged, ignored errors unchanged, and already-wrapped errors unchanged (the first wrap wins). Use the arity-matching variant — Wrap2, Wrap3, Wrap4, Wrap5 — when wrapping the return of a function that produces additional values.

func Wrap2 added in v0.1.0

func Wrap2[T any](v T, err error) (T, error)

Wrap2 is the two-value form of Wrap; v is returned unchanged.

func Wrap3 added in v0.5.0

func Wrap3[T1, T2 any](v1 T1, v2 T2, err error) (T1, T2, error)

Wrap3 is the three-value form of Wrap; v1 and v2 are returned unchanged.

func Wrap4 added in v0.7.0

func Wrap4[T1, T2, T3 any](v1 T1, v2 T2, v3 T3, err error) (T1, T2, T3, error)

Wrap4 is the four-value form of Wrap; v1, v2, and v3 are returned unchanged.

func Wrap5 added in v0.7.0

func Wrap5[T1, T2, T3, T4 any](v1 T1, v2 T2, v3 T3, v4 T4, err error) (T1, T2, T3, T4, error)

Wrap5 is the five-value form of Wrap; v1, v2, v3, and v4 are returned unchanged.

func WrapPull added in v0.7.0

func WrapPull(err error, ok bool) (error, bool)

WrapPull wraps an error returned from an iter.Pull-style next function with a stack trace captured at the pull site. Already-wrapped errors and ignored errors pass through unchanged. The ok value is returned unchanged.

func WrapPull2 added in v0.7.0

func WrapPull2[T any](v T, err error, ok bool) (T, error, bool)

WrapPull2 is the three-value form of WrapPull; v is returned unchanged.

func WrapSeq added in v0.7.0

func WrapSeq(seq iter.Seq[error]) iter.Seq[error]

WrapSeq returns an iterator that wraps each error yielded by seq with a stack trace captured at yield time.

func WrapSeq2 added in v0.7.0

func WrapSeq2[T any](seq iter.Seq2[T, error]) iter.Seq2[T, error]

WrapSeq2 is the two-value form of WrapSeq; values of type T pass through unchanged.

Types

type Error

type Error struct {
	Err        error
	StackTrace []StackFrame
	// contains filtered or unexported fields
}

Error wraps an underlying error together with a stack trace captured at the wrap site. Recover one from an arbitrary error via errors.As or errors.AsType, or call StackTrace for the frames directly.

func (*Error) Error

func (se *Error) Error() string

Error returns the wrapped error's message.

func (*Error) Unwrap

func (se *Error) Unwrap() error

Unwrap returns the wrapped error.

type StackFrame

type StackFrame struct {
	Function string `json:"function"`
	File     string `json:"file"`
	Line     int    `json:"line"`
}

StackFrame is a single entry in a captured stack trace.

func StackTrace

func StackTrace(err error) []StackFrame

StackTrace returns the captured frames if err is or wraps a *Error, otherwise nil.

Directories

Path Synopsis
linter module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL