Carpe Diem

備忘録

OpenTelemetryのTraceをGoで試してみる

概要

OpenTelemetryはObservabilityの三本柱であるLogs、Metrics、Tracesをベンダーに依存せず実装するための仕様&実装です。

今回はそのうちのTracesをGoで試してみます。

環境

  • Go v1.20.2
  • go.opentelemetry.io/otel/trace v1.14.0

前提知識

登場人物

OpenTelemetryでは以下の要素があります。

登場人物 役割
Tracer Provider Tracerを提供する。またトレース情報のExport先を管理したり、トレースに共通で付けたい属性データを管理する
Tracer Spanを生成する
Span 各操作におけるトレース情報
Trace Exporter トレース情報の出力先。標準出力だったり、Jaegerのような分散トレース可視化ツールなど
Trace Context Spanの関連付け(Child of, Follows from)をする上で伝播させる必要のあるデータ

Spanは以下の図が分かりやすいです。

ref: Understand distributed tracing | Lightstep Observability Learning Portal

Contextは以下の図が分かりやすいです。

ref: Understand distributed tracing | Lightstep Observability Learning Portal

また仕様上、TracerProvider, Tracer, Spanのメソッドは全てgoroutine-safeです。

TracerProvider - all methods are safe to be called concurrently.

Tracer - all methods are safe to be called concurrently.

Span - All methods of Span are safe to be called concurrently.

https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#concurrency

実装

Goでの具体的な実装方法を説明します。

準備

TraceProvider

まずはTraceProviderを用意します。

func NewTracerProvider(serviceName string) (*sdktrace.TracerProvider, func(), error) {
        exporter, err := NewJaegerExporter()
        if err != nil {
                return nil, nil, err
        }

        r := NewResource(serviceName, "1.0.0", "local")
        tp := sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exporter),
                sdktrace.WithResource(r),
                sdktrace.WithSampler(sdktrace.AlwaysSample()),
        )

        otel.SetTracerProvider(tp)

        cleanup := func() {
                ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
                if err := tp.ForceFlush(ctx); err != nil {
                        log.Print(err)
                }
                ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
                if err := tp.Shutdown(ctx2); err != nil {
                        log.Print(err)
                }
                cancel()
                cancel2()
        }
        return tp, cleanup, nil
}

func NewResource(serviceName string, version string, environment string) *resource.Resource {
        return resource.NewWithAttributes(
                semconv.SchemaURL,
                semconv.ServiceNameKey.String(serviceName),
                semconv.ServiceVersionKey.String(version),
                attribute.String("environment", environment),
        )
}

func NewJaegerExporter() (sdktrace.SpanExporter, error) {
        // Port details: https://www.jaegertracing.io/docs/getting-started/
        endpoint := os.Getenv("EXPORTER_ENDPOINT")

        exporter, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(endpoint)))
        if err != nil {
                return nil, err
        }
        return exporter, nil
}

ポイント

  • Exporterを用意してsdktrace.WithBatcher()で設定
  • Spanにつける共通の属性情報をsdktrace.WithResource()で設定
  • サンプリングをsdktrace.WithSampler()で設定
  • otel.SetTracerProvider()でグローバルなTracerProviderを設定
    • otel.Tracer()する時に↑のTracerProviderにどこからでもアクセスできるようになる
  • 終了する前にForceFlush()で残っているSpanを全てExporterに吐き出す
  • 終了する前にShutdown()でTracerProviderをリソースリークしないように終了させる

関数にSpanの埋め込み

TracerをTracerProviderから用意し、

tracer = otel.Tracer("github.com/jun06t/otel-sample/simple/main")

処理の初めにtracer.Start()してSpanを生成し、終わりにspan.End()します。

func Operation1(ctx context.Context) {
        _, span := tracer.Start(ctx, "op1")
        defer span.End()
        time.Sleep(100 * time.Millisecond)
}

func Operation2(ctx context.Context) {
        _, span := tracer.Start(ctx, "op2")
        defer span.End()
        time.Sleep(100 * time.Millisecond)
}

