Skip to content

Commit de1dec6

Browse files
committed
feat(stack): error stack traces
1 parent ad12ebb commit de1dec6

File tree

7 files changed

+239
-54
lines changed

7 files changed

+239
-54
lines changed

errors/stack.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package errors
2+
3+
import (
4+
"strings"
5+
6+
"go.chrisrx.dev/x/slices"
7+
"go.chrisrx.dev/x/stack"
8+
)
9+
10+
type Frames []stack.Frame
11+
12+
func (f Frames) String() string {
13+
return strings.Join(slices.Map(f, func(f stack.Frame) string {
14+
return f.String()
15+
}), "\n ")
16+
}
17+
18+
type StackError interface {
19+
error
20+
21+
// Trace returns a stack trace for this error.
22+
Trace() Frames
23+
24+
isStackError()
25+
}
26+
27+
type stackError struct {
28+
error
29+
30+
frames []stack.Frame
31+
}
32+
33+
var _ StackError = (*stackError)(nil)
34+
35+
func Stack(err error) error {
36+
if err == nil {
37+
return nil
38+
}
39+
if _, ok := err.(StackError); ok {
40+
return err
41+
}
42+
return &stackError{
43+
error: err,
44+
frames: stack.Trace(1),
45+
}
46+
}
47+
48+
func (e *stackError) Error() string { return e.error.Error() }
49+
func (e *stackError) Unwrap() error { return e.error }
50+
51+
func (e *stackError) Trace() Frames {
52+
return e.frames
53+
}
54+
55+
func (e *stackError) isStackError() {}

errors/stack_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package errors_test
2+
3+
import (
4+
"cmp"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"go.chrisrx.dev/x/assert"
10+
"go.chrisrx.dev/x/errors"
11+
"go.chrisrx.dev/x/slices"
12+
"go.chrisrx.dev/x/stack"
13+
)
14+
15+
func getError() error {
16+
return getStackError()
17+
}
18+
19+
func getStackError() error {
20+
return errors.Stack(fmt.Errorf("is a stack error"))
21+
}
22+
23+
func TestStackError(t *testing.T) {
24+
err, _ := errors.As[errors.StackError](getError())
25+
assert.Error(t, "is a stack error", err)
26+
assert.Equal(t,
27+
[]string{
28+
"go.chrisrx.dev/x/errors_test.getStackError",
29+
"go.chrisrx.dev/x/errors_test.getError",
30+
"go.chrisrx.dev/x/errors_test.TestStackError",
31+
},
32+
slices.Filter(slices.Map(err.Trace(), func(f stack.Frame) string {
33+
return f.Name()
34+
}), func(name string) bool {
35+
return !cmp.Or(
36+
strings.HasPrefix(name, "testing"),
37+
strings.HasPrefix(name, "runtime"),
38+
)
39+
}))
40+
}

log/alias.go

Lines changed: 0 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

log/log.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
//go:generate go tool aliaspkg -docs=decls -include Fatal,Fatalf,Fatalln,Panic,Panicf,Panicln,Print,Printf,Println
1+
//go:generate go tool aliaspkg -docs=decls -include Panic,Panicf,Panicln,Print,Printf,Println
22

33
package log
44

55
import (
6+
"fmt"
67
"log/slog"
8+
"os"
79
"sync"
810

911
"go.chrisrx.dev/x/env"
12+
"go.chrisrx.dev/x/errors"
13+
"go.chrisrx.dev/x/slices"
14+
"go.chrisrx.dev/x/strings"
1015
)
1116

1217
// New constructs a new [*slog.Logger] with the provided options.
@@ -37,3 +42,80 @@ func SetDefault() {
3742
slog.Debug("default slog configured from environment", slog.Any("options", opts))
3843
})
3944
}
45+
46+
// Fatal is similar to [log.Fatal], but uses the default [slog.Logger]. It is
47+
// meant to be used at the end of an error chain only.
48+
//
49+
// If GO_BACKTRACE=1 environment variable is set, any arguments containing a
50+
// stack trace will be printed directly to stderr. Only the first argument
51+
// containing a stack trace will be printed.
52+
func Fatal(v ...any) {
53+
msg := fmt.Sprint(v...)
54+
if isBacktraceEnabled() {
55+
printBacktrace(msg, v)
56+
}
57+
slog.Error(msg)
58+
os.Exit(1)
59+
}
60+
61+
// Fatalf is similar to [log.Fatalf], but uses the default [slog.Logger]. It is
62+
// meant to be used at the end of an error chain only.
63+
//
64+
// If GO_BACKTRACE=1 environment variable is set, any arguments containing a
65+
// stack trace will be printed directly to stderr. Only the first argument
66+
// containing a stack trace will be printed.
67+
func Fatalf(format string, v ...any) {
68+
msg := fmt.Sprintf(format, v...)
69+
if isBacktraceEnabled() {
70+
printBacktrace(msg, v)
71+
}
72+
slog.Error(msg)
73+
os.Exit(1)
74+
}
75+
76+
// Fatalln is similar to [log.Fatalln], but uses the default [slog.Logger]. It
77+
// is meant to be used at the end of an error chain only.
78+
//
79+
// If GO_BACKTRACE=1 environment variable is set, any arguments containing a
80+
// stack trace will be printed directly to stderr. Only the first argument
81+
// containing a stack trace will be printed.
82+
func Fatalln(v ...any) {
83+
msg := fmt.Sprintln(v...)
84+
if isBacktraceEnabled() {
85+
printBacktrace(msg, v)
86+
}
87+
slog.Error(msg)
88+
os.Exit(1)
89+
}
90+
91+
const backtraceEnvVar = "GO_BACKTRACE"
92+
93+
func isBacktraceEnabled() bool {
94+
if value, ok := os.LookupEnv(backtraceEnvVar); ok && value == "1" {
95+
return true
96+
}
97+
return false
98+
}
99+
100+
// checkBacktrace checks if any arguments implement [errors.StackError] and
101+
// prints the full stack trace to stderr. Arguments are checked in order and if
102+
// multiple stack traces are provided, only the first is printed.
103+
//
104+
// This behavior is opt-in, requiring environment variable GO_BACKTRACE=1 to be
105+
// set.
106+
func printBacktrace(msg string, args []any) {
107+
if len(args) == 0 {
108+
return
109+
}
110+
for _, arg := range args {
111+
if err, ok := arg.(error); ok {
112+
if err, ok := errors.As[errors.StackError](err); ok {
113+
if v, ok := os.LookupEnv(backtraceEnvVar); ok && v == "1" {
114+
slog.Error(msg)
115+
fmt.Fprintln(os.Stderr, strings.Join(slices.Map(err.Trace(), strings.ToString), "\n\t"))
116+
os.Exit(1)
117+
}
118+
}
119+
}
120+
}
121+
}

