Elido
3分で読了エンジニアリング
コア記事

Sentry/GlitchTipを12のGoサービスに接続する--ホットパスを壊さずに

ElidoがどのようにしてすべてのGoサービスに同じパニック + 5xx自動キャプチャを提供する共有sentryinitパッケージを実装したか--そしてedge-redirectのP95 15msバジェットでゼロアロケーションを維持する方法。

Marius Voß
DevRel · edge infra
Diagram of 12 Go service tiles each emitting events into a central GlitchTip ingest, with the edge-redirect tile labelled zero-alloc on the happy path

Goサービスが一つのときは、エラートラッキングは30分の作業です。sentry-go を入れ、SENTRY_DSN から初期化し、重要な数カ所で sentry.CaptureException を呼び出し、リリースする。Goサービスが12個になると、その同じ30分の決断が複利で効いてくる税になります--すべてのサービスが少しずつ異なるinitコード、少しずつ異なるミドルウェア、「リリースタグ」が何を意味するかについての独自の見解を持つようになります。本番のパニックが起きる頃には、デプロイメントマニフェストの誰かがenv varを忘れたために、三つのサービスがSDKをまったく初期化していないことを発見します。

私たちはElidoでその配線作業を完了したばかりです--12のGoサービスに加え、監査チェーンのバックフィルCLI、三つのNext.jsアプリ、二つのNodeサービスも、すべてが sentry.elido.app のセルフホスト型GlitchTipにフィードしています。興味深い部分はSDKの呼び出しではありませんでした。それは、サービスごとにSDK呼び出しを1行に消し込む共有パッケージの形、そして edge-redirect のホットパスにP95 15msバジェットを燃やさずにミドルウェアを配置する必要から生まれる制約でした。

この記事は、配線がどのように機能するか、私たちが正しく行ったこと、そして意図的に行った二つの妥協について完全に説明します。

TL;DR#

  • 一つの共有パッケージ pkg/sentryinit が12個の func main のコピーを置き換えます。新しいサービスの追加は defer sentryinit.Init(logger, "service-name")() の1行にミドルウェアの1行を加えるだけです。
  • ChiMiddleware() はウォームパスサービスでパニックと非パニックの5xxレスポンスを自動キャプチャします。FastHTTPMiddleware()edge-redirect で同じことを行い、ハッピーパスではゼロアロケーション--パッケージに同梱のベンチマークで検証済み。
  • EU居住のためにSentry SaaSではなくGlitchTip(Sentry互換、セルフホスト)を選択しました。SDKはバイト単位で同一です。
  • ホットパスはハンドラーコードから sentry.CaptureException を明示的に呼び出しません。すべてのキャプチャはミドルウェア境界で行われ、そこでコストが具体化されるのは報告するものがあるときだけです。

共有パッケージ、12個のコピーではない理由#

共有の sentryinit パッケージが Init、ChiMiddleware、FastHTTPMiddleware を公開し、chi を使ったウォームパスサービスと fasthttp を使った edge-redirect サービスを main.go ごとの1行で配線する

GoでのSentryの最小限の配線は6行です:

sentry.Init(sentry.ClientOptions{
    Dsn:              os.Getenv("SENTRY_DSN"),
    Environment:      os.Getenv("ENV"),
    Release:          os.Getenv("ELIDO_VERSION"),
    ServerName:       "api-core",
    AttachStacktrace: true,
})
defer sentry.Flush(2 * time.Second)

6行、12サービス。時間とともに乖離する72行。問題は行数ではなく、ドリフトです。あるサービスが Release を忘れます。別のサービスが少し異なる名前のenv varから Environment を設定します。三番目は1秒のflushを持ち、素早いSIGTERM時にイベントを失います。フリート全体のエラートラッキングの動作は、プラットフォームの特性ではなく、そのサービスの main.go を書いたエンジニアの特性になってしまいます。

pkg/sentryinit はシンプルな修正です。Goワークスペースに存在し、すべてのサービスはローカルの replace ディレクティブを通じてそれを require し、呼び出し側は1行です:

defer sentryinit.Init(logger, "api-core")()

パッケージ自体は小さいです。ランタイムサーフェス全体は、一つのInit関数、二つのHTTPミドルウェア(chiとnet/http)、一つのfasthttpミドルウェア、そして本番で配線をエンドツーエンドで証明するためのデバッグエンドポイントです。実装の関連部分:

