errext

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 5, 2026 License: GPL-3.0 Imports: 12 Imported by: 0

README

errext

test lint

A small, standard-library-only Go error package that extends errors with stack-trace capture, contextual prefix wrapping, structured JSON / log/slog output, optional caller-supplied metadata, and panic-recovery helpers. It preserves errors.Is / errors.As / errors.Unwrap through every wrapper.

Install

go get github.com/neumachen/errext

Minimum Go version: 1.24.

Why

errext aims to be the smallest useful extension to errors:

  • enriched error values without a logging-framework dependency,
  • standard error semantics (Unwrap, Is, As, fmt.Errorf %w),
  • safe concurrent reads, immutable wrappers, no surprise filesystem reads.

It is deliberately not a logging framework, metrics framework, tracing SDK, or domain error taxonomy.

Quick start

import (
    "encoding/json"
    "errors"
    "fmt"

    "github.com/neumachen/errext"
)

var ErrNotFound = errors.New("not found")

func lookup(id string) error {
    if id == "" {
        return errext.Errorf("lookup %q: %w", id, ErrNotFound)
    }
    // ...
    return nil
}

func main() {
    err := lookup("")

    fmt.Println(errors.Is(err, ErrNotFound)) // true

    // Add context without mutating the wrapped error.
    wrapped := errext.WrapPrefix(err, "user lookup", 0).(*errext.TraceError)

    // Attach JSON metadata. Validation happens at set time.
    md := json.RawMessage(`{"request_id":"abc-123"}`)
    if e := wrapped.SetMetadata(&md); e != nil {
        // invalid JSON, surfaced immediately
    }

    fmt.Printf("%v\n", wrapped)   // user lookup: lookup "": not found
    fmt.Printf("%+v\n", wrapped)  // ...then a runtime stack section
}

Behavior

  • Nil in, nil out. NewError(nil), Wrap(nil, ...), and WrapPrefix(nil, ..., ...) all return nil. No more accidental conversion of a success path into a failure path.
  • Wrapping never mutates the wrapped error. Each Wrap / WrapPrefix captures a fresh stack and returns a new *TraceError. The wrapped value is reachable via Unwrap and the deepest non-errext cause is available via Cause.
  • Standard errors integration. errors.Is, errors.As, errors.Unwrap, and fmt.Errorf("…: %w", err) all work through the wrappers. errext.Is is a thin wrapper around errors.Is.
  • Immutable from the caller's perspective. Stack(), StackFrames(), and Metadata() return copies; mutating them does not affect the error.
  • Concurrency safe. All read methods are safe under concurrent use. SetMetadata is safe to call concurrently with reads; it validates with json.Valid and stores a clone of the bytes.
  • No filesystem reads in default formatting. StackFrame.String() and RuntimeStack() never open source files. The opt-in StackFrame.SourceLine helper remains for explicit callers.

API surface

Canonical type:

type TraceError struct { /* unexported */ }

func (e *TraceError) Error() string
func (e *TraceError) Unwrap() error
func (e *TraceError) Cause() error
func (e *TraceError) Prefix() string
func (e *TraceError) Type() string
func (e *TraceError) Stack() []uintptr
func (e *TraceError) StackFrames() []StackFrame
func (e *TraceError) RuntimeStack() []byte
func (e *TraceError) Metadata() *json.RawMessage
func (e *TraceError) SetMetadata(*json.RawMessage) error
func (e *TraceError) UnmarshalMetadata(target any) error
func (e *TraceError) Record() Record
func (e *TraceError) MarshalJSON() ([]byte, error)
func (e *TraceError) LogValue() slog.Value
func (e *TraceError) Format(s fmt.State, verb rune)

Constructors (return Error interface for source compatibility):

func NewError(cause error) Error
func Errorf(format string, a ...any) Error
func Wrap(err error, stackToSkip int) Error
func WrapPrefix(err error, prefix string, skip int) Error
func Is(err, target error) bool                       // == errors.Is
func ParsePanic(s string) (Error, error)
func FromPanic(value any, stack []byte) *TraceError

