バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

SentryでGoのcustom errorをstack trace付きで表示する

はじめに

こちらは バイセルテクノロジーズ Advent Calendar 2021の21日目の記事です。

前日の記事は @Tamarihirotoさんの「内定者インターンが行った社内サービスのデザイン刷新」でした。

こんにちは、21年新卒で入社した開発2部の飯島です!

今は主にリユースプラットフォーム内の商品マスタサービスを開発しているのですが、前回の「新規プロジェクトにインフラ監視ツールを導入した話」に引き続き、そのサービスに導入したエラー監視ツール(Sentry)の話をしていければと思います。

前提

Sentryとは

オープンソースのアプリケーションエラーの監視ツール*1で、開発するアプリにパッケージを組み込んでSentryにエラーを送信するコードを書くことによって、Sentryのダッシュボード上でエラーがいつ起こったか、どんな状況だったかなどの詳細な情報をリアルタイムで追うことができます。

Webアプリやモバイルアプリの言語を広くカバーしており、今回フォーカスするGoもカバーされております。

Sentryに送信されたエラーはダッシュボードに表示されるほか、SlackやEmailなどに通知することもできます。

このように表示される*2

custom errorを導入することになった経緯

まず、開発中の商品マスタサービスではバックエンドにGraphQL(Hasura)を使用しており、ロジック拡張をGoのgqlgen*3で行っている*4のですが、gqlgenで実装している箇所は、バリデーション処理や外部サービスへのリクエストなどを行っており、Sentryを使って監視したいというのがチームでの意向でした。

そしてSentryを使って監視するにあたって以下を要件として定めました。

  • エラーの発生元を検知するために、Sentryへのエラー送信時にstack trace*5を含めたい
  • エラー送信ロジックは各所に書くとメンテナンスが困難なため、共通化したメソッドに集約したい

すると、gqlgenのエラーパッケージとして一般的に用いられ、チームでも使っていたgqlerror*6を利用した場合に、エラーの発生元までのstack traceの取得ができないことがわかり、要件を満たせないことが問題として浮上しました。

そこで、今回はgqlerrorにstack trace用のメソッドを加えたcustom errorを自作し、問題に対処することを試みました。

デフォルトのgqlerrorだとSentryにエラーを送信する共通メソッドまでしかたどれず、どこで発生したエラーなのかがわからない

gqlerrorを使うことをやめて、代わりにstack trace用のメソッドが組み込まれているerror packageを使うのも考えました。しかし既存影響を考慮し、gqlerrorベースのcustom errorを自作してstack traceを実現することにしました。

実装

sentry-goの仕様

Sentryを導入する際は、sentry-go*7というpackageをGoのアプリケーションにインストールします。

インストールしたら、以下のようにimportし、DSN*8を渡して初期化するだけでSentryにエラーを送信することができます。また、DSNを渡さなくても環境変数でSENTRY_DSNというのを設定しておけば自動で読み取ってくれます。

package main

import (
    "log"
    "time"
    "github.com/getsentry/sentry-go"
)

func main() {
    err := sentry.Init(sentry.ClientOptions{
        Dsn: "sentryのdashboardの設定から確認できます",
    })
    if err != nil {
        log.Fatalf("sentry.Init: %s", err)
    }
    defer sentry.Flush(2 * time.Second)
        
    sentry.CaptureMessage("It works!")
}

docs.sentry.io

custom errorを自作

custom errorを自作していきます。

まずsentry-goのソースコードを見てみると、stacktraceの取得に関して一部のerror packageのstack trace関連のメソッドを決め打ちで実行しているようでした。

// 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")

github.com

上記のerror packageしかstack traceには対応していないということなので、今回はその中で一番githubのstar数が多く比較的メンテナンスがされている github.com/pkg/errors を使用します。

決め打ちで叩かれているStackTraceメソッドをpackageからimportしてくればよいのではと想定しつつ調査したところ、exportされていないメソッドだということがわかりました*9

なので、github.com/pkg/errorsをimportしつつ、StackTraceメソッドとその内部で呼ばれているメソッドを自前で実装することでstack traceを実現します。

package customError

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

// 元の実装箇所: https://github.com/pkg/errors/blob/614d223910a179a466c1767a985424175c39b465/stack.go#L140-L169

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 {
    // 取得するstacktraceの深さ
    // デフォルトの32で設定
    const depth = 32

    // スキップするソースコードの数
    // 2以下にするとcustomErrorのpkgまで見に行ってしまうので3で設定
    const skip = 3

    var pcs [depth]uintptr
    n := runtime.Callers(skip, pcs[:])
    var st Stack = pcs[0:n]
    return &st
}