func Init(logger *zap.Logger, serverName string) func() {
    dsn := os.Getenv("SENTRY_DSN")
    if dsn == "" {
        return func() {}
    }
    env := os.Getenv("ENV")
    if env == "" {
        env = "production"
    }
    release := os.Getenv("ELIDO_VERSION")
    if err := sentry.Init(sentry.ClientOptions{
        Dsn:              dsn,
        Environment:      env,
        Release:          release,
        ServerName:       serverName,
        AttachStacktrace: true,
        EnableTracing:    false,
        SampleRate:       1.0,
        IgnoreErrors: []string{
            "context canceled",
            "http: Server closed",
        },
    }); err != nil {
        if logger != nil {
            logger.Warn("sentry init failed", zap.Error(err), zap.String("service", serverName))
        }
        return func() {}
    }
    sentry.ConfigureScope(func(scope *sentry.Scope) {
        scope.SetTag("service", serverName)
    })
    return func() { sentry.Flush(flushTimeout) }
}

このスニペットには、その行に値する三つのことがあります。

第一に、空DSNの早期リターン。ローカル開発にはDSNがありません。CIテストにもありません。早期リターンなしでは、すべての開発者のマシンがどこも指さないSDKを初期化しようとし、go run が起動するたびに「無効なDSN」という警告を発します。早期リターンにより、呼び出し側は分岐する必要がなくなります--defer sentryinit.Init(logger, "api-core")() はすべての環境で正しいです。

第二に、グローバルスコープに固定された service タグ。GlitchTipはすでにプロジェクト別(サービスごとに一プロジェクト)でイベントをセグメント化しますが、このタグによりDSNのプロジェクトIDを解析することなく、クロスプロジェクト検索とダッシュボードでサービスのスラッグでフィルタリングできます。同じパニックのクラスが1時間以内に三つのサービスで現れた場合、タグによってそのパターンが一つのクエリで見つかります。

第三に、IgnoreErrorscontext canceled は下流のリクエストが上流のタイムアウトによってキャンセルされると、すべてのgRPCクライアントが返すものです--バグではなく、チェーン化されたマイクロサービスグラフの通常の制御フローイベントです。http: Server closed はグレースフルシャットダウン中に標準ライブラリのHTTPサーバーが返すものです。どちらもシグナルを溺れさせるノイズを生成します。拒否リストはそれらがキューに達する前にフィルタリングします。

新しいサービスの配線は go.work に追加し、サービスの go.mod に1行のrequire + replaceを追加し、main.godefer 行を追加することです。それがコントラクトです。他のすべて--flushタイムアウト、サンプルレート、無視するエラーパターン--は集中管理されています。

chiミドルウェア#

ウォームパスサービス--api-coreanalytics-apibillingdomain-managersearchurl-scannerqr-generatormetadata-fetcherwebhook-dispatcher--での自動キャプチャサーフェスはHTTPです。ハンドラーはパニックを起こすこともあれば、パニックなしに5xxを返すこともあり、どちらも見えるようにしたいと思います。

単純なアプローチは sentry-go/http の組み込みの Handle ミドルウェアを使うことです。二つの理由から採用しませんでした。第一に、そのミドルウェアは EnableTracing が false であってもリクエストごとにトランザクションを常に開始します--無駄なアロケーションです。第二に、パニックはキャプチャしますがパニックしない5xxレスポンスはキャプチャしません。つまり、Postgresが接続を切断したために 503 を返すハンドラーは見えないままです。

リクエストがルーターとミドルウェアに入る。通常の 2xx または 3xx レスポンスではキャプチャなし。パニックまたはパニックしない 5xx の場合はリカバー、スコープ設定、GlitchTip へのイベント転送が行われる

代替品は小さいです:

func ChiMiddleware() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            hub := sentry.GetHubFromContext(r.Context())
            if hub == nil {
                hub = sentry.CurrentHub().Clone()
                r = r.WithContext(sentry.SetHubOnContext(r.Context(), hub))
            }
            hub.Scope().SetRequest(r)

            ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
            defer func() {
                if rvr := recover(); rvr != nil {
                    if rvr == http.ErrAbortHandler {
                        panic(rvr)
                    }
                    hub.RecoverWithContext(r.Context(), rvr)
                    if ww.Status() == 0 {
                        ww.WriteHeader(http.StatusInternalServerError)
                    }
                    return
                }
                if status := ww.Status(); status >= 500 && status < 600 {
                    hub.WithScope(func(scope *sentry.Scope) {
                        scope.SetLevel(sentry.LevelError)
                        scope.SetTag("status_code", strconv.Itoa(status))
                        hub.CaptureMessage(fmt.Sprintf("HTTP %d %s %s", status, r.Method, r.URL.Path))
                    })
                }
            }()

            next.ServeHTTP(ww, r)
        })
    }
}