Deprecated but retained:

type Error interface { /* ... */ }      // Deprecated: prefer *TraceError
type ErrorSetter interface { /* ... */ }// Deprecated: metadata-only
func NewErrorf(format string, a ...any) Error // Deprecated: alias of Errorf
var  MaxStackDepth int                  // Deprecated: prefer DefaultMaxStackDepth

Constants:

const DefaultMaxStackDepth = 50

Structured output

*TraceError marshals to a stable Record:

{
  "message":      "user lookup: lookup \"\": not found",
  "cause":        "not found",
  "type":         "*errors.errorString",
  "prefix":       "user lookup",
  "stack_frames": [
    {
      "file": "/path/to/file.go",
      "line_number": 42,
      "name": "FunctionName",
      "package": "github.com/example/pkg",
      "program_counter": 1234567
    }
  ],
  "stack":   [1234567, 2345678],
  "metadata": {"request_id": "abc-123"}
}

message is the full contextual error (identical to Error()); cause is the deepest non-*TraceError cause. type is diagnostic only — it is not stable enough to drive control flow; use sentinel errors with errors.Is or typed errors with errors.As instead.

*TraceError also implements slog.LogValuer, producing the same fields as a slog group attribute (the raw stack PCs are omitted to keep log lines compact).

Recovering from panics

import "runtime/debug"

func doWork() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = errext.FromPanic(r, debug.Stack())
        }
    }()
    // ...
    return nil
}

FromPanic returns a *TraceError whose Type() is "panic" and whose RuntimeStack() is the supplied debug.Stack() bytes (when non-nil), or a freshly captured stack (when nil).

ParsePanic parses pre-formatted panic strings; it remains useful for post-mortem analysis of crash logs. Use FromPanic for in-process recovery.

Security note

Stack frames may include absolute file paths and function names, and metadata may contain caller-controlled data. Do not expose the full structured representation to untrusted clients without first considering whether the contents are safe to disclose.

Testing

go test ./...
go test -race ./...
go test -run='^$' -fuzz=FuzzParsePanic -fuzztime=30s
go test -bench=. -benchmem -run='^$' ./...

License

See LICENSE.md.

Documentation

Overview

Package errext is a small, dependency-free extension of Go's standard errors package. It adds stack-trace capture, contextual prefix wrapping, structured JSON / log/slog output, optional caller-supplied metadata, and panic-recovery helpers, while preserving standard errors.Is / errors.As / errors.Unwrap semantics.

Quick start

err := errext.Errorf("failed to process %s: %w", item, cause)

if errors.Is(err, cause) {
    // standard errors.Is sees through errext wrappers
}

// Add context without mutating the wrapped error:
wrapped := errext.WrapPrefix(err, "validation failed", 0)

// Attach structured metadata (validated as JSON at set time):
md := json.RawMessage(`{"request_id":"abc-123"}`)
wrapped.(*errext.TraceError).SetMetadata(&md)

Core type

The package's canonical type is *TraceError. All constructors return values backed by *TraceError; the historical Error interface is retained as a deprecated alias for source compatibility.

A *TraceError carries:

  • the wrapped cause (visible via Unwrap and Cause),
  • a captured runtime stack (Stack, StackFrames),
  • an optional prefix (Prefix),
  • optional caller-supplied JSON metadata (Metadata, SetMetadata).

It implements error, fmt.Formatter, json.Marshaler, and slog.LogValuer.

Behavior changes from earlier versions

  • Nil in, nil out. NewError(nil), Wrap(nil, ...), and WrapPrefix(nil, ..., ...) all return nil.
  • Wrapping never mutates the wrapped error. Each Wrap / WrapPrefix call produces a new *TraceError with a fresh stack capture.
  • StackFrames(), Stack(), and Metadata() return copies; callers may freely mutate the returned slices.
  • Default stack formatting no longer reads source files from disk. The opt-in StackFrame.SourceLine helper remains for explicit callers.
  • errext.Is is a thin wrapper around errors.Is.

