👨‍🔧

[Go]Sentryに対応したcustom errorの作り方

2020/10/22に公開

はじめに

go v1.x の 標準erorrはシンプルなゆえに欲しい機能が足りていない事が多く
標準errorをより使いやすくしたpkg/errors等が存在しますが、それでもerror自体に特定のステータス(status codeやerror levelなど)を保持したい場合等はそれ専用のcustom errorを作る事になると思います。

それ自体は良いのですが、errorが発生した際にそのerrorをSentryに通知したい場合
sentry-goCaptureException()ではStacktraceの取得に以下のpackageを使用する事が前提になっています。

今回はcustom errorを使用してSentryへStacktraceを表示させるための実装を試しました。

sentry-goのソースを読む

sentry-goには大きく以下の3つのCapture方法があり

// CaptureMessage captures an arbitrary message.
func (client *Client) CaptureMessage(message string, hint *EventHint, scope EventModifier) *EventID {
	event := client.eventFromMessage(message, LevelInfo)
	return client.CaptureEvent(event, hint, scope)
}
// CaptureException captures an error.
func (client *Client) CaptureException(exception error, hint *EventHint, scope EventModifier) *EventID {
	event := client.eventFromException(exception, LevelError)
	return client.CaptureEvent(event, hint, scope)
}
// CaptureEvent captures an event on the currently active client if any.
//
// The event must already be assembled. Typically code would instead use
// the utility methods like CaptureException. The return value is the
// event ID. In case Sentry is disabled or event was dropped, the return value will be nil.
func (client *Client) CaptureEvent(event *Event, hint *EventHint, scope EventModifier) *EventID {
	return client.processEvent(event, hint, scope)
}

基本的にはCaptureExceptionCaptureMessageを使用すると思いますが
ソースコードを読んで分かる通りCaptureException CaptureMessageではEventの作成のみオリジナルの処理で最終的には全てCaptureEventが呼ばれています。

今回Stacktraceをcaptureする処理として重要なのは
CaptureException内でEventのStacktraceを取得しているExtractStacktraceです。

func ExtractStacktrace(err error) *Stacktrace {
        // reflectionでStacktraceを取得するmethodを取得
	method := extractReflectedStacktraceMethod(err)

	var pcs []uintptr

	if method.IsValid() {
                // reflectionでStacktraceを取得
		pcs = extractPcs(method)
	} else {
		pcs = extractXErrorsPC(err)
	}
        // 以下省略
}

func extractReflectedStacktraceMethod(err error) reflect.Value {
	var method reflect.Value

	// https://github.com/pingcap/errors
	methodGetStackTracer := reflect.ValueOf(err).MethodByName("GetStackTracer")
	// https://github.com/pkg/errors
	methodStackTrace := reflect.ValueOf(err).MethodByName("StackTrace")
	// https://github.com/go-errors/errors
	methodStackFrames := reflect.ValueOf(err).MethodByName("StackFrames")

	if methodGetStackTracer.IsValid() {
		stacktracer := methodGetStackTracer.Call(make([]reflect.Value, 0))[0]
		stacktracerStackTrace := reflect.ValueOf(stacktracer).MethodByName("StackTrace")

		if stacktracerStackTrace.IsValid() {
			method = stacktracerStackTrace
		}
	}

	if methodStackTrace.IsValid() {
		method = methodStackTrace
	}

	if methodStackFrames.IsValid() {
		method = methodStackFrames
	}

	return method
}

見てわかる通り、reflectionで各error packageのStacktrace実装から決め打ちでStacktraceを取得しています。
要するにcustom errorで各packagesのStacktraceの実装と同じInterfaceを実装すればSentryでStacktraceを取得できるはずです。

custom errorをSentryに対応させる

元々作成していたcustom errorはpkg/errorsをベースに拡張されたものでしたのでpkg/errorsのStacktraceのInterfaceを実装していきます。

custom errorにpkg/errorsのStacktrace methodを実装する

Sentryがreflectionで呼んでいるcustom errorに実装すべきmethodはこちら