ハブはリクエストごとにクローンされ、コンテキストに保存されます。これにより、ハンドラーが他の処理中のリクエストに漏れることなく、ドメイン固有のブレッドクラム(sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...))を添付できます。chi内部の WrapResponseWriterhttp.Flusher / http.Hijacker / http.Pusher インターフェースを保持します--下流の一部のchiミドルウェアはこれらを確認し、手書きのラッパーだと失われます。chiを使わないサービス(click-ingesteranalytics-export はプレーンな http.ServeMux をマウントしています)のために、パッケージは HTTPMiddleware() と呼ばれる標準ライブラリのみの双子バージョンを提供します。

微妙な動作の一つ:http.ErrAbortHandler はキャプチャされずに再パニックされます。それは「クライアントが切断した、ゴルーチンをクリーンに抑制する」という標準ライブラリの規則です。それを例外としてキャプチャすると、バグでないものでキューが溢れてしまいます。

配線はウォームパスサービス全体で同一です:

r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(sentryinit.ChiMiddleware())
r.Use(oteltrace.ChiMiddleware("api-core"))
// ... rest of the middleware stack

トレーシングレイヤーでのパニックもキャプチャされるよう、sentryinit.ChiMiddlewareoteltrace.ChiMiddleware の前に配置されます。

難しい部分:リダイレクトホットパスのfasthttp#

edge-redirect は別物です。そのバジェットはキャッシュヒットでP50 5ms / P95 15msで、三つの本番POP全体で計測されています。リクエストごとにアロケーションするものはGCプロフィールに表れ、最終的にP99テールに現れます。上のchiミドルウェアはアロケーションを自由に行うウォームパスサービスには問題ありませんが、エッジでは問題になります。

sentry-go/fasthttp.Handlesentry-go/http.Handle と同じ理由で選択肢になりませんでした:報告するものがない場合でも、ハッピーパスを含むすべてのリクエストで http.Request スナップショットを構築します。POPごとに毎秒何千ものリクエストを処理するサービスでは、POPごとに毎秒何千もの不要な http.Request 構造体が生成されます。

pkg/sentryinit のfasthttpミドルウェアはコストモデルを反転させます:実際にキャプチャするものがあるまでアロケーションは発生しません。

func FastHTTPMiddleware() func(fasthttp.RequestHandler) fasthttp.RequestHandler {
    return func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
        return func(ctx *fasthttp.RequestCtx) {
            defer func() {
                if rvr := recover(); rvr != nil {
                    if rvr == http.ErrAbortHandler {
                        panic(rvr)
                    }
                    hub := sentry.CurrentHub().Clone()
                    req := fasthttpRequestSnapshot(ctx)
                    hub.Scope().SetRequest(req)
                    hub.RecoverWithContext(
                        context.WithValue(context.Background(), sentry.RequestContextKey, req),
                        rvr,
                    )
                    ctx.Response.Reset()
                    ctx.SetStatusCode(fasthttp.StatusInternalServerError)
                    return
                }
                if status := ctx.Response.StatusCode(); status >= 500 && status < 600 {
                    hub := sentry.CurrentHub().Clone()
                    req := fasthttpRequestSnapshot(ctx)
                    hub.WithScope(func(scope *sentry.Scope) {
                        scope.SetRequest(req)
                        scope.SetLevel(sentry.LevelError)
                        scope.SetTag("status_code", strconv.Itoa(status))
                        hub.CaptureMessage("HTTP " + strconv.Itoa(status) + " " + string(ctx.Method()) + " " + string(ctx.Path()))
                    })
                }
            }()
            next(ctx)
        }
    }
}

形はchiバージョンと同じですが、ハブのクローンとリクエストスナップショットの構築はrecover / 5xxブランチの内側に押し込まれています。302キャッシュヒットレスポンス--圧倒的に多い一般的なケース--では、deferボディが発火し、recover() がnilを返し、ステータスチェックがfalseを返し、他には何も実行されません。クロージャ自体はGoがこの呼び出し形状でスタックフレームにインライン展開するものなので、遅延関数のコストさえも何も検出できないほど償却されます。

パッケージにはこれを確認するベンチマーク(fasthttp_test.go)があります:

func BenchmarkFastHTTPMiddleware_HappyPath(b *testing.B) {
    noop := func(ctx *fasthttp.RequestCtx) {
        ctx.SetStatusCode(fasthttp.StatusFound)
    }
    wrapped := FastHTTPMiddleware()(noop)

    ctx := &fasthttp.RequestCtx{}
    ctx.Init(&ctx.Request, &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1234}, nil)
    ctx.Request.SetRequestURI("/abc123")
    ctx.Request.Header.SetMethod("GET")
    ctx.Request.Header.SetHost("f.elido.me")

    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wrapped(ctx)
    }
}

BenchmarkFastHTTPHandler_Bare(同じハンドラー、ミドルウェアなし)との比較で、2024年M3の開発機でのデルタはノイズの中にあります--ラップされたバージョンはオペレーションごとのアロケーションがゼロと報告されます。エッジリダイレクトホットパスのSentryミドルウェアはハッピーパスでコストゼロです。パニックまたは5xxがあるときだけコストがかかり、それはまさに支払うことを厭わないときです。

edge-redirectmain.go での配線は1行です:

rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)

これが明示的にしないこと:リダイレクトハンドラー自体に sentry.CaptureException 呼び出しを散りばめることはしません。ハンドラーはレイテンシバジェットが必要とする方法のままです--Sentryの認識なし、エラートラッキング目的のリクエストごとのアロケーションなし。ミドルウェア境界が唯一のキャプチャポイントであり、ミドルウェア境界はハッピーパスで構造的にフリーです。

これは意図的な妥協です。edge-redirect がクラッシュせず5xxも返さずに誤った宛先URLを生成するロジックバグを持っている場合--例えばEUトラフィックを誤ったフォールバックにルーティングする設定ミスのルール--Sentryはそれを見ません。ボットダッシュボードとシンセティックモニタリングが見ます。トレードオフはリダイレクトを安価に保つことです。非エラーの正確性のためのオブザーバビリティはSDKの外に存在します。

GlitchTip、Sentry SaaSではない理由#

GDPRファーストの製品が顧客データを米国でホストされたエラートラッキングサービスに書き込むことは、監査人が気づく矛盾です。api-core のスタックトレースにはURLパス、場合によってはテナントID、時にはIPアドレスが含まれます(Sentryの BeforeSend フックで難読化しますが、難読化は間違いで迂回される可能性があります)。最もクリーンな経路は、独自のEUリージョン内にデータプレーンを保持することです。

GlitchTipが選択です。Sentryのワイヤプロトコルを話すため、SDKはバイト単位で同一です--フォークなし、シムなし、二番目の認証ライブラリなし。ダッシュボードはSentryの形をしており、wg-easy VPNの背後の sentry.elido.app に存在します。o<projectId>.sentry.elido.app/api/<id>/store/ のインジェストエンドポイントはnginxレイヤーのレート制限付きで、すべてのサービスからパブリックインターネット経由で到達可能です。最近の fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only コミットがその正確な分割をキャプチャしています。

Sentry SaaSからGlitchTipへの移行コストはおおよそ一つのDNS変更、プロジェクトごとに一つのDSNスワップ、そしてダッシュボードホストの背後のPostgres + Redisデプロイメントです。私たちはSaaSで動いたことはありません--初日からGlitchTipを配線しました--しかしどちらの方向にも経路は開いています。SDKはどのバックエンドと話しているかを知りません。

ロールアウト中に経験して修正した二つのGlitchTip固有の注意点があります。第一に、GlitchTipのサインアップフローは最初の管理者招待が機能するために登録が開いている必要があります。ブートストラップ中に開き、招待を送り、閉じ直しました。第二に、GlitchTipのアウトバウンドメールはResend経由でサインアップし、from-domainが検証されていないとサインアップ時のメール確認が成功しません--ResendドメインがgreenになるまでEmailの確認をスキップし、その後有効化します。どちらもこれを繰り返す人のためのランブックに記録されています。

デバッグパニックエンドポイント#

再デプロイなしに本番で配線をエンドツーエンドでテストすることは、静かに決してやられない種類のことです--実際のパニックが起きるまで、そして三週間前から配線が壊れていたことを発見するまで。まさにそのために恒久的な診断サーフェスを追加しました。

func DebugPanicHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        expected := os.Getenv(debugTokenEnv)
        if expected == "" || r.URL.Query().Get("token") != expected {
            http.NotFound(w, r)
            return
        }
        panic("elido sentry-debug panic: " + r.RemoteAddr + " " + r.URL.RawQuery)
    }
}