Structured output

JSON marshaling produces a Record value:

{
    "message":      "ctx: root cause",   // == Error()
    "cause":        "root cause",        // deepest non-TraceError cause
    "type":         "*errors.errorString",
    "prefix":       "ctx",
    "stack_frames": [...],
    "stack":        [...],
    "metadata":     {...}
}

slog.LogValuer emits the same fields as a group attribute, omitting the raw stack PCs to keep log lines compact.

Concurrency

All methods on *TraceError are safe for concurrent use. The wrapped cause, prefix, and captured PCs are immutable after construction; metadata access is guarded by an internal RWMutex.

Security note

Stack frames may include absolute file paths and function names, and metadata may contain caller-controlled data. Do not expose the full structured representation to untrusted clients without first considering whether the contents are safe to disclose.

Index

Constants

View Source
const DefaultMaxStackDepth = 50

DefaultMaxStackDepth is the default cap on captured program counters per error. Negative or zero values are clamped to this default at capture time.

Variables

View Source
var MaxStackDepth = DefaultMaxStackDepth

MaxStackDepth caps the number of program counters captured per error.

Deprecated: this is a process-wide mutable global with no synchronization. New code should rely on the default. The variable is retained only for source compatibility; invalid values (<= 0) are clamped to DefaultMaxStackDepth at capture time.

Functions

func Is deprecated

func Is(err, target error) bool

Is reports whether any error in err's chain matches target. It is a thin wrapper around the standard library's errors.Is.

Deprecated: call errors.Is directly. This function is retained for source compatibility.

Types

type Error deprecated

type Error interface {
	error
	Cause() error
	StackFrames() []StackFrame
	Stack() []uintptr
	Prefix() string
	Type() string
	RuntimeStack() []byte
	Metadata() *json.RawMessage
	SetMetadata(*json.RawMessage) error
	UnmarshalMetadata(target any) error
}

Error is the historical exported interface of this package.

Deprecated: new code should accept the standard error interface and use type assertions to *TraceError for enriched access. This interface is retained for source compatibility.

func Errorf

func Errorf(format string, a ...any) Error

Errorf creates a *TraceError from a formatted message. It is the canonical alias for the historical NewErrorf and is the form the README recommends. %w directives participate in errors.Is / errors.As.

func NewError

func NewError(cause error) Error

NewError returns a *TraceError wrapping cause. It returns nil if cause is nil, following the Go convention that nil means no error.

func NewErrorf deprecated

func NewErrorf(format string, a ...any) Error

NewErrorf creates a *TraceError from a formatted message. The message is produced via fmt.Errorf, so %w directives in format participate in errors.Is / errors.As walks through the wrapper.

Deprecated: prefer Errorf. NewErrorf is retained for source compatibility.

func ParsePanic

func ParsePanic(panicToParse string) (Error, error)

ParsePanic converts a panic stack-trace string into a *TraceError. The input is expected to start with "panic: <message>" followed by the "goroutine N [running]:" section emitted by the Go runtime. Frames are parsed without recording program counters; StackFrames() will return the parsed entries as-is.

ParsePanic never panics on malformed input; it returns a descriptive error instead. For newer code that has access to the recovered value and the raw debug.Stack() bytes, prefer FromPanic.

func Wrap

func Wrap(err error, stackToSkip int) Error

Wrap returns a *TraceError around err with a fresh stack capture at the call site. It returns nil if err is nil. Unlike historical behavior, Wrap never returns the input pointer aliased; wrapping an existing *TraceError produces a new wrapper that Unwraps to it.

stackToSkip is added to the number of frames hidden from the captured stack; 0 starts the stack at the caller of Wrap.

func WrapPrefix

func WrapPrefix(err error, prefix string, skip int) Error