// Frame represents a program counter inside a stack frame.
// For historical reasons if Frame is interpreted as a uintptr
// its value represents the program counter + 1.
type Frame uintptr
// StackTrace is stack of Frames from innermost (newest) to outermost (oldest).
type StackTrace []Frame
// stack represents a stack of program counters.
type stack []uintptr

func (s *stack) StackTrace() StackTrace {
	f := make([]Frame, len(*s))
	for i := 0; i < len(f); i++ {
		f[i] = Frame((*s)[i])
	}
	return f
}

Frameはスタックトレースの各フレーム情報を指します。StackTraceはその集合体です。
上記を実装しただけではcustom errorのstackには何も情報を持たないため
errorが発生した際にgolangのruntime情報からFrameを作成する必要があります。

pkg/errorsの関数をそのまま使えればよかったのですがpkg/errorsでStacktraceを取得するcallersはprivateな関数であるためcustom errorにも同様の処理をそのまま実装する必要があります。
error作成時にStacktraceを取得する実装は以下の通り。

func callers() *stack {
	const depth = 32
	const skip = 4
	var pcs [depth]uintptr
	n := runtime.Callers(skip, pcs[:])
	var st stack = pcs[0:n]
	return &st
}

depthは取得するStacktraceの深さ、runtime.Callers() のパラメータの「4」はStacktraceにerror package内の情報までstackしないようにskipするstack数を示しています。
このskip数はerror packagesの実装により異なるのでcallers()を呼ぶまでのネストの数を調べましょう。

ちなみにGo1.7以上である場合Stacktrace(runtime.Frames)を取得するruntime.CallersFrames()関数が追加されている為そちらを利用する事も可能です。
https://golang.org/pkg/runtime/#example_Frames

Stacktrace実装例として
errorにgprc.statusを持たせたサンプルは以下の通り。

error.go
package customerror

import (
	"github.com/pkg/errors"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type CustomError interface {
	Error() string
	Status() *status.Status
}

type customError struct {
	status  *status.Status
	*stack // ここでStacktraceのmethodを実装しているのがポイント
}

func NewCustomError(code codes.Code, message string, args ...interface{}) error {
	return newCustomError(nil, code, message, args...))
}

func newCustomError(code codes.Code, message string, args ...interface{}) error {
	s := status.Newf(code, message, args...)
	return &customError{s, callers()}
}

func (e *customError) Error() string {
	return fmt.Sprintf("[%s] %v", e.status.Code(), e.status.Message())
}

func (e *customError) Status() *status.Status {
	return e.status
}
stack.go
package customerror

import (
	"fmt"
	"github.com/pkg/errors"
	"runtime"
)

type stack []uintptr

func (s *stack) StackTrace() errors.StackTrace {
	f := make([]errors.Frame, len(*s))
	for i := 0; i < len(f); i++ {
		f[i] = errors.Frame((*s)[i])
	}
	return f
}

func callers() *stack {
	const depth = 32
	const skip = 4
	var pcs [depth]uintptr
	n := runtime.Callers(skip, pcs[:])
	var st stack = pcs[0:n]
	return &st
}

custom error以外のorigin errorのStacktraceを取得する

そのアプリ内でcustom errorしか使わない場合は上記の実装だけで問題ないですが
実際のアプリでは別のサブシステムやlibrary内で起きたoriginのerrorを保持する必要が出て来ると思います。
その場合、custom errorのstackはoriginのerrorのStacktraceを引き継がないといけません。

今回の場合サブシステム内で使用されているerror packageはpkg/errorsであるとしましょう。
pkg/errorsのStacktraceを取得する方法はpkg/errorsのソースコードを眺めているとコメントに細かく記載されています。
https://github.com/pkg/errors/blob/v0.9.1/errors.go#L66

// Retrieving the stack trace of an error or wrapper
//
// New, Errorf, Wrap, and Wrapf record a stack trace at the point they are
// invoked. This information can be retrieved with the following interface:
//
//     type stackTracer interface {
//             StackTrace() errors.StackTrace
//     }
//
// The returned errors.StackTrace type is defined as
//
//     type StackTrace []Frame
//
// The Frame type represents a call site in the stack trace. Frame supports
// the fmt.Formatter interface that can be used for printing information about
// the stack trace of this error. For example:
//
//     if err, ok := err.(stackTracer); ok {
//             for _, f := range err.StackTrace() {
//                     fmt.Printf("%+s:%d\n", f, f)
//             }
//     }
//
// Although the stackTracer interface is not exported by this package, it is
// considered a part of its stable public interface.