GET /debug/sentry-panic にマウントされ、ELIDO_SENTRY_DEBUG_TOKEN でゲートされます。env varが設定されていない場合、ルートは404を返します--本番環境に安全にリリースできます。varが設定されており、リクエストに ?token=<value> が含まれる場合、ハンドラーは意図的にパニックを起こします。ミドルウェアがそれをキャッチし、SDKがGlitchTipに転送し、イベントが正しいプロジェクトに着地します。再デプロイなしに1分以内にラウンドトリップ全体を確認できます。

エッジ用のfasthttpの双子があります:

func DebugPanicFastHTTPHandler() fasthttp.RequestHandler {
    return func(ctx *fasthttp.RequestCtx) {
        expected := os.Getenv(debugTokenEnv)
        if expected == "" || string(ctx.QueryArgs().Peek("token")) != expected {
            ctx.SetStatusCode(fasthttp.StatusNotFound)
            return
        }
        panic("elido sentry-debug panic: " + ctx.RemoteAddr().String() + " " + string(ctx.QueryArgs().QueryString()))
    }
}

同じトークンゲート、未設定時の隠蔽動作も同じ。デプロイ後に最初にやることは、オンコールが影響を受けたサービスのデバッグエンドポイントをヒットすることです。イベントが10秒以内にGlitchTipに着地すれば、配線は正常です。着地しなければ、次の障害が壊れた配線を困難な方法で発見する前にデプロイがロールバックされます。

配線しなかったこと#

明らかな追加のように見えるが、意図的にスコープ外に置いた三つのことがあります。

トレーシング。 InitEnableTracing: false。分散トレーシングにはOpenTelemetryを使用しています(pkg/oteltrace パッケージが同じサービス全体に配線しています)。Sentryが並行してトレーシングを行うと、リクエストごとのトランザクションアロケーションが二倍になり、コールグラフを通じたコンテキスト伝播のコストも二倍になります。Sentryの強みはエラーです。OTelの強みはスパンです。それぞれが得意なことに使います。

リダイレクトパスでの手動 CaptureException 上で説明した通り。ホットパスはハンドラーから呼び出すためにsentryinitをインポートしません。ミドルウェアが唯一のキャプチャ境界です。

パフォーマンスモニタリング(トランザクション)。 トレーシングと同じ理由です。redirect_duration_secondsregioncache_tier ラベルを持つPrometheusヒストグラムです。これがレイテンシのソースオブトゥルースです。同じデータをSentryのパフォーマンスモニタリングに通すことは、集計が劣る重複パイプラインになります。

外から見るとどう見えるか#

edge-redirect を含む 12 の Go サービスが sentry.elido.app のセルフホスト GlitchTip に一本化してフォワード。各イベントにはサービスタグ、環境、リリースが付きクロスプロジェクト検索が可能

12サービス、一つの共有パッケージ、main.goごとに1行、ルーターごとにミドルウェアの1行。パニックが起きると--起きます--GlitchTipの正しいプロジェクトに、正しい service タグ、正しい Environment、正しい Release、そして行を見つけるのに十分な深さのスタックトレースと共に表示されます。パニックしない5xxが漏れ出たとき--これも起きます、通常はデータベースのブリップの後--同じように表示されます。

妥協点は明示的であり、パッケージのパッケージレベルのdocコメントに書き留められており、ベンチマークでテストされています。配線はランブックと同じ場所に記録されており、部族的知識の中ではありません。13番目のサービスを追加するのに15分かかるでしょう--そのうち5分がテストを書くこと、5分がDSNをデプロイメントマニフェストに配線すること、5分が make build を実行してデバッグエンドポイントで証明することです。

それが持続する形です。サービスごとに6行では常にドリフトします。1行、共有パッケージ、ベンチマークではドリフトしません。


GoのフリートでSentryまたはGlitchTipを使用していてコピーする形を探している人のために、配線はモノレポの pkg/sentryinit/でオープンになっています。付属のランブックはDSNのローテーション手順、GlitchTipのブートストラップの注意点、ロールバック経路をカバーしています。Elidoスタック全体をセルフホストしているチームには、k3sプレイブックがSDKがより広いKubernetesデプロイメントにどのように収まるかをカバーしています。「ハッピーパスでゼロアロケーション」が負荷の下で実際に何を意味するかの深掘りについては、リダイレクトP95の記事がコンパニオン記事です。


Marius VoßはElidoのDevRelとエッジインフラ担当です。上記で説明したロールアウトと並行して sentryinit パッケージを実装し、先週はGlitchTipダッシュボードが以前は見えていなかったイベントで満たされていくのを見ていました。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
sentry go middleware
glitchtip self-hosted
observability url shortener
fasthttp panic recovery
chi middleware
go error tracking

続きを読む