🍉

GoのAPIサーバーにSentry.ioを導入してみた

2023/09/07に公開

こんにちは、テラーノベルでサーバーサイドを担当している@manikaです。

今回はログ調査の効率を上げるために Sentry というログ監視サービスを検証導入しました。導入時に調べた事や、引っかかった点等を少しまとめたいと思います。

そもそもSentryとは?ですが エラー収集・可視化・監視やパフォーマンス計測も可能なサービスでSaaSで提供されており[1]、フロントエンドからバックエンドまで対応しています。 今回はバックエンドに導入をしました。Developerプランも用意されていますので気軽に検証も出来ますね。

https://sentry.io/

エラーのグループ化

Sentryではログのスタックトレース等の情報を元に自動的にフィンガープリントが発行され、そのフィンガープリントが同じログをSentry上でグループ化してくれます。

これはとても便利な機能なのですが、同じ内容であっても発生箇所が違うためスタックトレースが異なっている等が原因でグループ化されない事があります。また、ログの内容が異なっていても関連するログはグループ化しておきたいというケースもあるかもしれません。

そういった場合は、以下の4つの方法でグループ化のロジックを指定することが可能です。

1. Sentry のIssue画面でイベントをマージする。

マージしたいイベントにチェックを入れてmergeをクリックする事で同じグループになります。
間違えてmergeしてしまったものはmergeしたイベント詳細画面の「Merged Issues]からunmerge可能です。

2. Sentry のコンソールでフィンガープリントルールを設定する。

プロジェクトの設定画面にIssue Groupingという項目がありFINGERPRINT RULESを任意で設定する事が出来ます。

ここでフィンガープリントルールを設定する事で、同じフィンガープリントになるログは同じグループとして扱うことが可能になります。

# 例)ログにつけたタグhogeの中身がfugaだったらフィンガープリントをhoge-errorにする(=グループ化される)
tags.hoge:"fuga" -> hoge-error

詳しい構文やマッチャーはこちらにあります。

3. Sentry のコンソールでスタックトレースルールを設定する。

(2)のフィンガープリントルールと似ていますが、同じくプロジェクトの設定画面の Issue Groupingに STACK TRACE RULES があり任意で設定する事が出来ます。

構文はFINGERPRINT RULESと似た感じですが、Matcherが異なります。
また FINGERPRINT RULES とは違い Action というものがあります。

Sentryがフィンガープリントを生成する際にスタックトレース内の情報を元に行われているので、スタックトレースルールではどのスタックトレースの内容を使うか・使わないかを明示的に指定する時に使用します。

例えばgroupアクションの場合はマッチしたスタックトレースに対して
 -group = フィンガープリントを生成する時に対象に含めない(除外する)
 +group = フィンガープリントを生成する時に対象に含める(追加する)
といった形になります。

グループ化のアルゴリズム内で使用されたスタックトレースは各ログ詳細画面の最下部で確認する事が出来ます。

それでは設定の方ですが、以下のスタックトレースのログがあると仮定します。

stack-trace
    frame
        module  tellernovel/api/middleware
	function  InjectContextUtils.func1.1
    frame
	module  tellernovel/api/rpc
	function  (*myServiceServer).ServeHTTP
    frame
	module  tellernovel/api/rpc
	function  (*myServiceServer).serveQuery
    frame
	module  tellernovel/api/rpc
	function  (*myServiceServer).serveQueryJSON
    frame
	module  tellernovel/api/rpc
	function  (*myServiceServer).serveQueryJSON.func1
    frame
	module  tellernovel/api/service
	function  (*apiService).Query

例) moduleが「tellernovel/api/rpc」のframeを除外したい時

stack.module:tellernovel/api/rpc -group

結果)「module tellernovel/api/rpc」のframeが除外されます。

stack-trace
    frame
        module  tellernovel/api/middleware
	function  InjectContextUtils.func1.1
    frame
	module  tellernovel/api/service
	function  (*apiService).Query

例) functionにmyServiceServerを含むframeは除外するが、serveQueryJSONのframeは含めたい時

stack.function:*myServiceServer* -group  //myServiceServerを含むトレースは全て一度削除する
stack.function:*serveQueryJSON +group //serveQueryJSONだけ追加する

結果)「functionにmyServiceServer」を含むframeのうち、serveQueryJSONのframeのみ残ります。

stack-trace
    frame
        module  tellernovel/api/middleware
	function  InjectContextUtils.func1.1
    frame
	module  tellernovel/api/rpc
	function  (*myServiceServer).serveQueryJSON
    frame
	module  tellernovel/api/service
	function  (*apiService).Query

他のアクションの詳細や使い方はこちらを参考にすると良いでしょう。

4. コード内でフィンガープリントを設定する。

これはSentryを実装するコード内で直接フィンガープリントを指定します。プログラム内に直接記述する為、柔軟に対応は出来ますがメンテは大変になるので1〜3で対応出来ない場合にのみ使用すると良いと思います。

sentry.ConfigureScope(func(scope *sentry.Scope) {
	scope.SetFingerprint([]string{"my-view-function"})
})

クォータ

Sentryに送信出来るログの件数には月毎に上限がありDeveloperプランでは50kまでしか送信が出来ません。
その為サービスによってはログの件数が多く全てのログを送信してしまうと一瞬で上限に到達してしまう事もあるでしょう。対策としては以下の2つがあります。

  • 送信するログをフィルタリングする
  • SampleRateを設定する

「送信するログをフィルタリングする」

フィルタリングをする方法はいくつかあるのですがほとんどがBussinesプラン以上でないと使用出来ない為、今回はTeam/Developerプランでも使用可能なコード内で対応を行いました。

