GCPの Cloud Traceを OpenTelemetry ベースで 動作確認した際の 最小限のGo言語実装

第三開発部所属の恩田です。久しぶりに記事を書いてみました。

## 対象者
- OpenTelemetry で可観測性を上げたいエンジニア
- 利用環境は Cloud Trace (GCP) である
- 取り敢えず動かしたい

## 概要
最近 Cloud Traceを Go言語のコードに適用した。

> 過去にも同等の経験がありその際は簡単だったので、すぐに済むと高を括っていたが、現在推奨の連携は [Open Telemetry](https://opentelemetry.io/ja) ベースの実装となりやや複雑化している。幾つかのサンプル実装を目にしたが、個人的には最小セットのコードが欲しくそれ以上の機能は一旦不要なので、必要なものだけピックアップした最小セットに近いものを作成した。

> なお、公式のサンプルコードも [GCP版](https://cloud.google.com/trace/docs/setup/go-ot?hl=ja) 
 [OpenTelemetry版](https://opentelemetry.io/docs/languages/go/getting-started/) とあるが、何れも Metricsや Logの実装も含めていたり、それらのシャットダウン処理を束ねるクリーンアップ関数を用意して後処理を効率化したりと要所々々で色を付けているので、そこは一旦カットした（本番適用時のデザインとしては秀逸なので追って適用）
 
 > 余談だが、Metricsを利用すると任意の値を元に Cloud Monitoring上の独自の Metricsとしてグラフが作成され可観測性が上がるので、興味があればそちらも試してみて欲しい。

## 実装
結論から述べれば後述の実装がほぼ最小セットである。動かすために追加した部分は2箇所だ。
- ① 環境誤差を環境変数として読み込む部分と、それを `.env` ファイル越しに設定する部分
- ② 模擬的な計測対象としての関数（spanXXという形式の名称）とその共通機能を抽出した `spanFunc`

この辺りは本質ではないが、動作確認のために必要となっている。
> 本当に骨組みだけ知りたければ OpenTelemetryの [Go Instruction](https://opentelemetry.io/docs/languages/go/instrumentation/) が参考になる。

また、動作確認用のコードなので、見通し重視でエラーハンドリングは適当だ。もし実際に利用するならその辺りも補強した上で利用して欲しい。

### 解説

主な関数の構造としては以下の形式になっている。
- `main` 環境変数より各種情報を収集する
- `control` 主な処理の流れをコントロールする
- `newTracerProvider` トレース制御を行う `Tracer` のファクトリを初期化する

基本的に `newTracerProvider` で生成する `TracerProvider` の `Exporter` を入れ替えれば様々なベンダーのメトリクスに連携ができるようになっており、それ以外は汎用的な構造をしている。

> [ベンダー一覧](https://opentelemetry.io/ecosystem/vendors/) を見ると 2024/12/04現在で 76ベンダーを確認できる。これらの共通項を提示している訳で OpenTelemetryは偉大である。

```go:main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"go.opentelemetry.io/otel/attribute"

	gcptrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
	"github.com/joho/godotenv"

	"go.opentelemetry.io/contrib/detectors/gcp"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
	"go.opentelemetry.io/otel/trace"
)

// Prerequisites:
//   - ServiceAccount with "Cloud Trace Agent" role
//   - Enabling "Cloud Trace API"
//
// See:
//   - https://github.com/GoogleCloudPlatform/golang-samples/blob/main/opentelemetry/instrumentation/app/setup.go
func main() {
	// ① 環境誤差を環境変数として読み込む部分と、それを `.env` ファイル越しに設定する部分
	if err := godotenv.Load(".env"); err != nil {
		log.Fatal(err)
	}
	projectID := os.Getenv("GOOGLE_CLOUD_PROJECT")
	serviceName := os.Getenv("SERVICE_NAME")
	credFilePath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")

	fmt.Printf("GOOGLE_CLOUD_PROJECT: [%s], SERVICE_NAME: [%s], GOOGLE_APPLICATION_CREDENTIALS: [%s] \n", projectID, serviceName, credFilePath)

	if err := control(projectID, serviceName); err != nil {
		log.Fatal(err)
	}
}

func control(projectID, serviceName string) error {
	ctx := context.Background()

	// TraceProvider の設定
	tp, err := newTracerProvider(ctx, projectID, serviceName)
	if err != nil {
		return err
	}
	defer func(ctx context.Context) {
		_ = tp.ForceFlush(ctx)
		_ = tp.Shutdown(ctx)
	}(ctx)
	otel.SetTracerProvider(tp)

	// Trace情報の収集
	t := tp.Tracer("trace-1")
	if err := span1(ctx, t); err != nil {
		return err
	}
	return nil
}

func newTracerProvider(ctx context.Context, projectID, serviceName string) (*sdktrace.TracerProvider, error) {
	// 1. 出力先に責任を持つ Exporterを用意し、GCPプロジェクトに設定
	// See: https://opentelemetry.io/ecosystem/vendors/
	exporter, err := gcptrace.New(gcptrace.WithProjectID(projectID))
	if err != nil {
		return nil, err
	}
	// 2. 資源情報に責任を持つ Resourceを用意し、サービスを設定
	res, err := resource.New(ctx,
		resource.WithDetectors(gcp.NewDetector()),
		resource.WithAttributes(
			semconv.ServiceNameKey.String(serviceName),
		),
	)
	if err != nil {
		return nil, err
	}
	// 3. サンプリング率の設定
	sampler := sdktrace.AlwaysSample() // sdktrace.NeverSample(), sdktrace.TraceIDRatioBased(0.1) are another options

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(res),
		sdktrace.WithSampler(sampler),
	)
	// 4. トレースコンテキストの伝播方法を Contextベースに指定
	otel.SetTextMapPropagator(propagation.TraceContext{})
	return tp, nil
}

// ↓ ③ 模擬的な計測対象としての関数（spanXXという形式の名称）とその共通機能を抽出した `spanFunc`
func spanFunc(ctx context.Context, t trace.Tracer, spanName string, latency time.Duration, f func(ctx context.Context) error) error {
	chCtx, span := t.Start(ctx, spanName)
	span.SetAttributes(attribute.String("expectedLatency", latency.String())) // Optional
	defer span.End()
	time.Sleep(latency)
	return f(chCtx)
}

func span1(ctx context.Context, t trace.Tracer) error {
	return spanFunc(ctx, t, "span-1", 10*time.Millisecond, func(ctx context.Context) error {
		_ = span11(ctx, t)
		_ = span12(ctx, t)
		return nil
	})
}

func span11(ctx context.Context, t trace.Tracer) error {
	return spanFunc(ctx, t, "span-11", 20*time.Millisecond, func(ctx context.Context) error {
		_ = span111(ctx, t)
		return nil
	})
}

func span111(ctx context.Context, t trace.Tracer) error {
	return spanFunc(ctx, t, "span-111", 5*time.Millisecond, func(ctx context.Context) error {
		return nil
	})
}

func span12(ctx context.Context, t trace.Tracer) error {
	return spanFunc(ctx, t, "span-12", 30*time.Millisecond, func(ctx context.Context) error {
		return nil
	})
}

```

```:.env
GOOGLE_CLOUD_PROJECT="xxxxx"
SERVICE_NAME="xxxx"
GOOGLE_APPLICATION_CREDENTIALS="xxxx.json"
```


## 実行方法
### 環境変数の用意
下記の環境変数を埋める。 `.env` ファイルに定義しておけば都度引数として渡す必要が無い。

| # | 名前 | 設定内容 |
| -- | -- | -- |
| 1 | GOOGLE_CLOUD_PROJECT | GCPのプロジェクトID |
| 2 | SERVICE_NAME | Trace上に表示されるサービスの論理名 |
| 3 | GOOGLE_APPLICATION_CREDENTIALS | ローカル実行時に参照するクレデンシャルファイルのパス |

### 権限設定
IAMで上記の #3で指定したサービスアカウントに以下の権限を付けておく
- Cloud Trace エージェント

### API有効化
以下のAPIを有効化しておく
- Cloud Trace API

## ハマりどころ
今回実際にハマった部分を晒しておく。分かり易いエラーメッセージを出力してくれる優しい人達ばかりではないので注意して欲しい。
- IAM設定が抜けていてエラーが出る
- API許可が抜けていて無言で Traceが出ない
- `TraceProvider.ForceFlush(ctx)` が無いため Traceに出ない
- `sdktrace.AlwaysSample()` が無いため Traceに出ない 
- `trace` パッケージが以下の3種存在するので混乱する
    - gcpに出力する Exporter定義のための `trace`
    - Trace処理を実際に実装している otel SDKの `trace`
    - インターフェイスを提供する otel本体の `trace`

> なお、`TraceProvider.ForceFlush(ctx)` と `tracer.Trace.AlwaysSample()` の部分は稼働確認後にプロジェクトの適性に合わせて再設定して欲しい。そうしないと全てが都度漏れなく出力されてしまい、性能に支障が出てしまう。

## 出力先
管理コンソール（GUI）上の Cloud Traceを確認すると、 [Traceエクスプローラ](https://console.cloud.google.com/traces/list) 画面上に青いドットとして出力され、選択するとトレース情報が出力される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375665/98a929a9-c39f-f396-2103-e72d15676e8a.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375665/e34477b4-58dc-c763-a6f6-dd05918cd0b3.png)

また、属性情報を付与した場合は、トレースを選択すると表示される属性欄に記載される。例えば、Repository 上の関数で複数件の登録を行った際に、その件数を属性として設定しておく等の利用法が考えられる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375665/1fb71752-3d41-56cc-fb79-9cedd4faf592.png)


## 実装の拡張
一通りの稼働を確認できたら、`Span` に追加の情報を載せて可観測性を上げると良い。具体的には以下のような機能が用意されている。 `Attributes` を代表としてサンプル実装にも載せたので、これらを適切に使って解像度の高いトレースを実現したい。

| # | 名称 | 説明 | Link |
| --- | --- | --- | --- |
| 1 | Attributes | Spanに追加の属性情報を付与する<br>フィルタリング等で利用可能 | [Span Attributes](https://opentelemetry.io/docs/languages/go/instrumentation/#semantic-attributes) |
| 2 | Events | 期間内に特殊なイベントが発生した場合に付与する。ロック制御・再試行等 | [Events](https://opentelemetry.io/docs/languages/go/instrumentation/#events) |
| 3 | Status | 異常なステータスをマークする | [Set span status](https://opentelemetry.io/docs/languages/go/instrumentation/#set-span-status) |
| 4 | Record Error | 異常なステータスと共にエラーを記録する | [Record errors](https://opentelemetry.io/docs/languages/go/instrumentation/#record-errors) |
| 5 | Propagation | 別のマイクロサービス等、プロセス外の対象に伝搬させる | [Propagators and Context](https://opentelemetry.io/docs/languages/go/instrumentation/#propagators-and-context) |

## Otelの全貌

ついでに全体構成を分析していったら、結構大掛かりだったので解像度の粗い画像で雰囲気だけでも晒してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375665/c7469ae8-f848-30a9-7c66-e6c9f200fe27.png)

<!--
パッケージとしては 3層に分かれており、以下のようになっている。
- インターフェイスの定義とそれらの組み合わせを定義した `go.opentelemetry.io/otel`（`otel` と表記）
- 実装を提供している `go.opentelemetry.io/otel/sdk`（`otelsdk` と表記）
- GCPへの出力を担う `google-cloud-platform/opentelemetry-operations-go`（`gcp` と表記）

最重要なインターフェイスとしては、`TracerProvider`, `Tracer`, `Span` の三種となっており、それぞれが直後のインスタンスを生成するために用いられる。

生成時には Functional Options パターンによる細かい挙動の指定が可能となっている。特に、起点となる `TracerProvider` には多くの設定が可能であり、設定毎に独自のモデルが存在する。特に注目すべきは `gcp`系に連携するための `Exporter` `Resource` 辺りだが、その部分も大きなツリーの中のほんの一部を実装しているに過ぎないことがわかるだろう。
-->

これで何が言いたいのかといえば、「これだけ複雑なら初見が苦労するはずだよ」ということ。全貌の詳細は 別途まとめて記事化しようと考えている。

## まとめ
簡単そうに見えるトレース出力が意外と難しかった。苦労したくない皆様はこのサンプルを動かしてみた上で、問題なく稼働したならエレガントなコードにリファクタリングして利用することで工数を最小化して欲しい。