OpenTelemetryでコマンドラインツールを観測する

こんにちは。アプリケーションエンジニアの id:lufiabb です。

OpenTelemetryはサーバーアプリケーションの文脈で話題となることが多いと感じていますし、MackerelでもメインとなるユーザーはWebアプリケーションなのは間違いありません。ですがこれ自体はWebアプリケーションに限った技術ではなく、コマンドラインツールを計装するために利用しても便利に使えます。例えば手元に、実行時間の比較的長いコマンドはありませんでしょうか。OSイメージのダウンロード、静的サイトのビルド、何かのデプロイであったりと、少なくとも数秒以上を要するコマンドがあるなら、計装して結果を眺めることでより効率的にできるかもしれません。

前提

コマンドラインツールは、サーバーアプリケーションと異なりテレメトリーデータの送信先バックエンドサービスを限定できません。社内のみで使われるツールであっても、異なるチームが別のオブザーバビリティバックエンドを使っていることはあるでしょうし、広く配布しているなら尚更です。そのためコマンドラインツールにバックエンドサービスや設定変更のオプションが必要となるのですが、OpenTelemetryのSDKはもともと環境変数を使ってテレメトリーデータの送信先を切り替えたり、属性を付与したりできるようになっていますので、独自のオプションを用意するよりも公式の環境変数を使って切り替えをする方が、開発も楽になるしユーザーにとっても分かりやすいでしょう。

以下ではOpenTelemetry Go API and SDKを使って実装例をみていきますが、どの言語でも同じ環境変数を参照できるようになっていると思います。利用可能な環境変数のリスト*1は以下を参照してください。

実装

実装としてはサーバーアプリケーションとそれほど違いはありません。ただしOTLPエクスポーターはデフォルトで localhost:4317 と通信してしまうので、必要なときだけテレメトリーデータを送出するようにしたほうが驚きが少ないでしょう。なのでコマンドラインオプションで -export を与えた場合だけ有効にするための実装を入れていきます。

import (
    "context"
    "flag"
    "os/signal"
)

func main() {
    flag.Parse()

    ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)

    if *exportFlag {
        meterProviderShutdown := registerMeterProvider(ctx)
        defer meterProviderShutdown(ctx)

        tracerProviderShutdown := registerTracerProvider(ctx)
        defer tracerProviderShutdown(ctx)
    }

    ...
}

registerMeterProviderregisterTracerProvider はほぼ同等の関数で、OTLPエクスポーターを初期化しているだけです。エラーハンドリング周りはもっとシンプルな書き方ができるかもしれませんが、あまり時間がなくて調べきれませんでした。

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

// otlpmetricgrpc部分はバックエンドに合わせて切り替えてください
func registerMeterProvider(ctx context.Context) func(context.Context) {
    metricExporter, err := otlpmetricgrpc.New(ctx)
    if err != nil {
        otel.Handle(err)
        return func(context.Context) {}
    }
    meterProvider := sdkmetric.NewMeterProvider(
        sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)),
    )
    otel.SetMeterProvider(meterProvider)
    return func(ctx context.Context) {
        if err := meterProvider.Shutdown(ctx); err != nil {
            otel.Handle(err)
        }
    }
}

// otlptracehttp部分はバックエンドに合わせて切り替えてください
func registerTracerProvider(ctx context.Context) func(context.Context) {
    traceExporter, err := otlptracehttp.New(ctx)
    if err != nil {
        otel.Handle(err)
        return func(context.Context) {}
    }
    tracerProvider := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(traceExporter),
    )
    otel.SetTracerProvider(tracerProvider)
    return func(ctx context.Context) {
        if err := tracerProvider.Shutdown(ctx); err != nil {
            otel.Handle(err)
        }
    }
}

これでOpenTelemetry SDKの準備は終わりです。あとは必要なところでトレースやメトリックを計装しましょう。OpenTelemetry SDKでは各種プロバイダーを設定していない場合「何もしない」プロバイダーが使われるので、-export オプションの有無に関わらず otel.Tracerotel.Meter を使えます。

import (
    "context"
    "log"
    "math/rand/v2"
    "reflect"
    "sync"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel/metric/noop"
    "go.opentelemetry.io/otel/trace"
)

// パッケージ名をreflectで取り出すためのハック
type t struct{}

func main() {
    ...

    tracer := otel.Tracer(reflect.TypeOf(t{}).PkgPath())
    ctx, span := tracer.Start(ctx, "main")
    defer span.End()
    var wg sync.WaitGroup
    for _, url := range []string{"https://www.google.com/", "https://example.com/"} {
        wg.Go(func() {
            fetchData(ctx, url)
        })
    }
    wg.Wait()
}

func fetchData(ctx context.Context, url string) {
    log.Println("fetching", url)
    urlAttr := attribute.String("url", url)

    // トレースの計装
    tracer := otel.Tracer(reflect.TypeOf(t{}).PkgPath())
    ctx, span := tracer.Start(ctx, "fetchData", trace.WithAttributes(urlAttr))
    defer span.End()

    // メトリックの計装
    meter := otel.Meter(reflect.TypeOf(t{}).PkgPath())
    histogram, err := meter.Int64Histogram("data_size")
    if err != nil {
        otel.Handle(err)
        histogram = noop.Int64Histogram{}
    }

    // 以下はダミー処理です、処理内容にはとくに意味はありません
    n := rand.N[time.Duration](100)
    select {
    case <-ctx.Done():
        span.RecordError(ctx.Err(), trace.WithStackTrace(true))
    case <-time.After(n * time.Millisecond):
        histogram.Record(ctx, rand.Int64(), metric.WithAttributes(urlAttr))
    }
}

これをそのまま実行すると、-export がないためテレメトリーデータの送信は行われません。-export を付けるとデフォルトのオブザーバビリティバックエンドへ送られるのですが、デフォルトは http://localhost:4317 または http://localhost:4318 となっているので、環境変数で切り替えてMackerelへ送ってみます。このときAPIキーも必要なので設定します。

MACKEREL_APIKEY=xxx

export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://otlp.mackerelio.com:4317
export OTEL_EXPORTER_OTLP_METRICS_HEADERS="Mackerel-Api-Key=$MACKEREL_APIKEY"
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otlp-vaxila.mackerelio.com/v1/traces
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Mackerel-Api-Key=$MACKEREL_APIKEY"
export OTEL_SERVICE_NAME=service1
export OTEL_RESOURCE_ATTRIBUTES="host.name=$(uname -n),user.name=$USER"

同じURLへ送ることができるバックエンドの場合は OTEL_EXPORTER_OTLP_ENDPOINT 環境変数を使ってまとめて設定できますが、Mackerelでは色々と事情があって分かれているため、トレースとメトリックで送信先を分ける必要があって少し面倒ですね。属性については、なるべく属性名をOpenTelemetry semantic conventionsに合わせておくことを推奨します。

とはいえこれでコマンドラインツールを観測できるようになりました。本番Webアプリケーションに導入するよりも気軽に試せますので、興味があるけどまだ触れていない方がいましたら年末年始などお時間のあるときに試していただけるとうれしいです。

Mackerelのトレース画面でテレメトリーデータを描画している様子です

Mackerelのメトリックエクスプローラーでテレメトリーデータを描画している様子です


この記事は Mackerel Advent Calendar 2025 24日目の記事です。

*1:SDKによってはotlpmetricgrpcのように以下より多くの環境変数をサポートしている場合もあります