every Tech Blog

株式会社エブリーのTech Blogです。

プッシュ通知基盤におけるアラートの方針について

プッシュ通知基盤におけるアラートの方針について

はじめに

エブリーでヘルシカのサーバーサイドの開発をしている赤川です。

ヘルシカでは、APIサーバーの監視をSentryを用いて行っており、開発中のプッシュ通知基盤にもSentryを導入することになりました。通知基盤が一度に処理するデータ量は、ユーザーの数に対して線形に多くなっていきます。そのため、どのように監視を行うかは重要な検討事項となります。

本記事では、今回実装したプッシュ通知基盤のアラート設計における自分の考えについて述べようと思います。

背景

ヘルシカのプッシュ通知基盤は、AWS LambdaがFirebase Cloud Messagingにメッセージを送る構成になっており、まとまったメッセージの集合をfor文で処理しています。通常の実装では、エラーが発生した時点で即座にループを停止することもしばしばですが、プッシュ通知ではできるだけ多くのユーザーに通知を届けることが重要です。そのため、一部のメッセージでエラーが発生しても、すべてのメッセージに対して処理を継続する設計を採用しています。しかしこの設計で単純にエラーごとにアラートを出してしまうと、一度に出るアラートの数が膨大になる危険があります。

アラート設計のアプローチ

これらの課題を解決するため、以下の2つの方針でアラート設計を行いました。これらはどちらか1つというものではなく、相互に関連し合うものだと考えています。

適切なエラーのグルーピング: エラーの種類ごとに適切に分類し、同じ種類のエラーを一つのIssueにまとめることで、問題の全体像を把握しやすくする

アラート数の最小化: エラーが大量に発生しても、アラートの数は最小限に抑える

適切なエラーのグルーピング

SentryのIssueグルーピングの仕組み

SentryはIssueという単位でエラーをグルーピングします。Issueを識別するのがfingerprintというキーで、通常はエラーメッセージやスタックトレースから自動生成されます。

大規模なプロジェクトでは、エラーの種類が多様でfingerprintを自前で管理することは現実的ではありません。対して今回のLambdaの実装は小規模で、エラーの種類も限定的です。そのため、fingerprintを明示的に管理することで、安定したグルーピングを実現しました。

// fingerprintの設定
type ErrorKey string

// 例
const (
    ErrorKeyFCMSend         ErrorKey = "fcm_send_failed"
    ErrorKeyDDBSaveRequest  ErrorKey = "ddb_save_request_failed"
)

sentry-goパッケージでは、sentry.WithScope内でSetFingerprintを使うことでfingerprintを設定できます。

import "github.com/getsentry/sentry-go"

sentry.WithScope(func(scope *sentry.Scope) {
    // fingerprintの設定
    // fingerprintの型は`[]string`で、複数設定も可能
    scope.SetFingerprint(fingerprint)
    // Sentryにエラーを送信
    sentry.CaptureException(err)
})

エラーメッセージの改善

その他、外部SDKを多用しているため、エラーメッセージだけでは何が起きたのか分からない場合があります。そのため、エラーメッセージにErrorKeyをプレフィックスとして付与し、エラーの種類を明確にしました。

アラート数の最小化

前述のとおり、エラーのたびにアラートを出していてはキリがありません。そこで、forループ内ではエラーを記録するだけでアラートは送信せず、処理完了後に1回だけ集約したエラーをSentryに送信する方式を採用しました。

初期案: errors.Join

最初に考えたのはerrors.Joinを使用する方法でした。

var aggErr error = nil
for _, message := range messages {
    err := message.Send()
    aggErr = errors.Join(aggErr, err)
}
return aggErr

しかし、この方法には問題があります。例えば、10件のメッセージが2つのバッチで処理され、それぞれ3件と2件が失敗した場合は以下のようなエラーになります。

// バッチ1(3件失敗)
failed to send message
failed to send message
failed to send message

// バッチ2(2件失敗)
failed to send message
failed to send message

fingerprintでグルーピングはできますが、エラーメッセージが冗長で、影響度の把握が困難です。

改善案: エラー集約構造体

そこで今回は、エラー集約専用の構造体を設計しました。

type ErrorSample struct {
    RequestID        string
    NotificationType string
    Error            error
}

type ErrorAggregate struct {
    Count     int
    Sample    ErrorSample
}

type ErrorAggregates map[ErrorKey]*ErrorAggregate

この構造体により、エラーの発生回数や詳細をを効率的に管理できます。既にグルーピングは実現できているため、サンプルはひとまず1件で十分だという考えのもと構造体を定義しています。

メソッドの実装

構造体には以下の3つのメソッドを実装しました。

  • AddSample: サンプルが未設定の場合のみ、エラーサンプルを保存
  • Increment: エラーカウントを1増加
  • Emit: 集約結果をエラー種別ごとに1イベントだけSentryに送信

Emitは大体以下のように実装しています。SentryのTagを活用することで、影響度や通知種別などを追えるようにしました。

func (m ErrorAggregates) Emit() {
    for errKey, agg := range m {
        tags := map[string]string{
            "error_key":                string(errKey),
            "count":                    strconv.Itoa(agg.Count),
            "sample_request_id":        agg.Sample.RequestID,
            "sample_notification_type": agg.Sample.NotificationType,
        }
        CaptureErrorWithContext(
            agg.Sample.Error,
            tags,
            []string{"push_to_fcm", string(errKey)},
        )
    }
}

まとめ

今回のアラート設計により、以下を実現しました。

  • アラート数の大幅削減: エラーが大量発生しても、アラート数は最小限に抑制
  • 問題の迅速な特定: エラーの種類と影響度を一目で把握可能

システム監視は広大な分野であり、自分もまだまだ知らないことが多くあります。今後もより良いアラート設計を追求していけたらと思います。

最後までお読みいただきありがとうございました。

参考文献