概要
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.
実装
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
期待通りトレーシングできています。
その他
サンプルコード
今回のサンプルコードはこちらです。
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}) }
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...) }
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での実装方法を紹介しました。