WrapPrefix returns a new *TraceError that wraps err and prepends prefix to the contextual error message. The wrapped error is not mutated. Calling Error() on the result walks the wrapper chain, so wrapping a prefixed TraceError yields "outer: inner: base".

WrapPrefix returns nil if err is nil.

type ErrorSetter deprecated

type ErrorSetter interface {
	SetMetadata(*json.RawMessage) error
}

ErrorSetter is the historical mutation interface.

Deprecated: prefer calling SetMetadata directly on *TraceError. This interface is retained for source compatibility and is reduced to metadata mutation only; the prefix is no longer mutable after construction.

type Record

type Record struct {
	// Message is the full contextual error message, identical to Error().
	Message string `json:"message,omitempty"`
	// Cause is the deepest non-TraceError cause message.
	Cause string `json:"cause,omitempty"`
	// Type is a diagnostic Go type string (e.g. "*errors.errorString",
	// "panic"). It is not stable enough for domain control flow.
	Type string `json:"type,omitempty"`
	// Prefix is this wrapper's own prefix; it does not include prefixes
	// contributed by wrapped TraceErrors.
	Prefix string `json:"prefix,omitempty"`
	// StackFrames contains resolved frame data with no source-code lines.
	StackFrames []StackFrame `json:"stack_frames,omitempty"`
	// Stack contains the raw captured program counters.
	Stack []uintptr `json:"stack,omitempty"`
	// Metadata is caller-supplied raw JSON.
	Metadata *json.RawMessage `json:"metadata,omitempty"`
}

Record is the stable structured representation of a TraceError used by MarshalJSON and LogValue. Field names are part of the package's public surface; new fields may be added but existing ones will not silently change meaning.

type StackFrame

type StackFrame struct {
	// File is the absolute path to the source file containing the frame.
	File string `json:"file"`
	// LineNumber is the 1-based line within File.
	LineNumber int `json:"line_number"`
	// Name is the function name with any package prefix stripped.
	Name string `json:"name"`
	// Package is the import path of the package that contains the
	// function, including a trailing slash when applicable.
	Package string `json:"package"`
	// ProgramCounter is the raw runtime program counter for the frame.
	// It may be zero for frames that were parsed from a text stack trace.
	ProgramCounter uintptr `json:"program_counter"`
}

StackFrame describes a single resolved frame of a captured stack. The fields carry source-location metadata but never contain the source-code line itself; the latter is available on demand via SourceLine.

func NewStackFrame

func NewStackFrame(pc uintptr) StackFrame

NewStackFrame builds a StackFrame from a program counter. Frames whose program counter does not resolve to a known function are returned with only ProgramCounter populated.

func (StackFrame) Func

func (s StackFrame) Func() *runtime.Func

Func returns the runtime.Func describing the frame, or nil if the program counter does not resolve.

func (*StackFrame) SourceLine

func (s *StackFrame) SourceLine() (string, error)

SourceLine returns the line of source code referenced by the frame. It reads the file from disk, so callers should treat the result as opt-in debug aid and avoid invoking it on hot paths or untrusted file paths.

func (StackFrame) String

func (s StackFrame) String() string

String returns a one-frame description suitable for diagnostic stack dumps. The format is:

<package>/<name>
\t<file>:<line> +0x<pc>

String never reads source files from disk. To attach the source line, call SourceLine explicitly and append it.

type TraceError

type TraceError struct {
	// contains filtered or unexported fields
}

TraceError is an enriched error value with a captured stack trace, an optional contextual prefix, optional structured metadata, and standard errors.Unwrap support.

A *TraceError is immutable apart from its metadata slot. All methods are safe for concurrent use; reads of mutable state take a read lock, and SetMetadata takes a write lock. Wrapping an existing *TraceError produces a new *TraceError without mutating the wrapped value.

The zero value is not usable; obtain a *TraceError via NewError, Wrap, WrapPrefix, NewErrorf, Errorf, ParsePanic, or FromPanic.

