kubell Creator's Note

株式会社kubellのエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

GraphQL の可観測性を上げる!Datadog の OSS にコントリビュートした話

はじめに

こんにちは、kubell の tomoikey です。

今回 Datadog の Go トレーシングライブラリ dd-trace-go (GitHub Star 800+) に機能追加の PR を送り、無事マージされました🎉

https://github.com/DataDog/dd-trace-go/pull/3711

この記事では GraphQL 環境 (gqlgen)で発生する Span 爆発問題と、それを解決するために追加した WithShouldStartSpanFunc オプションについて解説します。

発見した問題

GraphQL (gqlgen) × Datadog の Span 爆発

dd-trace-go と gqlgen を組み合わせて GraphQL サーバーを運用していると、フィールドごとに Span が生成されます。GraphQL はその性質上、1つのリクエストで大量のフィールドを解決するため、トレースの Span 数が膨れ上がってしまいます。

特に配列系のデータで大量のレコードが返ってくるケースでは顕著です。

query {
  users(first: 100) {  # 100件のユーザーを取得
    id
    name
    email
    posts {            # 各ユーザーが複数の投稿を持つ
      id
      title
      content
    }
  }
}

このようなクエリでは、users の各要素、さらにネストした posts の各要素に対して Span が生成されるため、1リクエストで 3000 Spans とかめちゃめちゃ普通に発生していました。

Span が多すぎるとどうなる... ?

Span が多すぎると何が起きるかというと、まず Datadog APM の UI がモサモサします。トレースを開こうとしてもなかなか表示されない。

ひどいときはエラーが発生して開けないこともありました。

仮に開けたとしても、Span の粒度が細かすぎて何が起きているのか把握しづらいです。

ボトルネックを探したいのにノイズが多すぎて本質が見えないという本末転倒な状態でした。

また Lambda extension で Datadog Agent を動かしている環境だと、大量の Span データがメモリを圧迫してレイテンシに影響を与えることもあります。

既存オプションも色々あったが...

dd-trace-go の gqlgen 向けトレーサーには、既にいくつかの Span 抑制オプションがあります。

// IntrospectionQuery の Span を抑制
gqlgentrace.WithoutTraceIntrospectionQuery()

// 関数リゾルバ以外の Span を抑制
gqlgentrace.WithoutTraceTrivialResolvedFields()

これらは便利なんですが、ユースケースが限定的です。

「この path のフィールドだけ抑制したい」とか「特定の条件を満たすフィールドだけトレースしたい」といった柔軟な制御ができませんでした...

解決策

WithShouldStartSpanFunc の追加

そこで WithShouldStartSpanFunc という新しい設定オプションを追加しました!!!

GraphQL の各フィールド解決時に「このフィールドの Span を生成するかどうか」をユーザーが自由に制御できる機能です。

func WithShouldStartSpanFunc(fn func(_ context.Context, _ *graphql.FieldContext) bool) OptionFn

graphql.FieldContext という GraphQL フィールドに関する詳細な情報を受け取るので、フィールドの path・親の情報・引数などなど判定に必要な情報は大体取れます。

戻り値が true なら Span を生成し、false なら抑制するという簡潔な実装を組みました。

これによって GraphQL フィールドというコンテキストを元に複雑な Span 生成ロジックを簡潔に記述することができます。

もちろん問題として挙げていた Span 数が爆発する問題も、乱数などを用いたサンプリングレート機構を WithShouldStartSpanFunc 内部に記述するだけで解決します!!

続いてトレーシングの設定を管理する構造体に shouldStartSpanFunc フィールドを追加し、トレーサーがフィールドを処理する際にこの関数を呼び出して Span 生成の可否を判断するようにしました。

type config struct {
    analyticsRate                  float64
    withoutTraceIntrospectionQuery bool
    withoutTraceTrivialResolvedFields bool

    // New!!!
    shouldStartSpanFunc            func(ctx context.Context, fieldCtx *graphql.FieldContext) bool

    tags                           map[string]interface{}
    errExtensions                  []string
}

nil を渡した場合はデフォルトの挙動 (常に true を返す) になるようにしています。既存コードでは nil が渡されるので後方互換性が保たれます。

OSS では後方互換性を破壊するような、いわゆる Breaking Changes は嫌われます。

特別な理由がない限り却下されます。