http client

http clientのTransportをotelhttpのものにすると自動的にトレースしてくれます。

func newHandler() *handler {
        hc := http.Client{
                Transport: otelhttp.NewTransport(http.DefaultTransport),
        }
        return &handler{
                cli: hc,
        }
}

http server

http serverもotelhttp.NewHandler()というミドルウェアが提供されているのでそれを使います。

mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(h.alive))
mux.Handle("/hello", http.HandlerFunc(h.hello))
http.ListenAndServe(":8000", otelhttp.NewHandler(mux, "server",
        otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
))

動作確認

Export先のJaegerをdocker-composeで立ち上げておきます。

$ docker-compose up

リクエストを投げてみます。

$ curl localhost:8000/hello

期待通りトレーシングできています。

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

TracerはTracerProviderの設定より先に呼び出しても良い

若干違和感があったのですが、よくある実装例だとグローバルでtracerを先に代入しており、その後でTracerProviderを呼び出したりします。

var tracer = tp.Tracer("ExampleService")  // 先に代入

func main() {
    ctx := context.Background()
    exp, err := newExporter(ctx)
    if err != nil {
        log.Fatalf("failed to initialize exporter: %v", err)
    }

    tp := newTraceProvider(exp)
    defer func() { _ = tp.Shutdown(ctx) }()

    otel.SetTracerProvider(tp) // ここでセット
}

これだと先に呼び出したtracerは別のオブジェクトになるのでは?と思ったのですが、内部で後からセットしたTracerProviderに委譲するロジックが入っているので問題ありませんでした。

func SetTracerProvider(tp trace.TracerProvider) {
    current := TracerProvider()

    if _, cOk := current.(*tracerProvider); cOk {
        if _, tpOk := tp.(*tracerProvider); tpOk && current == tp {
            // Do not assign the default delegating TracerProvider to delegate
            // to itself.
            Error(
                errors.New("no delegate configured in tracer provider"),
                "Setting tracer provider to it's current value. No delegate will be configured",
            )
            return
        }
    }

    delegateTraceOnce.Do(func() {
        if def, ok := current.(*tracerProvider); ok {
            def.setDelegate(tp)
        }
    })
    globalTracer.Store(tracerProviderHolder{tp: tp})
}

https://github.com/open-telemetry/opentelemetry-go/blob/3a40e65a38db12d8d1b1a313ff78b27452b2accc/internal/global/state.go#L57-L78

func (p *tracerProvider) setDelegate(provider trace.TracerProvider) {
    p.mtx.Lock()
    defer p.mtx.Unlock()

    p.delegate = provider

    if len(p.tracers) == 0 {
        return
    }

    for _, t := range p.tracers {
        t.setDelegate(provider)
    }

    p.tracers = nil
}

// Tracer implements TracerProvider.
func (p *tracerProvider) Tracer(name string, opts ...trace.TracerOption) trace.Tracer {
    p.mtx.Lock()
    defer p.mtx.Unlock()

    if p.delegate != nil {
        return p.delegate.Tracer(name, opts...)
    }

https://github.com/open-telemetry/opentelemetry-go/blob/3a40e65a38db12d8d1b1a313ff78b27452b2accc/internal/global/trace.go#L65-L89

otelhttpでSpanが親子関係になる

地味にハマったのですが以下のような状態です。

otelhttpを使う場合、bodyをClose()するタイミングでSpanがEndになるため、defer resp.Body.Close()の後ろに処理が挟まっていると終了タイミングがおかしくなってしまいます。

NG

        resp, err := h.cli.Do(hreq)
        if err != nil {
                log.Fatal(err)
        }
        // span ends after resp.Body.Close.
        defer resp.Body.Close()

        Operation3(r.Context())
}

なので関数自体を切り出すか、次の処理の前にCloseさせましょう。

まとめ

OpenTelemetryのGoでの実装方法を紹介しました。

参考