func FromPanic

func FromPanic(value any, stack []byte) *TraceError

FromPanic constructs a *TraceError from a value recovered via recover().

The cause is fmt.Sprint(value) reported with Type "panic". If stack is non-nil it is preserved as the error's runtime stack output; otherwise a fresh runtime.Callers capture is taken at the call site. The expected usage is:

defer func() {
    if r := recover(); r != nil {
        err = errext.FromPanic(r, debug.Stack())
    }
}()

func (*TraceError) Cause

func (e *TraceError) Cause() error

Cause returns the deepest non-TraceError cause in the wrapper chain. It is retained for source compatibility with callers that want the "original" error; new code should use errors.Is / errors.As / errors.Unwrap.

func (*TraceError) Error

func (e *TraceError) Error() string

Error returns the contextual error message. When the error has a prefix, the prefix is prepended with a colon separator. Wrapped errors contribute their own prefixes via the Unwrap chain.

func (*TraceError) Format

func (e *TraceError) Format(s fmt.State, verb rune)

Format implements fmt.Formatter.

%s, %v  → Error()
%q      → quoted Error()
%+v     → Error() followed by RuntimeStack()

func (*TraceError) LogValue

func (e *TraceError) LogValue() slog.Value

LogValue returns a slog.Value with the structured fields from Record. The raw stack PCs are omitted from the slog output to keep log lines compact; they remain available via JSON marshaling and the Stack method.

func (*TraceError) MarshalJSON

func (e *TraceError) MarshalJSON() ([]byte, error)

MarshalJSON encodes the error as a Record.

func (*TraceError) Metadata

func (e *TraceError) Metadata() *json.RawMessage

Metadata returns a deep copy of the caller-supplied metadata, or nil if none was set.

func (*TraceError) Prefix

func (e *TraceError) Prefix() string

Prefix returns this wrapper's own prefix string. It does not include prefixes contributed by wrapped TraceErrors.

func (*TraceError) Record

func (e *TraceError) Record() Record

Record returns a snapshot of the error suitable for structured output. The returned StackFrames and Stack slices are copies owned by the caller.

func (*TraceError) RuntimeStack

func (e *TraceError) RuntimeStack() []byte

RuntimeStack returns a formatted byte slice describing the captured stack. The format is the package's own representation; it does not read source files from disk and is not guaranteed to match runtime/debug.Stack().

For TraceErrors built from a pre-formatted panic stack (FromPanic with a non-nil stack argument), RuntimeStack returns those raw bytes.

func (*TraceError) SetMetadata

func (e *TraceError) SetMetadata(metadata *json.RawMessage) error

SetMetadata stores metadata on the error. Passing nil clears any previously stored metadata. Non-nil metadata is validated with json.Valid and cloned; invalid JSON is reported immediately and the previous value is left unchanged. SetMetadata is safe for concurrent use.

func (*TraceError) Stack

func (e *TraceError) Stack() []uintptr

Stack returns a copy of the captured program counters. Callers may freely mutate the returned slice.

func (*TraceError) StackFrames

func (e *TraceError) StackFrames() []StackFrame

StackFrames returns a copy of the resolved stack frame data. Frames are resolved lazily on first call. Subsequent calls reuse the cached frames and return a fresh copy each time.

func (*TraceError) Type

func (e *TraceError) Type() string

Type returns a Go type string describing the underlying cause. For errors produced by ParsePanic or FromPanic it returns "panic". The empty string is returned when no cause is present. The result is diagnostic only and is not stable enough for domain control flow.

func (*TraceError) UnmarshalMetadata

func (e *TraceError) UnmarshalMetadata(target any) error

UnmarshalMetadata decodes the stored metadata into target. It returns nil without touching target when no metadata is present.

func (*TraceError) Unwrap

func (e *TraceError) Unwrap() error

Unwrap returns the immediate wrapped cause for use with errors.Is and errors.As.

Jump to

Keyboard shortcuts

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