🔭

OpenTelemetry-Go@v1.36.0 で体験する分散トレース+ログ統合入門

に公開

概要

近年、ソフトウェア開発においてオブザーバビリティ(可観測性)が注目されています。
企業でもオブザーバビリティの実現について注目されることが増えている中、Go言語のOpenTelemetryによる実現については少し前(2024年4月リリースのv1.26.0以前)まではOpenTelemetry Logsが開発中だったため、OpenTelemetryを利用せず監視SaaSが提供するエージェントを利用することが多かったように思います。

しかし、2024年5月リリースのv1.27.0よりOpenTelemetry LogsがBetaに上がり、2025年6月現在もBetaではありますが、十分に業務利用も視野に入るレベルまで開発が進んできました。

すでにいくつもOpenTelemetry-Goの入門記事があるところですが、現在のOpenTelemetry-Goが提供してくれる体験もふまえた入門記事として紹介していきます。

前提

  • Go 1.24.3
  • OpenTelemetry-Go v1.36.0

オブザーバビリティ概要

オブザーバビリティ(可観測性)は、システムの内部状態を外部から把握できる能力を指します。
障害対応やパフォーマンス改善のために注目されています。

OpenTelemetry概要

この記事ではオブザーバビリティの実現手法として、OpenTelemetryでの実現を想定します。
OpenTelemetryについてご存知ではない方向けに、このセクションではOpenTelemetryについての説明を記載します。

OpenTelemetry(以下OTel)は、オープンソースのオブザーバビリティフレームワークであり、ツールキットです。
データの保存やフロントエンドは他のツールに任せており、OTelは測定データを他ツールに送信する部分を担っています。

アプリケーションがメトリクス、ログ、トレースなどの測定データ(シグナル)を発するようにすることを計装と言います。
そして、アプリケーションの計装を手助けするツールがOTelです。

測定データ(シグナル)の種類

ログ

ログはアプリケーションの中で特定のコード行が実行されると出力されるものですが、ログでの監視やトラブルシューティングをしていると以下のような困りごとがあるかと思います。

  • DBの処理失敗ログが出ているが、全てにおいて出ている訳では無さそう。前処理の条件に依存していそうだが、関係性が分からない
  • エラーログが沢山出ているけど、これはどの顧客でのエラーなのか?

上記のような課題が次に説明する分散トレースとの連携によって、以下のように解決します。

  • 分散トレースのスパンにログを含めることで、特定ログの前処理を特定できるようになる
  • 分散トレースのコンテキストに顧客情報を含めることで、ログにも顧客情報が含まれるようになる

分散トレース

分散トレース(以後トレース)は、マイクロサービスなどの複数のサービスで構成されたサービスのリクエストが辿った経路を記録します。
トレースは1つ以上のスパンで構成され、スパンは親子関係を持つことができます。
スパンは下記の図でいうと1つの四角のことです。