自前で実装すると、stack traceの深さやスキップするソースコードの数など細かく設定を決められるのでメリットかもしれません。

このstack traceをさらに以下のようにcustom errorに組み込みます。

package customError

import (
    "fmt"
)

type CustomErrorService interface {
    Error() string
}

// github.com/vektah/gqlparser/v2/gqlerror をimportしてgqlerror.Errorを使おうとすると
// Error()のメソッド名と名前が競合してエラーが起こっていたので自前でgqlerror.Errorから必要な項目を抜き出して定義
type GqlError struct {
    Message    string
    Extensions map[string]interface{}
}

type Error struct {
    GqlError
    *Stack
}

func NewCustomErrorService() *Error {
    return &Error{}
}

func New(message string, extensions map[string]interface{}) *Error {
    return &Error{
        NewGqlError(message, extensions),
        Callers(),
    }
}

func NewGqlError(message string, extensions map[string]interface{}) GqlError {
    return GqlError{
        Message:    message,
        Extensions: extensions,
    }
}

// custom error化するために、errorのinterfaceであるError()を組み込んでいる
func (e *Error) Error() string {
    return fmt.Sprint(e.Message)
}

Goでcustom errorを作成する際はstringを返すErrorメソッドを組み込めばいいので楽ですね。なお、gqlerrorと合わせてcustom errorを作る場合は、typeのgqlerror.Errorが名前被りで競合してしまい後者を自前で定義する必要があったので注意です。

また、Sentryに送るエラーと送らないエラーを分けたかったので、前者の場合はgqlerror + stack traceのcustom error、後者の場合はデフォルトのgqlerrorを使うようにし、server.goで、gqlerrorの加工ができるSetErrorPresenter*10を用いて以下のような分岐処理を実装してます。

srv := handler.NewDefaultServer(generated.NewExecutableSchema(
        generated.Config{Resolvers: graph.NewResolver(
            hogehoge,
        )},
    ))

srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
        var convertedErr *customError.Error

        // error.Asでエラーチェーンに*customError.Errorがないか探し、あった場合はエラーの値をそれに書き換えている
        // See: https://pkg.go.dev/errors#As
        if errors.As(err, &convertedErr) {
            // *customError.Errorがある時だけ別ファイルで切り出したsentryのサービスに値を渡す
            sentry.NewSentryService().CaptureException(ctx, err)

            return &gqlerror.Error{
                Message:    convertedErr.GqlError.Message,
                Extensions: convertedErr.GqlError.Extensions,
            }
        }

        // ^に該当しない場合はerrを*gqlerror.Errorにtype assertionして返している
        return err.(*gqlerror.Error)
    })

SetErrorPresenterからgqlerrorを返す時にはstack traceを除いたデフォルトのgqlerrorを返すようにしたかったので、最後にtype assertionしております。

実際にエラーを送信してみる

stack traceを実装したcustom errorをSentryに渡してみると、dashboard上で意図した通りにエラー発生箇所が表示されております。

stack trace付きでエラーを送信しても送信用のコードが叩かれた箇所に関するトレースは残り、そのトレースと実装したstack traceの2つがdashboardに表示される形になるので注意です。

上がSentryへの送信コードが叩かれた箇所のトレース、下が追加実装したstack trace

最後に

いかがでしたか?

SentryをGoで書かれたアプリケーションに導入する際の参考に少しでもなれば幸いです。

バイセルではエンジニアを募集しています。

herp.careers

明日の バイセルテクノロジーズ Advent Calendar 2021@bst-imaiさんが新規プロジェクトのバックエンド周りに関して書いてくださいます。

ご覧いただき、ありがとうございました。

*1:https://sentry.io/welcome/

*2:https://docs.sentry.io/product/issues/issue-details/

*3:https://github.com/99designs/gqlgen

*4:詳細知りたい方は同期の小松山くんの記事を見ていただければと思います。https://tech.buysell-technologies.com/entry/adventcalendar2021-12-09

*5:エラーが発生した際の関数やメソッドなどの履歴

*6:https://pkg.go.dev/github.com/vektah/gqlparser/gqlerror

*7:https://pkg.go.dev/github.com/getsentry/sentry-go

*8:Data Source NameのことでSentryによってプロジェクトに割り当てられる一意のClient key。https://docs.sentry.io/product/sentry-basics/dsn-explainer/

*9:stack traceはtypeしかexportされていない。 https://pkg.go.dev/github.com/pkg/errors#StackTrace

*10:https://gqlgen.com/reference/errors/#the-error-presenter