最近社内でGoのStacktraceを取る方法が話題に上がったので備忘録としてブログにしてみる。

Goでは標準機能としてスタックトレースを取る方法を提供していない。

これは適切なエラーメッセージを書かけばStacktraceは不要という思想なのかもしれないが、やはり大きめのプロジェクトになればStacktraceが欲しくなることはある。

3rdパーティ製のerrorパッケージを使うという手もあるが、Stacktraceを取るだけならそこまで難しいコードにならないため、自作も十分最初の選択肢に入るはず。

ということで、以下に備忘録的にStacktraceを取得できるerrorパッケージのコードを書いておく。

使用感は標準のerrorsパッケージと同じようにしつつ、 Stacktrace 関数にerrorを渡すことでStacktraceであるFrameの配列を取得できるようになっている。

また、 Format 関数でpanicと同じようなフォーマットの文字列を取得できるようにしている。

これにより、panicを解析してくれるようなクラウドサービスへ乗せたときに、エラーレポートも正しく解析してくれる。

唯一気をつけなければならないこととして、パッケージレベル変数でErrorfを使ってしまうとその呼出時点でStacktraceが記録されてしまうため、発生元のStacktraceが正しく取れなくなってしまう。

そのため、パッケージレベル変数でのerror定義にErrorfを使っている場合、警告を出してくれるLinterも用意した。

https://github.com/nametake/nopkgvarerrorf

package errors

import (
	"bytes"
	"errors"
	"fmt"
	"runtime"
	"strings"
)

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
	return errors.New(text)
}

// Errorf formats according to a format specifier and returns the string as a
// value that satisfies error.
//
// If the format specifier includes a %w verb with an error operand,
// the returned error will implement an Unwrap method returning the operand. It is
// invalid to include more than one %w verb or to supply it with an operand
// that does not implement the error interface. The %w verb is otherwise
// a synonym for %v.
func Errorf(format string, a ...interface{}) error {
	vars := make([]interface{}, 0, len(a))
	for _, err := range a {
		if e, ok := err.(error); ok {
			vars = append(vars, &stackError{
				err:    e,
				frames: frames(1),
			})
		} else {
			vars = append(vars, err)
		}
	}

	for _, v := range vars {
		if _, ok := v.(*stackError); ok {
			return fmt.Errorf(format, vars...)
		}
	}

	return &stackError{
		err:    fmt.Errorf(format, vars...),
		frames: frames(1),
	}
}

// StackTrace returns stack frames from error.
//
// You must use Errorf to add stack frames.
// If not added stack traces, StackTrace returns nil.
func StackTrace(err error) Frames {
	stackErr := &stackError{}

	for {
		if !errors.As(err, &stackErr) {
			break
		}
		err = stackErr.err
	}

	return stackErr.frames
}

// Frame represents a stack frame.
type Frame struct {
	File     string
	Line     int
	Function string
}

type Frames []*Frame

func (fs Frames) String() string {
	var b strings.Builder
	b.WriteString(goroutine())
	b.WriteByte('\n')
	for _, f := range fs {
		b.WriteString(f.String())
		b.WriteByte('\n')
	}
	return b.String()
}

func (e *stackError) Error() string {
	return e.err.Error()
}

func goroutine() string {
	buf := make([]byte, 64)
	buf = buf[:runtime.Stack(buf, false)]
	lines := bytes.Split(buf, []byte("\n"))
	return string(lines[0])
}

func (e *stackError) Unwrap() error {
	return e.err
}

type stackError struct {
	err    error
	frames []*Frame
}

func (f *Frame) String() string {
	return fmt.Sprintf("%s(...)\n\t%s:%d", f.Function, f.File, f.Line)
}

// frames gets stack frames with skip.
func frames(skip int) []*Frame {
	var frames []*Frame
	var pc [64]uintptr
	skip += 2
	for {
		n := runtime.Callers(skip, pc[:])
		if n == 0 {
			break
		}
		if frames == nil {
			frames = make([]*Frame, 0, n)
		}
		ff := runtime.CallersFrames(pc[:n])
		for {
			f, more := ff.Next()
			frames = append(frames, &Frame{
				File:     f.File,
				Line:     f.Line,
				Function: f.Function,
			})
			if !more {
				break
			}
		}
		skip += n
	}

	return frames
}

func Format(err error) string {
	frames := StackTrace(err)
	if frames == nil {
		return err.Error()
	}
	var b strings.Builder
	b.WriteString(err.Error())
	b.WriteByte('\n')
	b.WriteByte('\n')
	b.WriteString(frames.String())
	return b.String()
}