https://opentelemetry.io/ja/docs/concepts/observability-primer/ より
(画像は https://opentelemetry.io/ja/docs/concepts/observability-primer/ より)

スパンは構造化ログやメタデータを含めることができます。

メトリクス

メトリクスはアプリケーション内の特定の値を、監視ツールが集計しやすい値として出力します。
活用例としては、httpリクエストのレスポンスステータスの番号ごとに返した回数をメトリクスとして出力する、等があるかと思います。(200がn回、404がn回…など)
メトリクスは簡易的に集計済みの値を出力しているため、ログ等から集計するよりも効率的に数値の集計が出来ます。

Prometheusとの比較

OTelではメトリクスもシグナルとして扱うことが出来ます。

メトリクスといえばPrometheusを利用している方もいるかも知れません。
筆者はメトリクスをPrometheusで出力するかOTelで出力するかはケースバイケースかと思っています。以下のような特徴があるので、システムの性質によって選択するのが良いかと思います。

  • Prometheusの特徴
    • サービスが公開するメトリクスのエンドポイントを巡回し、メトリクスを取得するpull型である
    • サービスはメトリクスを公開することのみに責任があり、サービスに負荷をかけずに計装が可能
    • 共通のPrometheusが複数のサービスのメトリクスを収集している場合、サービスごとの細かい設定をするのが少々面倒
  • OTelでのメトリクス出力の特徴
    • exporter次第だが、サービスからのオブザーバビリティバックエンドへの直接のpush型が採用出来る
    • push型の場合、サービスでメトリクスの出力設定を細かく設定しやすい
    • push型はサービスからオブザーバビリティバックエンド(もしくはOTel Collector)への送信まで責任があるため、サービスに負荷がかかる可能性がある
    • prometheus exporterを利用することでpull型アーキテクチャにも対応可能。この場合OTelのみでpull型に対応でき、依存ライブラリを削減できるメリットがある

ハンズオン

ベースとなるアプリケーションを編集していくことで、OTelの分散トレースとログをアプリケーションに計装していきます。
メトリクスに関しては取り扱いません。

また、ハンズオンを順を追って作業せず、最終的なソースコードに興味がある方は以下のリポジトリをご参照ください。

https://github.com/mirko-san/example-otel/tree/main/example/with-trace-and-log

ベースアプリケーション

ベースとなるアプリケーションを用意します。
以下のファイルを任意のディレクトリに用意します。

go.mod
module example-otel

go 1.24.3
server/main.go
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func getEnv(key, fallback string) string {
	if v, ok := os.LookupEnv(key); ok {
		return v
	}
	return fallback
}

func helloHandler(logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		logger.InfoContext(ctx, "Received request")
		fmt.Fprintf(w, "Hello, World")
	}
}

func errorHandler(logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		logger.InfoContext(ctx, "Received request")
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
	}
}

func httpbinHandler(logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		logger.InfoContext(ctx, "Received request")
		path := r.URL.Path[len("/httpbin"):]
		targetURL := "https://httpbin.org/" + path

		resp, err := otelhttp.Get(r.Context(), targetURL)
		if err != nil {
			http.Error(w, "Failed to fetch data from httpbin", http.StatusInternalServerError)
			return
		}
		defer resp.Body.Close()

		w.WriteHeader(resp.StatusCode)
		io.Copy(w, resp.Body)
	}
}

func main() {
	ctx := context.Background()
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	serverPort := getEnv("EXAMPLE_SERVER_PORT", "3030")

	mux := http.NewServeMux()

	mux.Handle("/hello", helloHandler(logger))
	mux.Handle("/error", errorHandler(logger))
	mux.Handle("/httpbin/", httpbinHandler(logger))
	err := http.ListenAndServe(fmt.Sprintf(":%s", serverPort), mux)
	if err != nil {
		logger.ErrorContext(ctx, err.Error())
	}
}
client/main.go
package main

import (
	"context"
	"flag"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
)

func main() {
	ctx := context.Background()
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	url := flag.String("server", "http://localhost:3030/hello", "server url")
	flag.Parse()

	client := http.Client{Transport: http.DefaultTransport}

	var body []byte
	var statusCode int

	err := func() error {
		req, _ := http.NewRequestWithContext(ctx, "GET", *url, nil)

		logger.InfoContext(ctx, "Sending request...")
		res, err := client.Do(req)
		if err != nil {
			panic(err)
		}
		body, err = io.ReadAll(res.Body)
		_ = res.Body.Close()

		statusCode = res.StatusCode

		return err
	}()
	if err != nil {
		logger.ErrorContext(ctx, err.Error())
	}

	logger.InfoContext(ctx, fmt.Sprintf("Response Received: %s", body))
	logger.InfoContext(ctx, fmt.Sprintf("Response status: %d", statusCode))
}

このアプリケーションはサーバーアプリケーションにクライアントアプリケーションがHTTPでリクエストをするという単純なものです。

アプリケーションを初期化します。

$ go mod tidy

まずは、サーバーを起動します。

$ go run server/main.go

このデフォルトで 3030 ポートで起動しようとするため、もし別のポートを利用したい場合は以下のように環境変数を指定して起動してください。

$ EXAMPLE_SERVER_PORT=<ポート番号> go run server/main.go

次に、Hello, Worldを返すようにリクエストする場合は以下のようにクライアントを実行します。

# 別のプロセスとして実行
$ go run client/main.go

サーバーのポートを変更していた場合は、以下のようにURLを指定して実行してください。

$ go run client/main.go --server http://localhost:<ポート番号>/hello