must/recover.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ func Recover(errs ...error) {
2020
if r := recover(); r != nil {
2121
if len(errs) == 0 {
2222
slog.Error("panic",
23-
slog.String("loc", stack.GetLocation(func(s stack.Source) bool {
23+
slog.String("loc", stack.Location(func(s stack.Frame) bool {
2424
return cmp.Or(
25-
strings.HasPrefix(s.FullName, "runtime"),
26-
strings.HasPrefix(s.FullName, "go.chrisrx.dev/x/must"),
27-
strings.HasPrefix(s.FullName, "go.chrisrx.dev/x/safe"),
25+
strings.HasPrefix(s.Name(), "runtime"),
26+
strings.HasPrefix(s.Name(), "go.chrisrx.dev/x/must"),
27+
strings.HasPrefix(s.Name(), "go.chrisrx.dev/x/safe"),
2828
)
2929
})),
3030
slog.Any("err", asError(r)),

stack/stack.go

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,74 @@ package stack
44

55
import (
66
"fmt"
7-
"path/filepath"
7+
"go/build"
8+
"reflect"
89
"runtime"
910
"strings"
1011
)
1112

12-
type Source struct {
13-
File string
14-
Line int
15-
FullName string
16-
}
13+
const maxStackDepth = 10
1714

18-
func (s Source) Name() string {
19-
return s.FullName[strings.LastIndex(s.FullName, "/")+1:]
15+
type Frame struct {
16+
pc uintptr
17+
file string
18+
line int
19+
name string
2020
}
2121

22-
func (s Source) String() string {
23-
name := s.Name()
24-
if name == "" {
25-
return fmt.Sprintf("%s:%d", s.File, s.Line)
22+
func (f Frame) String() string {
23+
s := fmt.Sprintf("%s:%d", f.file, f.line)
24+
if f.name == "" {
25+
return s
2626
}
27-
return fmt.Sprintf("%s:%d %s", s.File, s.Line, name)
27+
return s + fmt.Sprintf(" -- %s()", f.name[strings.LastIndex(f.name, "/")+1:])
2828
}
2929

30-
func GetSource(skip int) Source {
31-
pc, file, line, _ := runtime.Caller(1 + skip)
32-
s := Source{
33-
File: filepath.Base(file),
34-
Line: line,
35-
}
36-
if fn := runtime.FuncForPC(pc); fn != nil {
37-
s.FullName = fn.Name()
38-
}
39-
return s
30+
func (f Frame) Name() string {
31+
return f.name
4032
}
4133

42-
const maxStackDepth = 10
34+
type dummy struct{}
35+
36+
var packageName = reflect.TypeOf(dummy{}).PkgPath()
4337

44-
func GetLocation(ignore func(Source) bool) string {
38+
func Trace(skip int) (frames []Frame) {
4539
for i := 1; i < maxStackDepth; i++ {
46-
s := GetSource(i + 1)
47-
if ignore(s) {
40+
pc, file, line, ok := runtime.Caller(i + skip)
41+
if !ok {
42+
break
43+
}
44+
fn := runtime.FuncForPC(pc)
45+
if fn == nil {
46+
break
47+
}
48+
name := fn.Name()
49+
file = strings.TrimPrefix(file, build.Default.GOROOT+"/src/")
50+
51+
// Filter out all frames from this package.
52+
if strings.HasPrefix(name, packageName) {
53+
continue
54+
}
55+
GOROOT := build.Default.GOROOT
56+
if len(GOROOT) > 0 && strings.Contains(file, GOROOT) {
57+
continue
58+
}
59+
frames = append(frames, Frame{
60+
pc: pc,
61+
file: file,
62+
line: line,
63+
name: name,
64+
})
65+
}
66+
return frames
67+
}
68+
69+
func Location(ignore func(Frame) bool) string {
70+
for _, frame := range Trace(1) {
71+
if ignore(frame) {
4872
continue
4973
}
50-
return s.String()
74+
return frame.String()
5175
}
5276
return "<unknown>"
5377
}

strings/strings.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"bufio"
77
"bytes"
88
"cmp"
9+
"fmt"
910
"strings"
1011
"unicode"
1112
"unicode/utf8"
@@ -89,3 +90,7 @@ func ToSnakeCase(s string) string {
8990
}
9091
return b.String()
9192
}
93+
94+
func ToString[T fmt.Stringer](s T) string {
95+
return s.String()
96+
}

0 commit comments

Comments
 (0)