dd-java-agent によって GraphQL クエリの情報が自動収集される仕組み

この記事は株式会社エス・エム・エス Advent Calendar 2023の14日目の記事です。

qiita.com


カイポケリニューアルプロジェクトのバックエンドチームに所属している丸井です。

私のチームでは Spring for GraphQL を使ってバックエンドサービスを開発しています。監視基盤としては Datadogを利用していますが、トレースやGraphQL クエリ関連の各種メトリクスは java プロセス起動時に javaagent として dd-java-agentを指定することで収集がされています。

dd-java-agent を使うことでアプリケーションコードに何も手を入れなくても収集されるのはありがたいものの、どういう仕組みで動作しているのか分かっていなかったため調べてみることにしました。

※ Datadog の Metrics メニューを開いてみると、GraphQL 関連のメトリクスが収集されているのが確認できる

参考: OpenTelemetry Instrumentation for Java の仕組み

javaagent を利用してよしなに情報を収集するライブラリとしては他にもありますが、dd-java-agent と類似の位置づけのものとして OpenTelemetry Instrumentation for Java があります。

VA Linux エンジニアブログ: OpenTelemetry Instrumentation for Javaの自動トレースの仕組みの調査という記事にその仕組みが解説されており、分かりやすかったので紹介します。

上記の記事にあるように、OpenTelemetry Instrumentation for Java(や dd-java-agent)は javaagent によってクラスファイルをロードする前にバイトコードを動的に変更して、一般的なライブラリやフレームワークからテレメトリを収集して監視基盤に送信している、というのが基本的な仕組みです。

dd-java-agent が自動的に収集する対象

dd-java-agent も一般的なライブラリやフレームワークをカバーしているため、導入するだけで情報が収集されます。

https://github.com/DataDog/dd-trace-java/tree/master/dd-java-agent/instrumentation

2023/12現在、収集対象は200を超えており、spring、tomcat、servlet など Web サービスに関わるものから、java.lang 系の一部のクラスなんかも含まれています1

Spring for GraphQL が依存している GraphQL Java も対象となっており、そんなわけで GraphQL のクエリ情報が収集され Datadog に送信されています。

GraphQL Java の情報はどのように収集されているか

ここからは GraphQL Java 用の instrumentation を見ていきます。

ちなみに instrumentation(計装)とは、wikipedia: 計装 によると「生産工程等を制御するために、測定装置や制御装置などを装備し、測定すること」とあり、OpenTelemetry のドキュメントのDocs/Concepts/Instrumentationにもシステムを観測可能にするために実施するなにがしかのこと、という趣旨の説明が書かれています。

GraphQL Java の instrumentation コードは dd-trace-java リポジトリ内の /dd-java-agent/instrumentation/graphql-java-14.0に配置されていますが、先に dd-trace-java の CONTRIBUTING.md: Adding Instrumentationsを見てみると、

  • Must implement Instrumenter - note that it is recommended to implement Instrumenter.Default in every case.

instrumentation の追加には Instrumenter の実装が必要とあるので、まずは Instrumenter から見ていきます。

GraphQL Java の Instrumenter 実装

GraphQL Java の Instrumenter は GraphQLJavaInstrumentation.javaというファイルに実装されています。中を見てみると以下のように checkInstrumentationDefaultState というメソッドに対する Advice を適用しています。

  @Override
  public void adviceTransformations(AdviceTransformation transformation) {
    transformation.applyAdvice(
        isMethod()
            .and(
                namedOneOf(
                    "checkInstrumentationDefaultState" // 9.7+
                    // https://github.com/graphql-java/graphql-java/commit/821241de8ee055d6d254a9d95ef5143f9e540826
                    //                    "checkInstrumentation" // <9.7
                    // https://github.com/graphql-java/graphql-java/commit/78a6e4eda1c13f47573adb879ae781cce794e96a
                    ))
            .and(returns(named("graphql.execution.instrumentation.Instrumentation"))),
        this.getClass().getName() + "$AddInstrumentationAdvice");
  }

checkInstrumentationDefaultState というのは GraphQL Java に実装されているメソッドで、GraphQL サーバー機能のセットアップ時に呼び出されるものです。

Advice を適用することで、上記のメソッドの呼び出し前後に処理を追加することが出来ます。 適用する Advice は以下のようになっており、GraphQL Java で作成された instrumentation を dd-java-agent 側で実装している GraphQLInstrumentation でラップして返すようになっています。

  @SuppressWarnings("unused")
  public static class AddInstrumentationAdvice {
    @Advice.OnMethodExit(suppress = Throwable.class)
    public static void onExit(@Advice.Return(readOnly = false) Instrumentation instrumentation) {
      instrumentation = GraphQLInstrumentation.install(instrumentation);
    }

    public static void muzzleCheck() {
      // Class introduced in 15.0
      ValueUnboxer value = ValueUnboxer.DEFAULT;
    }
  }

突然「GraphQL Java で作成された instrumentation」というのが登場していますが、GraphQL Java 自身も計装のための仕組みを備えており、起動時に instrumentation のためのセットアップがされるようになっています。

https://www.graphql-java.com/documentation/instrumentation/

dd-java-agent は「GraphQL Java で作成された instrumentation」をラップすることで Datadog 連携用の計装を追加している形になります。

GraphQLInstrumentation の実装

GraphQL Java の Instrumentation のラッパークラスの実装はこちら

GraphQL クエリの情報を収集する上で重要なのは以下の DataFetcher に関する箇所です。

  @Override
  public DataFetcher<?> instrumentDataFetcher(
      final DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters) {
    State state = parameters.getInstrumentationState();
    final AgentSpan requestSpan = state.getRequestSpan();
    return new InstrumentedDataFetcher(dataFetcher, parameters, requestSpan);
  }

オリジナルの DataFetcher を dd-java-agent の InstrumentedDataFetcher でラップしています。

そして InstrumentedDataFetcher の実装はこちら

GraphQL クエリを処理する際に呼び出される DataFetcher#get に注目してみると、

  public Object get(DataFetchingEnvironment environment) throws Exception {
    if (parameters.isTrivialDataFetcher()) {
      try (AgentScope scope = activateSpan(this.requestSpan)) {
        return dataFetcher.get(environment);
      }
    } else {
      final AgentSpan fieldSpan = startSpan("graphql.field", this.requestSpan.context());
      // ...省略
      dataValue = dataFetcher.get(environment);
      // ...省略
      fieldSpan.finish();
      // ...省略
  }

startSpanfinish が実行されることが読み取れます。 このように GraphQL クエリに対する処理を実行する前後に処理を追加することで、情報を収集していることが分かりました。

(ちなみに上記の箇所以外では、GraphQL クエリの parse や validation に対する情報が収集されていました)

まとめ

  • dd-java-agent や OpenTelemetry Instrumentation for Java は javaagent を利用することで一般的なライブラリやフレームワークに対する自動 instrumentation を実施
  • GraphQL Java に対する instrumentation は GraphQL Java 側に実装されている checkInstrumentationDefaultState メソッドに Advice を適用して実装を差し替えることで実現

  1. java.lang 系の instrumentation を覗いてみたところ、脆弱性検出のためのコードを含んでいました。(例: java.lang.Math.random の呼び出しの監視)