上記コマンドを実行すると、標準出力に以下のように出力されます。

サーバーを実行しているプロセスでは、以下のように出力されます。

{ "time": "<省略>", "level": "INFO", "msg": "Received request" }

クライアントを実行しているプロセスでは、以下のように出力されます。

{"time":"<省略>","level":"INFO","msg":"Sending request..."}
{"time":"<省略>","level":"INFO","msg":"Response Received: Hello, World"}
{"time":"<省略>","level":"INFO","msg":"Response status: 200"}

分散トレースの計装

ソースコードで利用を始める以下のライブラリを取得します。

$ go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace \
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
	go.opentelemetry.io/otel

サーバー

server/main.goを以下のように編集します。

以下をimport blockに追加します。

	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.32.0"

main関数の上などに、以下の関数を追加します。

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
	exp, err := newTraceExporter(ctx)
	if err != nil {
		return nil, err
	}

	resource := resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceName("example-otel/server"),
	)

	bsp := sdktrace.NewSimpleSpanProcessor(exp)

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithSpanProcessor(bsp),
		sdktrace.WithResource(resource),
	)
	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
	return tp, nil
}

func newTraceExporter(ctx context.Context) (sdktrace.SpanExporter, error) {
	return stdouttrace.New(
		stdouttrace.WithPrettyPrint(),
	)
}

main関数のloggerの定義の下に以下を追加します。

	tp, err := initTracer(ctx)
	if err != nil {
		logger.ErrorContext(ctx, fmt.Sprintf("error setting up OTel Trace SDK - %e", err))
	}
	defer func() {
		if err := tp.Shutdown(context.Background()); err != nil {
			logger.Error(fmt.Sprintf("Error shutting down tracer provider: %e", err))
		}
	}()

httpハンドラーの実装箇所を以下のように変更します。

	mux.Handle("/hello", otelhttp.NewHandler(http.HandlerFunc(helloHandler(logger)), "hello"))
	mux.Handle("/error", otelhttp.NewHandler(http.HandlerFunc(errorHandler(logger)), "error"))
	mux.Handle("/httpbin/", otelhttp.NewHandler(http.HandlerFunc(httpbinHandler(logger)), "httpbin"))
	// err := を = に
	err = http.ListenAndServe(fmt.Sprintf(":%s", serverPort), mux)

クライアント

client/main.goを以下のように編集します。

以下をimport blockに追加します。

	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.32.0"

main関数の上などに、以下の関数を追加します。

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
	exp, err := newTraceExporter(ctx)
	if err != nil {
		return nil, err
	}

	resource := resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceName("example-otel/client"),
	)

	bsp := sdktrace.NewSimpleSpanProcessor(exp)

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithSpanProcessor(bsp),
		sdktrace.WithResource(resource),
	)
	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
	return tp, nil
}

func newTraceExporter(ctx context.Context) (sdktrace.SpanExporter, error) {
	return stdouttrace.New(
		stdouttrace.WithPrettyPrint(),
	)
}

main関数のloggerの定義の下に以下を追加します。

	tp, err := initTracer(ctx)
	if err != nil {
		logger.ErrorContext(ctx, fmt.Sprintf("error setting up OTel Trace SDK - %e", err))
	}
	defer func() {
		if err := tp.Shutdown(context.Background()); err != nil {
			logger.Error(fmt.Sprintf("Error shutting down tracer provider: %e", err))
		}
	}()

クライアントの定義を以下のように変更します。

	client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

リクエストを実行している関数のエラーの定義を変更します。

	// err := を = に
	err = func() error {
		req, _ := http.NewRequestWithContext(ctx, "GET", *url, nil)
		...
	}

動作確認

サーバープロセスを再実行します。

$ go run server/main.go

別のプロセスとしてクライアントを実行します。

$ go run client/main.go

サーバーの標準出力に以下のようなjsonが出力されていることを確認します。(代表的なアトリビュートのみ抜粋)