上記を参考にしつつorigin errorであるpkg/errorsのStacktraceを取得してstackに詰めなおすには以下のように実装します。

error.go
package customerror

import (
	"github.com/pkg/errors"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type CustomError interface {
	Error() string
	Status() *status.Status
	Origin() error
}

type customError struct {
	status  *status.Status
	origin  error // origin errorを格納する
	*stack
}

func NewCustomError(code codes.Code, message string, args ...interface{}) error {
	return newCustomError(nil, code, message, args...))
}

func NewCustomErrorFrom(origin error, code codes.Code, message string, args ...interface{}) error {
	return newCustomError(origin, code, message, args...))
}

func newCustomError(origin error, code codes.Code, message string, args ...interface{}) error {
	s := status.Newf(code, message, args...)
	if origin != nil {
		// https://github.com/pkg/errors
		type stackTracer interface {
			StackTrace() errors.StackTrace
		}
		if e, ok := origin.(stackTracer); ok {
			originStack := make([]uintptr, len(e.StackTrace()))
			for _, f := range e.StackTrace() {
				originStack = append(originStack, uintptr(f))
			}
			var stack stack = originStack
			return &applicationError{s, origin, &stack}
		}
	}
	return &CustomError{s, origin, callers()}
}


func (e *customError) Error() string {
	return fmt.Sprintf("[%s] %v", e.status.Code(), e.status.Message())
}

func (e *customError) Status() *status.Status {
	return e.status
}

func (e *customError) Origin() error {
	return e.origin
}

origin errorがpkg/errorsだった場合はpkg/errorsのStackTrace実装を呼び出してFrameを取得してからその値をプログラムカウンタの値に一度変換してstackに格納します。
勿論サブシステムがpkg/errors以外のerror packageを使用している場合そのpackage毎にStacktraceの実装は異なるので別途対応が必要です。

おまけ:custom errorをSentryで使用する

Sentryでstacktraceを出力可能なcustom errorの実装が本記事のメインですので
Sentryでcaptureする部分の実装はおまけですが、実際には以下のように使用します。

func main() {
	if err := Hoge(); err != nil {
		captureSentryExceptions(err)
	}
}

func captureSentryExceptions(err error) {
	if customErr, ok := errors.Cause(err).(customerror.CustomError); ok {
		sentry.CaptureException(customErr)
	} else {
		sentry.CaptureException(err)
	}
}

ただし上記の実装では仮にorigin errorがあった場合でもSentryのタイトルにはcustomErrorのtypeが表示されてしまうため、origin errorがある場合にそちらのtypeをタイトルに設定したい場合はsentry.CaptureEvent()を使うと良いでしょう。

func main() {
	if err := Hoge(); err != nil {
		captureSentryExceptions(err)
	}
}

func captureSentryExceptions(err error) {
	if customErr, ok := errors.Cause(err).(customerror.CustomError); ok {
		var errorType string
		if originErr := customErr.Origin(); oe != nil {
			// if has origin error, use origin error value and type
			errorType = reflect.TypeOf(originErr).String()
		} else {
			errorType = reflect.TypeOf(customErr).String()
		}
		event := sentry.NewEvent()
		event.Level = sentry.LevelError
		event.Exception = []sentry.Exception{{
			Value:      customErr.Error(),
			Type:       errorType,
			Stacktrace: sentry.ExtractStacktrace(customErr),
		}}
		sentry.CaptureEvent(event)
	} else {
		sentry.CaptureException(err)
	}
}

おわりに

特定のlibraryを拡張してcustom errorを実装すること自体は割と簡単に行えますが
Sentry等サードパーティ製のlibraryを使用してそのcustom errorを使用する際は多くのerror libraryのお作法に沿って作成してあげないと正常に動作しない事があります。
特にcustom errorを実装する際にはStacktraceをきちんと実装する事を忘れないように気をつけましょう。

Discussion