💭

OpenTelemetry Collectorを使ってDatadogにカスタムメトリクスを送信する

2024/08/28に公開

はじめに

株式会社CastingONEでソフトウェアエンジニアをしている @takashabe です。普段はHR領域のSaaSをGoで書いています。

弊社ではモニタリング基盤としてDatadogを採用しており、インフラやアプリケーションのメトリクスを収集しています。
また特定のSLIを設定するためにカスタムメトリクスをDatadogに送信することがあります。

従来はアプリケーション内で直接Datadog APIを叩いてカスタムメトリクスを送信していましたが、それをOpenTelemetry Collectorを使って送信するように変更しようとしています。
このエントリでは、OpenTelemetry Collectorを使ってDatadogにカスタムメトリクスを送信する方法について紹介します。

またサンプルコードは以下にあります。

https://github.com/takashabe/otel-collector-example

OpenTelemetry Collectorとは

OpenTelemetry Collectorは、OpenTelemetryの一部として提供されており、複数のデータソースからテレメトリデータを収集、加工、そしてDatadogなどのバックエンドサービスにエクスポートするためのサービスです。

OpenTelemetry Collectorの概要図

https://opentelemetry.io/docs/collector/ より引用

OpenTelemetry Collectorを使うことで、アプリケーションから直接特定のバックエンドサービスに依存することなく、テレメトリデータを収集することができます。

トレースの話になりますが、我々のシステムでもGoogle Cloud上の実行環境ではCloud Trace、ローカルではJaegerにトレースデータを送信するようにしています。
これをアプリケーションで処理していたものをOpenTelemetry Collectorに集約することで、アプリケーションでは常にOpenTelemetry Collectorに送信するだけで良くなり、コードをシンプルに保つことが期待できます。

カスタムメトリクスを送信する

ひとくちにカスタムメトリクスと言っても、Datadogにはいくつかのメトリクスタイプがあります。
OpenTelemetryとDatadogのメトリクスタイプの対応は以下を参照してください。

https://docs.datadoghq.com/metrics/open_telemetry/otlp_metric_types/

ここでは、我々がSLIを設定するために利用している gauge メトリクスを送信する方法について紹介します。
またアプリケーションからメトリクスを送るのはotel-goを使うことを想定します。

https://github.com/open-telemetry/opentelemetry-go

OpenTelemetry Collectorの設定

まずはアプリケーションから送信されたメトリクスを受け取るOpenTelemetry Collectorを起動しておきましょう。
いつものcompose.yamlと、OpenTelemetry Collector用のotel-collector-config.yamlを用意します。

compose.yaml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.102.0
    restart: always
    command: ["--config=/etc/otel-collector-config.yaml", ""]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - 4317:4317 # OTLP gRPC receiver
      - 4318:4318 # OTLP http receiver

otel-collector-config.yamlは以下のようなイメージですが、datadogの場合はsiteによってエンドポイントが異なるので注意してください。

otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

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

service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [datadog]

見ての通り、pipelineは複数設定できるようになっており、それぞれ柔軟にreceiversやprocessors、exportersを組み合わせることができます。
特に file exporter はデバッグにも便利なので、開発中はよく /dev/stdout に向けて使っています。

https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/fileexporter/README.md

otel-goからgaugeメトリクスを送信する

次にアプリケーションからotel-goを使ってgaugeメトリクスをOpenTelemetry Collectorに送信していきましょう。

まずは定型的ですがproviderの初期化を行います。ここでotelで定義されているサービス名やバージョンなどのリソースを設定しています。

main.go
package main

import (
	"context"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
)

...

type expoter struct {
	provider *sdkmetric.MeterProvider
}

func initProvider() (*expoter, error) {
	ctx := context.Background()

	resource, err := resource.Merge(resource.Default(),
		resource.NewWithAttributes(semconv.SchemaURL,
			semconv.ServiceName("my-service"),
			semconv.ServiceVersion("0.1.0"),
		))
	if err != nil {
		return nil, err
	}

	exp, err := otlpmetricgrpc.New(ctx,
		otlpmetricgrpc.WithInsecure(),
		otlpmetricgrpc.WithEndpoint("localhost:4317"),
	)
	if err != nil {
		return nil, err
	}

	meterProvider := sdkmetric.NewMeterProvider(
		sdkmetric.WithReader(
			sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(time.Hour)),
		),
		sdkmetric.WithResource(resource),
	)
	otel.SetMeterProvider(meterProvider)

	return &expoter{
		provider: meterProvider,
	}, nil
}

次に初期化したproviderを使って、実際にメトリクスを送信する部分です。サンプルとして何らかの配信処理の遅延時間を送信する例を示します。

テレメトリを受信するバックエンドサービス側(datadogなど)で適切にハンドリングできるように、タグを付与しています。
ここでは delivery_idenv の2つのタグを使っています。

main.go
func main() {
	provider, err := initProvider()
	if err != nil {
		panic(err)
	}

	metrics := []delayMetric{
		{
			Name:  "delivery.delay",
			Value: float64(5 * time.Second),
			Tags: []Tag{
				{
					Name:  "delivery_id",
					Value: "1",
				},
				{
					Name:  "env",
					Value: "prod",
				},
			},
		},
		{
			Name:  "delivery.delay",
			Value: float64(10 * time.Second),
			Tags: []Tag{
				{
					Name:  "delivery_id",
					Value: "2",
				},
				{
					Name:  "env",
					Value: "prod",
				},
			},
		},
	}
	if err := provider.Gauge(context.Background(), metrics); err != nil {
		panic(err)
	}
}

type delayMetric struct {
	Name  string
	Value float64
	Tags  []Tag
}

type Tag struct {
	Name  string
	Value string
}

func (r *expoter) Gauge(ctx context.Context, metrics []delayMetric) error {
	for _, m := range metrics {
		var attrs []attribute.KeyValue
		for _, t := range m.Tags {
			attrs = append(attrs, attribute.String(t.Name, t.Value))
		}

		meter := r.provider.Meter(m.Name)
		gauge, err := meter.Float64Gauge(m.Name)
		if err != nil {
			return err
		}
		gauge.Record(ctx, m.Value, metric.WithAttributes(attrs...))
	}
	if err := r.provider.ForceFlush(ctx); err != nil {
		return err
	}

	return nil
}

ここまでやると、OpenTelemetry Collectorにメトリクスが送信され、Datadogにも反映されるはずです。
Datadog上でこんな感じのメトリクスが見えるはずです。

Datadogのメトリクス

otel、特にメトリクスはトレースに比べて関連するコンポーネントが多いので、全体像を把握するまでは少しややこしい印象ですが、シンプルな値を送信するだけであればシュッと書けると思います。

従来はサンプルコードに載せたような同期的なgaugeメトリクスはサポートされておらず、コールバック的に書く非同期版で送信する必要がありましたが、最近otel-goでも同期的なgaugeメトリクスがサポートされ、Datadogのgaugeメトリクスとの親和性も上がりました。
またCloud Runではゼロスケールしてくれるため、定期的に起動してgaugeメトリクスを取得するようなアプリケーションとの相性も良いです。

https://github.com/open-telemetry/opentelemetry-go/pull/5304

もしotel-goでのメトリクス実装に移行しようとして困っていた場合は、最新動向を確認してみると良いかもしれません。

まとめ

OpenTelemetry Collectorを使ってDatadogにgaugeのカスタムメトリクスを送信する方法について紹介しました。
Collector自身も活発に開発が進んでおり、ProcessorやConnectorなどデータをこねくり回すことも柔軟にできるので、導入しておくと便利なことが多いと思います。

Discussion