{
	"Name": "hello",
	"SpanContext": {
		"TraceID": "e88d666bd9fb09de543d0004ecb426d4",
		"SpanID": "435ad3539a848e63",
		"TraceFlags": "01",
		"TraceState": "",
		"Remote": false
	},
	"Parent": {
		"TraceID": "e88d666bd9fb09de543d0004ecb426d4",
		"SpanID": "ff2ac65beb1595f1",
		...
	},
	"Resource": [
		{
			"Key": "service.name",
			"Value": {
				"Type": "STRING",
				"Value": "example-otel/server"
			}
		}
	],
	"Attributes": [
		{
			"Key": "server.address",
			"Value": {
				"Type": "STRING",
				"Value": "localhost"
			}
		},
		{
			"Key": "http.request.method",
			"Value": {
				"Type": "STRING",
				"Value": "GET"
			}
		},
		{
			"Key": "url.scheme",
			"Value": {
				"Type": "STRING",
				"Value": "http"
			}
		},
		{
			"Key": "server.port",
			"Value": {
				"Type": "INT64",
				"Value": 3030
			}
		},
		...
	],
	...
}

クライアントの標準出力に以下のようなjsonが出力されていることを確認します。(代表的なアトリビュートのみ抜粋)

{
	"Name": "HTTP GET",
	"SpanContext": {
		"TraceID": "e88d666bd9fb09de543d0004ecb426d4",
		"SpanID": "ff2ac65beb1595f1",
		"TraceFlags": "01",
		"TraceState": "",
		"Remote": false
	},
	"Parent": {
		"TraceID": "00000000000000000000000000000000",
		"SpanID": "0000000000000000",
		...
	},
	"Resource": [
		{
			"Key": "service.name",
			"Value": {
				"Type": "STRING",
				"Value": "example-otel/client"
			}
		}
	],
	"Attributes": [
		{
			"Key": "http.request.method",
			"Value": {
				"Type": "STRING",
				"Value": "GET"
			}
		},
		{
			"Key": "url.full",
			"Value": {
				"Type": "STRING",
				"Value": "http://localhost:3030/hello"
			}
		},
		{
			"Key": "server.address",
			"Value": {
				"Type": "STRING",
				"Value": "localhost"
			}
		},
		{
			"Key": "server.port",
			"Value": {
				"Type": "INT64",
				"Value": 3030
			}
		},
		{
			"Key": "network.protocol.version",
			"Value": {
				"Type": "STRING",
				"Value": "1.1"
			}
		},
		{
			"Key": "http.response.status_code",
			"Value": {
				"Type": "INT64",
				"Value": 200
			}
		}
	],
	...
}

このjson中で特徴的なのが、以下のアトリビュートです。

  • SpanContext
  • Parent
  • Resource
  • Attributes

SpanContext, ParentにはTraceID,SpanIDが含まれています。
TraceIDはトレースに共通して振られるユニークIDです。例示したjsonでもTraceIDは共通しています。
SpanIDは「測定データ(シグナル)の種類」の「分散トレース」セクションに記載した通り、トレースを構成する細かな処理について振られたIDです。スパンには親子関係があるので、Parentアトリビュートには親のスパンの情報を格納しています。例示したjsonでもサーバーのトレースのParentアトリビュートにクライアントのスパンの情報が格納されていることが分かると思います。

Resourceにはトレースを出力しているリソースについての情報が含まれています。このあたりの情報はinitTracer関数のリソース定義の箇所で定義した情報が格納されます。

Attributesはスパンに独自に付与した属性が含まれます。今回のサンプル実装ではotelhttpライブラリが付与しているものです。
たとえばAttributesに顧客IDを入れておくと、http.response.status_codeアトリビュートを集計してエラーが増えているのを見つけたとき、さらに顧客IDで絞り込むことで「特定の顧客のみエラーが出ているのか?」等の分析ができます。

ログの計装

ソースコードで利用を始める以下のライブラリを取得します。

$ go get go.opentelemetry.io/contrib/bridges/otelslog \
	go.opentelemetry.io/otel/exporters/stdout/stdoutlog \
	go.opentelemetry.io/otel/sdk/log

サーバー

server/main.goを以下のように編集します。

以下をimport blockに追加します。

	"go.opentelemetry.io/contrib/bridges/otelslog"
	"go.opentelemetry.io/otel/exporters/stdout/stdoutlog"
	sdklog "go.opentelemetry.io/otel/sdk/log"

main関数の上などに、以下の関数を追加します。

