OpenTelemetryのSpring Boot starterを使ってトレースを見てみる

## はじめに

最近話題のOpenTelemetryについて勉強しました。アプリケーションのコードをほぼ修正することなくテレメトリ情報を取得できるようです。とても便利に感じたのでご紹介したいと思います。
本記事では、今年の9月にstableとなった[Spring Boot starter](https://opentelemetry.io/blog/2024/spring-starter-stable/)を使用して、Spring BootのアプリケーションにOpenTelemetryを導入してみます。今回は主に**トレース**について見ていきたいと思います。

## OpenTelemetry

### 概要

https://opentelemetry.io/

公式サイトでは、OpenTelemetryについて以下のように説明されていました。

> OpenTelemetryはオブザーバビリティフレームワークであり、トレース、メトリクス、ログのようなテレメトリーデータを作成・管理するためにデザインされたツールキットです。 

各用語の説明は以下の通りです。

- オブザーバビリティ
> オブザーバビリティとは、システムの出力を調べることによって、システムの内部状態を理解する能力のことです

- トレース
> 一般的にトレースとして知られている分散トレースは、マイクロサービスやサーバーレスアプリケーションのようなマルチサービスアーキテクチャを伝播するリクエスト（アプリケーションまたはエンドユーザーによって行われる）が辿った経路を記録します。

- メトリクス
> メトリクス とは、インフラやアプリケーションに関する数値データを一定期間にわたって集計したものです。 たとえば、システムエラー率、CPU使用率、あるサービスのリクエスト率などです。

- ログ
> ログは、サービスや他のコンポーネントが発するタイムスタンプ付きのメッセージです。

- テレメトリ
> テレメトリー とは、システムやその動作から送出されるデータのことです。 データはトレース、メトリクス、ログなどの形式で得られます。

つまり、OpenTelemetryを使うと多くのテレメトリを送出することができるので、**システムの内部状態を理解しやすくなりデバッグや障害解析が容易になる**ということです。

### 計装

システムからテレメトリが送出されるようにすることを[計装(instrumentation)](https://opentelemetry.io/docs/concepts/instrumentation/)と言います。計装には2つの種類があります。

<dl>
  <dt>自動計装（Automatic instrumentation）</dt>
    <dd>
    アプリケーションのコードを変更せずに、システムからテレメトリを送出する。
    </dd>

  <dt>手動計装（Manual instrumentation）</dt>
    <dd>
    APIやSDKなどの計装ライブラリを使用して、テレメトリを送出するためのコードをアプリケーションに追加する。
    </dd>
</dl>

以降では、次節で紹介する簡単なベースアプリに計装を施していきたいと思います。

## ベースアプリ

### 概要

ベースアプリの概要は以下の通りです。トレースを充実させるためにアプリのレイヤは多めです。

- さいころアプリ（サーバアプリ）
    - GETリクエストを送ると１から6までの数字がランダムに1つ返却される
    - 出た目の数をDBに書き込む
- DB
    - 出た目の数の書き込み先
- クライアントアプリ
    - さいころアプリにリクエストを送る

### 構成

- Spring Boot 3.4.0
- JDK 17.0.13
- Gradle 8.11.1
- H2DB
- JDBC

Docker上でアプリを起動しています。

### 実装

#### さいころアプリ

さいころアプリのコードのほとんどは下記サンプルコードを拝借しました。

- [Getting Started by Example | OpenTelemetry](https://opentelemetry.io/docs/languages/java/getting-started/)

```java:Controller
@RestController
public class RollDiceController {

    @Autowired
    private RollDiceService service;

    @GetMapping("/rolldice")
    public String index(@RequestParam("player") Optional<String> player) {
        return service.rolldice(player);
    }

}
```

```java:Service
@Service
public class RollDiceService {

    private static final Logger logger = LoggerFactory.getLogger(RollDiceService.class);

    @Autowired
    private RollDiceDao dao;

    public String rolldice(Optional<String> player) {
        int result = this.getRandomNumber(1, 6);
        if (player.isPresent()) {
            logger.info("{} is rolling the dice: {}", player.get(), result);
        } else {
            logger.info("Anonymous player is rolling the dice: {}", result);
        }
        dao.insert(result, player);
        return Integer.toString(result);
    }

    public int getRandomNumber(int min, int max) {
        return ThreadLocalRandom.current().nextInt(min, max + 1);
    }

}
```

```java:Dao
@Repository
public class RollDiceDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public void insert(int result, Optional<String> player) {
        String playerName;
        if (player.isPresent()) {
            playerName = player.get();
        } else {
            playerName = "Anonymous player";
        }
        jdbcTemplate.update("INSERT INTO dice(player_name, val) VALUES (?, ?)",
                playerName,
                result
                );
    }

}
```

#### クライアントアプリ

5秒毎にさいころアプリへリクエストを送信します。

```java:Client
@Component
public class Client {
    
    private RestClient restClient;

    public Client(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
    }

    @Scheduled(fixedRate = 5000)
    public void callRollDice() {
        restClient.get()
                .uri("http://rolldice-app:8080/rolldice")
                .retrieve()
                .toBodilessEntity();
    }

}
```

## Spring Boot starter

https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/

Spring Bootアプリケーションを自動計装する手段は2つあります。1つは[OpenTelemetry Java agent](https://opentelemetry.io/docs/zero-code/java/agent)を使用する方法で、もう1つがSpring Boot starterを使用する方法です。両者の使い分けについては上記サイトを参照してください。今回はSpring Boot starterを使用します。

### 準備

Spring Boot starterはSpring Boot 2.6+または3.1+で使用できます。サーバとクライアントのアプリに以下の依存を追加します。（参考：[Getting started | OpenTelemetry](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/getting-started/)）

```diff_gradle:build.gradle
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.10.0"))
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
```

続いて、トレースの送信先として[Jaeger](https://www.jaegertracing.io/)を用意します。Jaegerは、トレースデータを分析・可視化することができるOSSです。

```yml:docker-compose.yml
services:
  jaeger:
    image: jaegertracing/all-in-one
    container_name: jaeger
    ports:
      - "16686:16686"
      - "4318:4318"
```

サーバとクライアントのアプリにSpring Boot starter用の環境変数を設定します。今回はアプリをJaegerと同じネットワーク内のコンテナで起動しているので、エンドポイントは``http://jaeger:4318``としています。

```yml:さいころアプリ
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
- OTEL_SERVICE_NAME=rolldice-app
```

```yml:クライアントアプリ
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
- OTEL_SERVICE_NAME=client-app
```

### トレースの確認

``http://localhost:16686/jaeger/ui/search``にアクセスしてトレースを確認します。

![{2086924B-7EBF-4765-89CD-77085B232498}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/c1211c98-6a04-bf27-3fac-584208611b90.png)

5秒毎にクライアントアプリからリクエストが送信されていることがわかります。トレースの中身は以下の通りです。

![{B6D35391-907E-4665-8D44-DC3981422C3A}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/006e0f11-9a87-cb64-ccc7-9fcf2d955313.png)

今回作成したリクエストからは4つの[スパン](https://opentelemetry.io/docs/concepts/signals/traces/#spans)を取得することができました。[スパン](https://opentelemetry.io/docs/concepts/signals/traces/#spans)とは、トレースを構成する作業や操作の単位のことです。
TagsやProcessタブを開くと、各スパンが持つ情報を確認することができます。Spring Boot starterが作成したスパンには``"telemetry.distro.name" : "opentelemetry-spring-boot-starter"``という属性が付与されています。これら4つのスパンにもこの属性が確認できました。

### アノテーションによるスパンの作成

続いて、アノテーションによるスパンの作成を行っていきます。以下の依存関係を追加します。（参考：[Annotations | OpenTelemetry](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/annotations/)）

```gradle:build.gradle
implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations")
implementation("org.springframework.boot:spring-boot-starter-aop")
```

アノテーションを各メソッドに付与します。

```java
//// rolldice-app
// RollDiceController
@WithSpan("index")
// RollDiceService
@WithSpan("rolldice")
@WithSpan("getRandomNumber")
// RollDiceDao
@WithSpan("insert")

//// client-app
// Client
@WithSpan("callRollDice")
```

トレースを確認してみます。

![{249798D7-D2C5-4CB8-97CB-0AC68A282994}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/c64589ee-3c2b-db8a-67b8-dbd11d4a2242.png)

各メソッドに対応するスパンが追加されていることがわかります。アノテーションを付与するだけでトレースを充実させることができるのは楽で良いですね。
一方で、``RollDiceService``クラスの``getRandomNumber()``メソッドに対応するスパンは作成されていません。これは[Spring AOPの仕様](https://docs.spring.io/spring-framework/reference/core/aop/proxying.html)によるものだそうです。アノテーションによるスパンの作成は、Spring AOPを利用して実装されているようです。

### リソース属性の作成

アプリがスパンに持たせたい[リソース属性](https://opentelemetry.io/docs/concepts/resources/)を簡単に作成することができます。例として、``example.key=value``という属性をスパンに付与してみます。設定は、Springのプロパティと同様に``application.yml``または``application.properties``に記述することで行うことができます。（参考：[SDK configuration | OpenTelemetry](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/sdk-configuration/)）

```yml:application.yml
otel: 
  resource: 
    attributes:
      example:
        key: value
```

スパンを確認すると、作成した属性が反映されていることがわかります。

![{8453C804-164B-407A-94CF-AF5975F05E94}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/60274fa9-da8b-98b9-91d8-ad14b03e685e.png)


### OpenTelemetry API/SDKによるスパンの作成

最後に、OpenTelemetryが提供するAPI/SDKを使用してスパンを作ってみます。Spring Boot starterの機能により、API/SDKを使用した手動計装が簡単に行えるようになっています。
今回はさいころアプリの``RollDiceService``クラスを手動計装したいと思います。まず、必要な依存関係を追加します。

```gradle:build.gradle
implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
```

1つ目の依存は、Spring Boot starterによって自動構成されたSDKを利用するためのものです。2つ目の依存は、手動計装によるスパンをOTLPでエクスポートするための依存です。

続いて、アプリの環境変数を設定します。

```yml:docker-compose.yml
- OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
```

手動計装によるトレース情報の送信プロトコルはデフォルトだと``grpc``ですが、自動計装によるトレース情報は``http/protobuf``が使われています（[参考](https://opentelemetry.io/docs/languages/java/configuration/#properties-exporters)）。エンドポイントを複数設定することはできないので、**自動計装と手動計装を併用するときは、手動計装で使用する送信プロトコルを``http/protobuf``にしなければなりません**。

> ![{93B92EDE-B411-4C78-9DB9-BF8AC4966E3C}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/b5f1790c-ee04-9a7b-d6cf-d1843e55ef2b.png)
[1]: OpenTelemetry Java agent 2.x and the OpenTelemetry Spring Boot starter use http/protobuf by default.

引用元：https://opentelemetry.io/docs/languages/java/configuration/#properties-exporters

:::note warn
自動計装と手動計装を併用するときは送信プロトコルを明示的に指定する
:::

次に、スパンの作成に必要な``Tracer``インスタンスを取得します。

```java:RollDiceService.java
public static final String INSTRUMENTATION_SCOPE = "com.example.app.service";

private final Tracer tracer;

public RollDiceService() {
    OpenTelemetry sdk = AutoConfiguredOpenTelemetrySdk.initialize().getOpenTelemetrySdk();
    this.tracer = sdk.getTracer(INSTRUMENTATION_SCOPE);
}
```

Spring Boot starterにより、手動計装に必要なSDKが自動構成されています（参考：[Zero-code SDK autoconfigure](https://opentelemetry.io/docs/languages/java/configuration/#zero-code-sdk-autoconfigure)）。自動構成された``OpenTelemetry``インスタンスから``tracer``を取得することができます。

続いて、取得した``tracer``を使用してスパンを作成していきます。``rolldice``メソッドの``int result = this.getRandomNumber(1, 6);``の行を以下のように修正します。

```java:rolldiceメソッド
    int result = 0;
    
    Span span = tracer.spanBuilder("getRandomNumber-span").setSpanKind(SpanKind.INTERNAL).startSpan();
    
    try (Scope ignored = span.makeCurrent()) {
        result = this.getRandomNumber(1, 6);
    } catch (Exception e) {
        span.setStatus(StatusCode.ERROR, "error message");
    } finally {
        span.end();
    }
```

``tracer``でスパンを作成し、``span.makeCurrent()``によりトレースに反映させています。処理が終了したら、``span.end()``でスパンを閉じます。

それでは、Jaegerでトレースを確認してみます。

![{E40FFFB5-3A36-4429-92E1-ED771AACC2C4}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3928346/b699f32e-76e8-8798-f6bb-2ca7a5d786a9.png)

自作のスパンを自動計装によるトレース内に反映させることができました。

## おわりに

今回はOpenTelemetryのSpring Boot starterの機能を使用して、シンプルなSpring Bootアプリケーションを計装しました。アプリのコードをほとんど修正することなく充実したトレースを得ることができるのは凄いですね。手動で計装する場合でも、簡単にテレメトリを作るための機能が揃っていました。
OpenTelemetryについては最近知ったのですが、[OpenTelemetryのAdvent Calendar](https://qiita.com/advent-calendar/2024/opentelemetry)があるくらい流行っているようですね。今後サポートされるテレメトリや言語、バックエンドなども増えていくと思うので、これからもOpenTelemetryに注目していきたいと思います。
ここまで読んでくださった方、どうもありがとうございました。