sentry.Initのオプションで IgnoreErrors があり、正規表現でマッチしたログはドロップしてくれます。
https://docs.sentry.io/platforms/go/configuration/options/

sentry.Init(sentry.ClientOptions{
	...
	IgnoreErrors: []string{
		"not found subscription",
		"write: broken pipe",
	},
})

もしignoreのみで対応が難しい場合は BeforeSendやBeforeTransactionのコールバックを使用する方法もありますのでそちらで実装してみると良いでしょう。

SampleRateの設定

SampleRateの設定は最大が1(=100%)となり「SampleRate:0.1」で設定を行うと各ログが10%の確率で送信されるようになります。

仮に100,000件のログ送信をしているとした場合は、10%の約10,000件のログが送信されるようになりますが発生件数の少ないログは検出が出来なくなる可能性があるので注意が必要です。

SampleRateは出来るだけ高いほうが良いので、フィルタリングをしてもクォータを超えてしまいそうという時に使用すると良いでしょう。

https://docs.sentry.io/platforms/go/configuration/sampling/?original_referrer=https%3A%2F%2Fsentry.io%2F

sentry.Init(sentry.ClientOptions{
  // ...
  SampleRate: 0.25, //25%のレートでログを送信する。1で100%
})

ちなみにテラーノベルでは SampleRate: 0.001 で設定を行いました。

ログに情報を追加する

Sentryでは各ログに任意でタグを付ける事が可能で、タグをつける事で以下のようなメリットがあります。

  • ログ調査に必要な固有情報を付与できる
  • Sentry画面上でログを確認する時にそのタグを利用して検索が行える
  • 前述のFINGERPRINT RULES等で利用ができる

また、タグとは別にsentryにUser型が定義されており、ScopeのSetUser関数を使用する事でアクセスしているユーザー情報をログと共に送信する事が可能になっています。

https://docs.sentry.io/platforms/go/enriching-events/identify-user/?original_referrer=https%3A%2F%2Fdocs.sentry.io%2Fplatforms%2Fgo%2Fguides%2Fhttp%2Fenriching-events%2Ftags%2F%3Foriginal_referrer%3Dhttps%253A%252F%252Fwww.google.com%252F

# sentryに定義されているUser型
type User struct {
	ID        string            `json:"id,omitempty"`
	Email     string            `json:"email,omitempty"`
	IPAddress string            `json:"ip_address,omitempty"`
	Username  string            `json:"username,omitempty"`
	Name      string            `json:"name,omitempty"`
	Segment   string            `json:"segment,omitempty"`
	Data      map[string]string `json:"data,omitempty"`
}

タグも同様に SetTag関数が用意されており基本的にはそれらを使用すれば設定が可能です。
これらの設定は Scope を使用して設定を行います。
https://docs.sentry.io/platforms/go/enriching-events/tags/?original_referrer=https%3A%2F%2Fdocs.sentry.io%2Fplatforms%2Fgo%2Fguides%2Fhttp%2Fenriching-events%2Ftags%2F%3Foriginal_referrer%3Dhttps%253A%252F%252Fwww.google.com%252F

sentry.ConfigureScope(func(scope *sentry.Scope) {
	scope.SetTag("hoge", "fuga")
	scope.SetUser(sentry.User{
		ID: request.user.id,
		Email: request.user.email,
	})
})

注意点

net/http等を使用してWEBやAPIサーバーとして運用している場合上記では上手くいきません。
sentry.ConfigureScopeではグローバルスコープとなってしまい、各リクエスト単位のスコープではない為、例えば同時アクセスがあった場合や条件によりセットする/しないとしているタグがある場合意図しない情報が送信されてしまいます。

例えば下記の実装を行ったとします。

sentry.ConfigureScope(func(scope *sentry.Scope) {
    if isAuthenticated {
        // 認証されたユーザーの時だけタグをセットする
	scope.SetTag("plan", user.plan)
    }
})

すると、認証されたユーザーの後に認証されていないユーザーからのリクエストがあった場合、前のリクエストのSetTagの内容がそのまま保持されておりSentryにログ送信をする場合にも認証されたユーザーで設定した内容のplanがそのまま送信されてしまいます。

それを防ぐには各リクエスト毎のコンテキストのスコープで実行する必要があります。
GetHubFromContext関数でコンテキストからHubを取得出来るのでそちらを使用します。

ctx := req.Context()
if hub := sentry.GetHubFromContext(ctx); hub != nil {
    scope := hub.Scope()
    if isAuthenticated {
        // 認証されたユーザーの時だけタグをセットする
	scope.SetTag("plan", user.plan)
    }
}

// ログ送信時には同じhubを使用して送信をしなくてはならない。
if hub := sentry.GetHubFromContext(ctx); hub != nil {
    hub.CaptureException(err)
}

// 以下で送信すると上記でセットしたタグは送信されない
sentry.CurrentHub().CaptureException(err)
sentry.CaptureException(err)

https://docs.sentry.io/platforms/go/guides/http/#usage

まとめ

上記のような対応を行う事でログ数をクォータ内に収め、ログに情報も追加する事が出来ました。
これまでログを確認する時に同様の内容のログが大量に流れてしまい確認に時間を取られてしまったり、重要なエラーを見逃してしまうというリスクもあったのですがエラーもグループ化されみやすくなりました。

sentryは対応言語も多く、実装もシンプルでしたので導入はしやすかったです。また、今回は実装していませんがパフォーマンス計測等も可能なのでボトルネックの調査等にも役立つ事でしょう。
もしログやパフォーマンス周りで困っていたら試用期間もあるので検討してはいかがでしょうか。

脚注
  1. オープンソース版もあります ↩︎

テラーノベル テックブログ

Discussion