func initLogger(ctx context.Context) (*slog.Logger, error) {
	exp, err := newLogExporter()
	if err != nil {
		return nil, err
	}

	resource := resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceName("example-otel/server"),
	)

	lp := sdklog.NewLoggerProvider(
		sdklog.WithResource(resource),
		sdklog.WithProcessor(sdklog.NewSimpleProcessor(exp)),
	)

	logger := otelslog.NewLogger("example-otel/server", otelslog.WithLoggerProvider(lp))
	return logger, nil
}

func newLogExporter() (sdklog.Exporter, error) {
	return stdoutlog.New(
		stdoutlog.WithWriter(os.Stdout),
		stdoutlog.WithPrettyPrint(),
	)
}

main関数のloggerの定義を以下のように変更します。

	logger, err := initLogger(ctx)
	if err != nil {
		panic(fmt.Sprintf("error setting up OTel Log SDK - %v", err))
	}

クライアント

client/main.goを以下のように編集します。

以下をimport blockに追加します。

	"go.opentelemetry.io/contrib/bridges/otelslog"
	"go.opentelemetry.io/otel/exporters/stdout/stdoutlog"
	sdklog "go.opentelemetry.io/otel/sdk/log"

main関数の上などに、以下の関数を追加します。

func initLogger(ctx context.Context) (*slog.Logger, error) {
	exp, err := newLogExporter()
	if err != nil {
		return nil, err
	}

	resource := resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceName("example-otel/client"),
	)

	lp := sdklog.NewLoggerProvider(
		sdklog.WithResource(resource),
		sdklog.WithProcessor(sdklog.NewSimpleProcessor(exp)),
	)

	logger := otelslog.NewLogger("example-otel/client", otelslog.WithLoggerProvider(lp))
	return logger, nil
}

func newLogExporter() (sdklog.Exporter, error) {
	return stdoutlog.New(
		stdoutlog.WithWriter(os.Stdout),
		stdoutlog.WithPrettyPrint(),
	)
}

main関数のloggerの定義を以下のように変更します。

	logger, err := initLogger(ctx)
	if err != nil {
		panic(fmt.Sprintf("error setting up OTel Log SDK - %v", err))
	}

動作確認

サーバープロセスを再実行します。

$ go run server/main.go

別のプロセスとしてクライアントを実行します。

$ go run client/main.go

サーバーの標準出力に以下のようなjsonが出力されていることを確認します。(代表的なアトリビュートのみ抜粋)

{
	"Timestamp": "<省略>",
	"SeverityText": "INFO",
	"Body": {
		"Type": "String",
		"Value": "Received request"
	},
	"TraceID": "284ba669ce9a46bd804084d29cdf033c",
	"SpanID": "3bb452a60dd5ff7a",
	"Resource": [
		{
			"Key": "service.name",
			"Value": {
				"Type": "STRING",
				"Value": "example-otel/server"
			}
		}
	],
	"Attributes": [],
	...
}

クライアントの標準出力に以下のようなjsonが出力されていることを確認します。(代表的なアトリビュートのみ抜粋)

{
	"Timestamp": "<省略>",
	"SeverityText": "INFO",
	"Body": {
		"Type": "String",
		"Value": "Sending request..."
	},
	"TraceID": "00000000000000000000000000000000",
	"SpanID": "0000000000000000",
	"Resource": [
		{
			"Key": "service.name",
			"Value": {
				"Type": "STRING",
				"Value": "example-otel/client"
			}
		}
	],
	"Attributes": [],
	...
}

この内容を見てみると、元々のslogの内容で記載されていた「時刻」「ログレベル」「メッセージ」の要素に追加して、トレースと同様にTraceID,SpanID,Resource,Attributesが追加されていることが分かります。

さらに、TraceIDに着目してみると、クライアントのSending request...のログがある時点ではhttpクライアントの処理が発生していないため、何もトレースがなく、ログにもTraceIDが記載されていません。
ですが、サーバーのReceived requestログが出力されている地点ではクライアントのトレースが開始されているため、TraceIDSpanIDが記載されています。

このように、トレースとログにOTelを導入することで、共通のTraceIDSpanIDが相互に紐づくことになります。

可視化してみる