func WithShouldStartSpanFunc(fn func(_ context.Context, _ *graphql.FieldContext) bool) OptionFn {
    return func(cfg *config) {
        if fn == nil {
            fn = func(_ context.Context, _ *graphql.FieldContext) bool {
                return true
            }
        }
        cfg.shouldStartSpanFunc = fn
    }
}

テストケース

テストでは3パターンを検証しています。

  • 常に true を返す関数を渡す
    • => Span が生成される
  • 常に false を返す関数を渡す
    • => Span が生成されない
  • nil を渡す
    • => デフォルト挙動で Span が生成される
"WithShouldStartSpanFuncFalse": {
    tracerOpts: []Option{WithShouldStartSpanFunc(func(_ context.Context, _ *graphql.FieldContext) bool {
        return false
    })},
    test: func(assert *assert.Assertions, _ *mocktracer.Span, spans []*mocktracer.Span) {
        var hasFieldOperation bool
        for _, span := range spans {
            if span.OperationName() == fieldOp {
                hasFieldOperation = true
                break
            }
        }
        assert.Equal(false, hasFieldOperation)
    },
},

使い方

マージされた機能は dd-trace-go v2.4.0 以降で利用できます。

import (
    "context"
    "strings"

    "github.com/99designs/gqlgen/graphql"
    gqlgentrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/99designs/gqlgen"
)

func main() {
    t := gqlgentrace.NewTracer(
        gqlgentrace.WithServiceName("my-graphql-server"),
        gqlgentrace.WithShouldStartSpanFunc(func(ctx context.Context, fieldCtx *graphql.FieldContext) bool {
            // field 名を基として Span を生成してもよし...
            // 乱数を用いてサンプリングレートのようにしてもよし...
        }),
    )
    
    h := handler.NewDefaultServer(generated.NewExecutableSchema(config))
    h.Use(t)
    // ...
}

graphql.FieldContext からはフィールドの様々な情報が取得できるので、プロジェクトの要件に合わせて柔軟にフィルタリングロジックを組むことができます。

OSS コントリビューションの流れ

7月: PR を出す

課題を感じてから実装し、PR を出したのが2025年7月でした。

Description には「なぜ必要か」「既存オプションでは不十分な理由」「具体的なユースケース」を丁寧に書きました。

9月: リマインド

実は3ヶ月ほど反応がありませんでした...。

大きな OSS プロジェクトではメンテナーも忙しいので、これは珍しいことではありません。

ここで大事なのはコントリビューションガイドライン (CONTRIBUTING.md) を読むことです。

We try to review new PRs within a week of them being opened. If more than two weeks have passed with no reply, please feel free to comment on the PR to bubble it up.

(引用元: https://github.com/DataDog/dd-trace-go/blob/main/CONTRIBUTING.md#getting-a-pr-reviewed)

dd-trace-go のガイドラインには「一定期間反応がなかったら突っついていいよ」と書いてありました。

ルールに従ってメンテナーにメンションしたところ、すぐにレビューが始まりました。

10月: マージ

レビュワーから「LGTM! thanks for your contribution and apologies for the delay 🙇」というコメントをもらい、無事マージされました。

OSS コントリビューションの学び

今回のコントリビューションを通じていくつか学びがありました。

  • コントリビューションガイドラインは必ず読む
    • 「反応がなかったら突っついていい」というルールを知らなければ、ずっと待ち続けていたかもしれません
    • プロジェクトごとにルールが異なるので、最初に確認しておくことが大切です
  • PR の Description は丁寧に書く
    • なぜこの変更が必要なのか、既存の方法では解決できない理由は何か
    • メンテナーが判断しやすい情報を提供することで、レビューがスムーズになります
  • 気長に待つことも大切
    • 大きな OSS プロジェクトのメンテナーは多忙です
    • 3ヶ月待ったのは長かったですが、突っついたらすぐにマージされました

まとめ

  • dd-trace-go の gqlgen tracer に WithShouldStartSpanFunc オプションを追加した
  • GraphQL 環境での Span 爆発問題を、ユーザーが柔軟に制御できるようになった
  • OSS へのコントリビュートは、ガイドラインを読んで丁寧に PR を書けば意外とハードルは高くない

GraphQL (gqlgen) + Datadog 環境で Span 数に悩んでいる方は、ぜひ WithShouldStartSpanFunc を試してみてください!


kubell ではエンジニアを積極的に募集しています。Go や GraphQL に興味のある方はぜひお声がけください! www.kubell.com