OpenTelemetryを使ってGoからDatadogへトレースを送ってみた

業務の中でOpenTelemetryを使ってオブザーバビリティを意識した
メトリクスの環境を構築する機会があったのでやりながら学んだことをまとめていきます。

# 前提
この記事ではトレースの複雑な設計や効率的、堅牢な実装はしません。
あくまでGoからOpenTelemetryを使ってDatadogへデータを流せるところまでをやります。
コレクターの設計などもざっくりやります。
Datadogのセットアップ周りもここでは触れませんmm

# OpenTelemetryとは
https://opentelemetry.io/ja/
OpenTelemetry は、分散システムのトレーシング、メトリクス、ログを統合的に収集・可視化するためのオープンソースのフレームワークです。Observability（可観測性）を実現するための標準的な手法を提供し、主要な言語やツールと連携できます。
OSSであること、様々なメトリクスサービスへ連携ができることや複数の分散サービスを
運用していても細かいトレースができるようなものである認識です。

# 今回の構成
ざっくりですが、以下のような構成でやります。
![](https://storage.googleapis.com/zenn-user-upload/b9cacc4af1fe-20250208.png)
Podをどのように置くかなどはありますが、以下の構成で進めます。
1. アプリケーションとコレクターはKubernetesで構築する
2. アプリケーションとコレクターは別のPodで構築する
3. DatadogへはコレクターからAPIキーを使ったAPI通信になる

# 実装について
## Go
まずはGoの実装を進めます。

```go:main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
)

func main() {

	endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")

	ctx := context.Background()

	// OpenTelemetry トレーサーを初期化
	tp, err := initTracer(ctx, endpoint)
	if err != nil {
		log.Fatalf("failed to initialize tracer: %v", err)
	}
	defer func() {
		if err := tp.Shutdown(ctx); err != nil {
			log.Printf("error shutting down tracer provider: %v", err)
		}
	}()

	// Echo サーバーを起動
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(otelecho.Middleware("sample-app")) // OpenTelemetry Echo Middleware

	// ヘルスチェックだけ動かす
	e.GET("/health", func(c echo.Context) error {
		// 検証用にトレースを追加
		tracer := otel.Tracer("sample-app")
		_, span := tracer.Start(c.Request().Context(), "HealthCheck Local")
		defer span.End()

		span.SetAttributes(
			attribute.String("custom.tag", "🚀 This is a test trace! 🚀"),
			attribute.String("custom.debugTag", "🐛 This is a debug trace! 🐛"),
			attribute.String("endpoint", "/health"),
			attribute.String("service.name", "vote-app"),
			attribute.String("environment", "local"),
			attribute.Bool("debug_mode", true),
			attribute.Int("random_number", 42),
		)

		return c.String(http.StatusOK, "OK")
	})

	go func() {
		if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server start error: %v", err)
		}
	}()

	shutdown(ctx, e)
}

// OpenTelemetry トレーサーを初期化
func initTracer(ctx context.Context, endpoint string) (*trace.TracerProvider, error) {
	traceExporter, err := otlptrace.New(
		ctx,
		otlptracegrpc.NewClient(
			otlptracegrpc.WithInsecure(),
			otlptracegrpc.WithEndpoint(endpoint),
		),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create trace exporter: %w", err)
	}

	tp := trace.NewTracerProvider(
		trace.WithBatcher(traceExporter),
		trace.WithResource(resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceNameKey.String("sample-app"),
		)),
	)
	otel.SetTracerProvider(tp)

	return tp, nil
}

// Graceful Shutdown を実装
func shutdown(ctx context.Context, e *echo.Echo) {
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)

	<-quit // シグナルを受信するまでブロック

	log.Println("Shutting down server...")

	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	if err := e.Shutdown(ctx); err != nil {
		log.Fatalf("server shutdown failed: %v", err)
	}

	log.Println("Server exited gracefully")
}
```
本来は分割して別パッケージにするべきですが、説明の関係上全て一緒に書いています。
ヘルスチェックのAPIが起動したタイミングで固定の文字列をトレースとして送るように設定しています。
middlewareはechoが提供してるライブラリを使って入れています。
適切にDIすればアプリケーションがメトリクス用のツールを意識せずに作れますね。

## Kubernetes
API側のPod部分は環境変数を読み込んでるだけなので今回は割愛します。（量も多いので）
今回は最小の構成くらいの想定ですが、以下の要素を組み込みます。
- Pod
- Deployment
- Service（Goから受け付ける必要があるため）
- ConfigMap

```yaml:otel.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-config
  namespace: sample-app
  labels:
    app: otel-collector
data:
  otel-collector-config.yaml: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
    
    processors:
      batch/datadog:
        send_batch_size: 10
        send_batch_max_size: 100
        timeout: 20s

      resource:
        attributes:
          - key: service.name
            action: insert
            value: vote-app
          - key: env
            action: insert
            value: staging

    exporters:
      datadog:
        api:
          key: "CHANGE_API_KEY"
          site: "ap1.datadoghq.com"

    service:
      telemetry:
        logs:
          level: "debug"

      pipelines:
        traces:
          receivers: [otlp]
          processors: [resource, batch/datadog]
          exporters: [datadog]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector
  namespace: sample-app
  labels:
    app: otel-collector
spec:
  replicas: 1
  selector:
    matchLabels:
      app: otel-collector
  template:
    metadata:
      labels:
        app: otel-collector
    spec:
      containers:
      - name: otel-collector
        image: otel/opentelemetry-collector-contrib:latest
        args: ["--config=/etc/otel-collector-config/otel-collector-config.yaml"]
        ports:
        - containerPort: 4317
        volumeMounts:
        - name: otel-collector-config-vol
          mountPath: /etc/otel-collector-config
      volumes:
      - name: otel-collector-config-vol
        configMap:
          name: otel-collector-config
          items:
          - key: otel-collector-config.yaml
            path: otel-collector-config.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: otel-collector
  namespace: sample-app
  labels:
    app: otel-collector
spec:
  selector:
    app: otel-collector
  ports:
    - protocol: TCP
      port: 4317
      targetPort: 4317
```
少し構成としては冗長みもあるかもしれません。サイドカーの構成にした方が良いなどあるでしょうが、
今回はとりあえず早く繋げてみようぜを最優先に進めたのでこのままで行きます。
Datadogの設定箇所については記載しておきます。

### receivers（データの受信）
```yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
```
受信については推奨されていることもありgRPCで接続します。
通信のプロトコルはotlp（OpenTelemetry Protocol）を使用します。
### processors（データの前処理）
```yaml
processors:
  batch/datadog:
    send_batch_size: 10
    send_batch_max_size: 100
    timeout: 20s

  resource:
    attributes:
      - key: service.name
        action: insert
        value: sample-app
      - key: env
        action: insert
        value: test
```
- batch/datadog:
  - 送信設定です
  - send_batch_size：送信する件数を設定。今回は10件ごとで送信するようにしている
  - send_batch_max_size：一回で送信する最大のサイズ。今回は100件を設定
  - timeout：タイムアウトの設定。今回は20秒で設定
- resource
  - service.name：Datadog上の識別するサービス名。
  - env：環境を識別するもの

### exporters（Datadogへの送信）
```yaml
exporters:
  datadog:
    api:
      key: "CHANGE_API_KEY"
      site: "ap1.datadoghq.com"
```
送信設定です。
APIキーを使ってDatadogに直接送信します。

### service（データの流れを定義）
```yaml
service:
  telemetry:
    logs:
      level: "debug"

  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource, batch/datadog]
      exporters: [datadog]
```
- telemetry
    - logs.level：今回は検証を行いながらやっていたのでdebugモードにしています。ノイズになるので運用上は外した方が良さそうです
- pipelines
    - receivers：送信設定を先ほど定義したotlpを使用
    - processors：ここも同様に上で設定したものを使用
    - exporters：同様

# Datadogでの確認
実際にどのように送られたのか確認します。
実は今回の実装で初めてDatadogを触ったこともあり、ここの確認でめちゃくちゃ迷いました。。
![](https://storage.googleapis.com/zenn-user-upload/0b674c666615-20250209.png)

`APM > Traces > Explorer`から確認することができました。

![](https://storage.googleapis.com/zenn-user-upload/cd4a30e9b2fe-20250209.png)

一覧にリクエストされたリストが並んでいます。
詳細のトレースをクリックすると、さらにその中の詳細が確認できました。

![](https://storage.googleapis.com/zenn-user-upload/626c8e0dfbad-20250209.png)

その中に今回検証用に設定していたSpanの情報を確認することができました。

# 終わりに
今回は最小アーキテクチャで検証をしたのでOpenTelemetryの良さを目一杯出すというものではなかったですが、実装自体もベンダーツールに非依存なのも良いなと思います。コレクターの向き先を変えれば
アプリケーションは意識をあまりせずにツールの変更ができます。

本来は複数のマイクロサービスを組み合わせた際にメリットを享受できるはずなので、
ちょっと今度は何個か繋いで試してみようかなと思います。
OpenTelemetryの導入編でした。