この記事の冒頭で、以下のように記載しました。

OpenTelemetry(以下OTel)は、オープンソースのオブザーバビリティフレームワークであり、ツールキットです。
データの保存やフロントエンドは他のツールに任せており、OTelは測定データを他ツールに送信する部分を担っています。

実際にOTelを利用する際は、可視化用のSaaSを利用する方が多いかと思います。
(Datadog, Grafana, New Relic, Splunk など…)

最後に、実際の利用感に近い、可視化SaaSを利用した場合の体験をご紹介しようと思います。

今回は、honeycombを利用して可視化をしてみます。

また、ハンズオンセクションで作業していないが、手元でソースコードを編集して監視SaaSに送信してみたい方は以下のリポジトリをご参照ください。
「測定データをSaaSに送信できるようにする」セクションの内容も実施済みなので、監視SaaSの認証情報を環境変数で指定すれば動作するはずです。

https://github.com/mirko-san/example-otel/tree/main/example/with-trace-and-log

測定データをSaaSに送信できるようにする

ソースコードの実装レベルで標準出力に出力してしまっているため、修正が必要です。
ソースコードで利用を始める以下のライブラリを取得します。

$ go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp

server/main.goclient/main.goの両方に以下の編集をしていきます。

以下をimport blockに追加します。

	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"

以下をimport blockから削除します。

	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/exporters/stdout/stdoutlog"

クライアントの場合のみ、以下をimport blockから削除します。

	"os"

newTraceExporter関数を以下のように編集します。

func newTraceExporter(ctx context.Context) (sdktrace.SpanExporter, error) {
	return otlptracehttp.New(ctx)
}

newTraceExporter関数を以下のように編集します。

func newLogExporter(ctx context.Context) (sdklog.Exporter, error) {
	return otlploghttp.New(ctx)
}

また、上記にあわせてnewLogExporterを呼び出している箇所を以下のように修正します。

	exp, err := newLogExporter(ctx)

認証情報を登録する

上記のようにOTel SDKで実装している場合、以下のような環境変数を設定することで送信先や認証情報の登録ができます。

$ export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.honeycomb.io:443"
export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=your-api-key"

OTEL_EXPORTER_OTLP_HEADERSの値はSaaSによってさまざまなので、以下のような案内ドキュメントを見て設定してください。

https://docs.honeycomb.io/get-started/start-building/application/traces/#configure-the-opentelemetry-sdk

上記までの手順を実行して、プロセスを再起動するとトレースとログがSaaSに送信されるようになります。

ブラウザで見てみる

honeycombでの集計をお見せしますが、他のSaaSでも同様の体験はできるかと思います。

トレースの閲覧

クエリ画面でデータの全体から個別のデータを取得します。
今回はデータが少ないので、Run Queryを押し、トレースタブからトレースを探します。

クエリ画面

上図のTrace IDをクリックすることで、トレースの詳細画面を開きます。
「OpenTelemetry 概要」セクションの分散トレースのところで見たようなスパンが見えますね。

トレースの詳細画面

スパンをクリックしてみると、イベントとしてログが紐づいているので、ログを辿ることができます。

トレースの詳細画面

ログの閲覧

トレースに紐づいていないログはログ画面から閲覧が出来ます。

サービス画面

ハンズオンのログの計装の中で、Sending request...のログはTraceIDが記載されていないためトレース画面からは見れませんが、独立したログとしてクエリすることが出来ます。

または、クエリとして以下のような条件でクエリするとログを見つけることが出来ます。

  • WHERE
    • service.name = example-otel/client
    • meta.signal_type = log
  • GROUP BY
    • body

クエリ画面

まとめ

本記事では、OpenTelemetry-Goを用いた分散トレースとログの統合方法をハンズオン形式で紹介しました。
トレースとログを一元的に管理することで、障害調査やサービスの可観測性が大きく向上することが想像ついたかと思います。

また、これまで監視SaaSのエージェントを利用するケースが多かったかも知れないですが、OpenTelemetryのログ機能も十分に実用的になってきていることが分かったかと思います。
今後はエージェント以外の選択肢としてOpenTelemetryの導入もおすすめです。ぜひ検討してみてください。

GitHubで編集を提案

Discussion