OpenCensusとJavaで分散トレーシングをする

## はじめに

OpenCensus APIとJavaで分散トレーシングを実装してみました。OpenCensusはGoogleのCloud Trace(旧 StackDriver Trace)をベースとした仕様で分散トレースとメトリック収集の仕様です。
こちらを使うことでPG側は統一したAPIを利用しながらバックエンドをJeagerやOpenZipkinあるいはCloud Traceなどに連携できます。

実はOpenCensusは既に古く、似た様な仕様のOpenTracingと統合する形で現在は[OpenTelemetry](https://opentelemetry.io/)として標準化されています。こちらはDatadog, NewRelic, Dynatrace, Instanaといった商用のAPMも含めて対応しているので今後はこちらを使うべきです。
なので、最初はOpenTelemetryを弄ってたのですがJeagerには連携できたものの[Cloud TraceではJava版はまだ未対応](https://cloud.google.com/trace/docs/setup)だったので、とりあえずドキュメントの多そうなOpenCensusを試してみました。

ただ、思ったほどドキュメントが整理されてなかったので備忘をかねてまとめてみます。

今回、作成したコードは以下にあります。
https://github.com/koduki/miniban/tree/example/opensensus

## システム構成

システム構成としてはシンプルに`api-endpoint`でリクエストを受け取り、ビジネスロジックを持つバックエンドの`api-core`に処理をREST/JSONで投げています。

![opencensus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54904/ba54bfd0-3e32-88ac-2ae2-38d6c3291355.png)

マイクロサービスというほど細分化はされてないけど、これはこれで良くある構成だし分散トレーシングの検証としては十分かと思います。
各APIはQuarkusを使ってJAX-RSで実装しています。MicroProfile特有のAPIも基本的には使ってないのでJavaEE環境ならどれでもそのまま動くはずです。

## 実装

### 依存ライブラリ

依存ライブラリとしてpom.xmlに以下を追加します。

```xml
<dependency>
    <groupId>io.opencensus</groupId>
    <artifactId>opencensus-api</artifactId>
    <version>0.26.0</version>
</dependency>
<dependency>
    <groupId>io.opencensus</groupId>
    <artifactId>opencensus-impl</artifactId>
    <version>0.26.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.opencensus</groupId>
    <artifactId>opencensus-exporter-trace-stackdriver</artifactId>
    <version>0.26.0</version>
</dependency>
<dependency>
    <groupId>io.opencensus</groupId>
    <artifactId>opencensus-exporter-trace-jaeger</artifactId>
    <version>0.26.0</version>
</dependency>
<dependency>
    <groupId>io.opencensus</groupId>
    <artifactId>opencensus-contrib-http-jaxrs</artifactId>
    <version>0.26.0</version>
</dependency>
```

APIとして必要なのはopencensus-apiとopencensus-impl。opencensus-exporter-xxxはトレース情報を送信するexpoter各種のライブラリ、`opencensus-contrib-http-jaxrs`はJAX-RS向けの便利ライブラリです。
今回は、stackdriverとjaegerの２つのエクスポーターを指定していますが普通はどれか一つになります。

### Exporterの初期化

まずはExporterの初期化と登録を行います。これは一度だけ行えば良いので`@Initialized(ApplicationScoped.class)`を使って起動時に読み込んでしまいます。

```Bootstrap.java
@ApplicationScoped
public class Bootstrap {
    public void handle(@Observes @Initialized(ApplicationScoped.class) Object event) {
        JaegerTraceExporter.createAndRegister(
                JaegerExporterConfiguration.builder()
                        .setThriftEndpoint("http://localhost:14268/api/traces")
                        .setServiceName("api-endpoint")
                        .build());
    }
}
```

[Quick Start](https://github.com/census-instrumentation/opencensus-java/tree/master/exporters/trace/jaeger#quickstart)では直接URLを`createAndRegister`の引数にとっていますがこれは既に非推奨コードのようなので`JaegerExporterConfiguration`を使います。

あとは見ての通りですが`setThriftEndpoint`で連携先のURLを指定し、`setServiceName`でJaeger上のサービス名となります。

このExporterの登録部分さえ変更してしまえば、バックエンドを任意に切り替えれます。たとえば、Cloud Traceを使いたい場合は以下の様に変更します。

```Bootstrap.java
@ApplicationScoped
public class Bootstrap {
    public void handle(@Observes @Initialized(ApplicationScoped.class) Object event) {
        String gcpProjectId = "your GCP project ID";
        StackdriverTraceExporter.createAndRegister(
                StackdriverTraceConfiguration.builder()
                        .setProjectId(gcpProjectId)
                        .build());
    }
}
```

### Trace Spanの作成

つづいてSpanの作成です。

```java
try (Scope ss = Tracing.getTracer()
        .spanBuilder("Span Name")
        .setRecordEvents(true)
        .setSampler(Samplers.alwaysSample())
        .startScopedSpan()) {

    // do somthing.
}
```

`Tracing.getTracer()`はシングルトンつまりグローバルからトレーサーを取得します。そこから`spanBuilder`を使ってSpanを組み立てます。
ポイントは`setSampler`です。サンプラはトレースを取得する頻度を指定します。ここで`Samplers.alwaysSample()`を指定しないとトレース情報が常に書き込まれません。本番では適切な閾値を費用や負荷の面から付けるケースもあると思いますが、テスト時は負荷テスト以外ではリクエストが少な過ぎるので必ずalwaysを指定しましょう。
そのほかのサンプラの詳細は[こちら](https://opencensus.io/tracing/sampling/#1)にあります。

ちなみに毎回上記を書くのは面倒なので以下の様なヘルパーソメッドを定義してみました。

```java
public static <R> R trace(Supplier<R> callback) {
    var depth = 2;
    var className = Thread.currentThread().getStackTrace()[depth].getClassName();
    var methodName = Thread.currentThread().getStackTrace()[depth].getMethodName();

    try (var ss = Tracing.getTracer()
            .spanBuilder(className + "$" + methodName)
            .setRecordEvents(true)
            .setSampler(Samplers.alwaysSample())
            .startScopedSpan()) {
        return callback.get();
    }
}
```

クラス名とメソッド名を取得してSpan名に自動で指定しています。利用する時は下記の様な感じです。

```java
@GET
@Path("/{userId}/balance")
public Map<String, Long> getBalance(@PathParam("userId") String userId) {
    return trace(() -> {
        var balance = service.getBalance(userId);
        return balance;
    });
}
```

### Trace Contextの伝播

同じアプリケーション内であれば上記のように`startScopedSpan`をするだけで勝手に入れ子のSpanが作成されます。
ただし、分散トレーシングの肝はシステム間連携です。そのためリモートのコンテキストを連携してやる必要があります。ただ、このあたりからドキュメントが古かったりちゃんと書かれてなくて試行錯誤での取り組みになりました。

#### traceparent

OpenCensusでは元々はX-B3-TraceId/X-B3-ParentSpanIdといったヘッダー情報を使ってコンテキストを渡してたようなのですが、現在は[W3Cで標準化されたTrace Context](https://w3c.github.io/trace-context/)が使われています。
これは`traceparent`というパラメータをHTTPヘッダに埋め込んでそれを元に「Trace ID」「Span ID」「Trace Options(trace-flags)」を組み立てます。

traceparentは以下の様な値です。

```
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
```

OpenCensusでtraceparentを解析してSpanContextを作成するのは以下の様なコードになります。

```java
int VERSION_SIZE = 2;
int TRACEPARENT_DELIMITER_SIZE = 1;
int TRACE_ID_HEX_SIZE = 2 * TraceId.SIZE;
int SPAN_ID_HEX_SIZE = 2 * SpanId.SIZE;
int TRACE_ID_OFFSET = VERSION_SIZE + TRACEPARENT_DELIMITER_SIZE;
int SPAN_ID_OFFSET = TRACE_ID_OFFSET + TRACE_ID_HEX_SIZE + TRACEPARENT_DELIMITER_SIZE;
int TRACE_OPTION_OFFSET = SPAN_ID_OFFSET + SPAN_ID_HEX_SIZE + TRACEPARENT_DELIMITER_SIZE;

// Get tranceparent
String traceparent = headers.getRequestHeaders().getFirst("traceparent");

// Parse traceparent
TraceId traceId = TraceId.fromLowerBase16(traceparent, TRACE_ID_OFFSET);
SpanId spanId = SpanId.fromLowerBase16(traceparent, SPAN_ID_OFFSET);
TraceOptions traceOptions = TraceOptions.fromLowerBase16(traceparent, TRACE_OPTION_OFFSET);

// traceparentからSpanContextの作成
SpanContext spanContext = SpanContext.create(traceId, spanId, traceOptions);
```

#### JAX-RSクライアント(REST Call時にtraceparentを付与)

コンテキストをリモートのAPIに伝えるにはREST Call時にtraceparentをHTTPヘッダ等に付与してやる必要があります。
手動でやっても良いですがJAX-RS向けのOpenCensusライブラリの「[opencensus-contrib-http-jaxrs](https://github.com/census-instrumentation/opencensus-java/tree/master/contrib/http_jaxrs)」を利用します。

```java
var url = 'http://localhost:5000';
var target = ClientBuilder.newClient()
        .target(url)
        .path("/account/" + userId + "/balance");

target.register(JaxrsClientFilter.class);
return target
        .request(MediaType.APPLICATION_JSON)
        .get(new GenericType<Map<String, Long>>() {});
```

`target.register(JaxrsClientFilter.class)`でJaxrsClientFilterを登録することで自動的にリクエストヘッダーに現状のSpanからtraceparentを生成して付与してくれます。

#### JAX-RSクライアント(traceparentからSpanの作成)

JAX-RSのサーバ側、今回でいうapi-coreの実装です。
[opencensus-contrib-http-jaxrs](https://github.com/census-instrumentation/opencensus-java/tree/master/contrib/http_jaxrs)のContainer Filterを使うことで自動で設定できる様なのですが、なぜかうまく動かなかったので自前で実装します。

```java
private static final TextFormat textFormat = Tracing.getPropagationComponent().getTraceContextFormat();
private static final TextFormat.Getter<HttpServletRequest> getter = new TextFormat.Getter<HttpServletRequest>() {
    @Override
    public String get(HttpServletRequest httpRequest, String s) {
        return httpRequest.getHeader(s);
    }
};

@GET
@Path("/{userId}/balance")
public Map<String, Long> getBalance(@Context HttpServletRequest request, @PathParam("userId") String userId) throws SpanContextParseException {
    var spanContext = textFormat.extract(request, getter);

    var depth = 1;
    var className = Thread.currentThread().getStackTrace()[depth].getClassName();
    var methodName = Thread.currentThread().getStackTrace()[depth].getMethodName();

    try (var ss = Tracing.getTracer()
            .spanBuilderWithRemoteParent(className + "$" + methodName, spanContext)
            .setRecordEvents(true)
            .setSampler(Samplers.alwaysSample())
            .startScopedSpan()) {
        var balance = service.getBalance(userId);
        return balance;
    }
}
```

`TextFormat`クラスを活用することで先ほどの面倒なパースを自前でせずに`SpanContext`を作成できます。
また、通常のSpan作成とは違いリモートのコンテキストからスパンを作る場合は`spanBuilderWithRemoteParent`を使用します。
以降の子Spanを作る時は`spanBuilder`で問題ありません。

以上で、JavaでOpenCensusを使った分散トレーシングの設定は完了なので、実際に動かしてJaegerやCloud Traceに連携されているか確認してください。

## まとめ

JavaでOpenCensusを使った分散トレーシングを実装してみました。
正直、パッと見ドキュメントあるし有名な仕様だからブログ記事とかも多いだろうから１時間と少しくらいで楽勝だろっと思ってたら沼にハマり１日くらい掛かってしまいました。

ドキュメント不足とか不備をなんとかしたい気もしますが、きっとその労力はOpenTelemetryに使われるべきなので、
あと、細かいところが書いてないのは基本的にはそもそも分散トレースに詳しい一部の人がライブラリやFWを設計してあとは使うだけってコンセプトだからな気はします。[MPのOpenTracing](https://github.com/eclipse/microprofile-opentracing/blob/master/spec/src/main/asciidoc/microprofile-opentracing.asciidoc)とかOpenTelemetryの[OpenTelemetry Auto-Instrumentation for Java](https://github.com/open-telemetry/opentelemetry-java-instrumentation)は良く知らなくても触れますしね。

まあ、結果として今回いろいろ詳しくなれたと思うのでそれはそれで良かったかな。OpenTelemetryもそのうち再チャレンジしないと。

それではHappy Hacking!

## 参考

- [OpenCensus: QuickStart - Java](https://opencensus.io/quickstart/java/tracing/)
- [Github: OpenCensus - Java](https://github.com/census-instrumentation/opencensus-java)
- [W3C の Trace Context の翻訳](https://qiita.com/TsuyoshiUshio@github/items/49c58608ccff168ee05e)
- [Distributed tracing with Opencensus and Jaeger in Java](https://medium.com/@mauro.canuto88/distributed-tracing-with-opencensus-and-jaeger-in-java-7de6454b0aa0)