エンドツーエンドAPI可視化のための Go OpenTelemetry トレーシング
GoでのOpenTelemetryトレーシングを、HTTPリクエスト、バックグラウンドジョブ、外部呼び出しにわたってトレース、メトリクス、ログを相関させるための実践的な手順とともに解説します。

Go APIにおけるエンドツーエンドトレーシングの意味
トレースは、リクエストがシステムを通過する間のタイムラインです。API呼び出しが到着したときに始まり、レスポンスを送信したときに終わります。
トレースの内側にはスパンがあります。スパンは「リクエスト解析」「SQL実行」「決済プロバイダ呼び出し」のような、1つの計測されたステップです。スパンにはHTTPステータスコード、識別可能だが安全なユーザーID、クエリが返した行数などの有用な情報を付与できます。
「エンドツーエンド」とは、トレースが最初のハンドラで止まらないことを意味します。ミドルウェア、データベースクエリ、キャッシュ呼び出し、バックグラウンドジョブ、サードパーティAPI(決済、メール、地図)、その他の内部サービスといった、問題が隠れがちな箇所までリクエストを追います。
トレーシングが最も役に立つのは、問題が断続的に発生する場合です。200回に1回だけ遅いリクエストがあるとき、ログは高速ケースと遅延ケースでほとんど同じに見えがちです。トレースがあれば違いは一目瞭然です:あるリクエストは外部呼び出しで800ms待ち、2回リトライし、その後フォローアップジョブを起動していました。
ログもサービス間でつなぐのが難しいことがあります。APIに1行、ワーカーに別の1行があり、その間に何もないことがよくあります。トレースがあれば、これらのイベントは同じtrace IDを共有するので、推測せずにチェーンをたどれます。
トレース、メトリクス、ログ:役割の違い
トレース、メトリクス、ログはそれぞれ別の疑問に答えます。
トレースは1つの実際のリクエストで何が起きたかを示します。ハンドラ、データベース呼び出し、キャッシュ参照、サードパーティへのリクエストにわたってどこに時間が使われたかを教えてくれます。
メトリクスは傾向を示します。アラートに最適で、集計が安定して安価です:レイテンシのパーセンタイル、リクエストレート、エラー率、キューの深さ、飽和度など。
ログはプレーンテキストで「なぜ」を説明します:バリデーションの失敗、予期しない入力、エッジケース、コードが下した決定など。
本当の利点は相関です。同じtrace IDがスパンと構造化ログの両方に現れると、エラーログから正確なトレースにジャンプして、どの依存先が遅くなったか、どのステップが失敗したかをすぐに見られます。
シンプルなメンタルモデル
各信号を得意な用途で使い分けます:
- メトリクスは何かがおかしいことを知らせます。
- トレースは1つのリクエストでどこに時間が使われたかを示します。
- ログはコードが何を決め、なぜそうしたかを説明します。
例:POST /checkout エンドポイントがタイムアウトし始めたとします。メトリクスはp95のレイテンシが上昇していることを示します。トレースを見れば大部分の時間が決済プロバイダ呼び出しに費やされていることがわかり、対応するログの相関行に502によるリトライが記録されていれば、バックオフ設定や上流のインシデントを疑えます。
コードを追加する前に:命名、サンプリング、追跡する項目
事前に少し計画しておくと、後でトレースを検索しやすくなります。計画がないとデータは集まりますが、基本的な問いが難しくなります:「これはステージングですか本番ですか?」「どのサービスが問題を始めたのですか?」
まず一貫した識別を決めてください。各Go APIに明確な service.name を付け(例えば checkout-api)、deployment.environment=dev|staging|prod のような単一の環境フィールドを持たせます。これらを安定させてください。週の途中で名前が変わると、チャートや検索が別のシステムのように見えます。
次にサンプリングを決めます。開発では全リクエストをトレースするのが良いですが、本番ではコストが高すぎることが多いです。一般的な方法は、通常のトラフィックの小さな割合をサンプリングし、エラーや遅いリクエストは常に保持することです。ヘルスチェックやポーリングのような高トラフィックなエンドポイントは、トレース頻度を下げるか無視するとよいでしょう。
最後に、スパンに付けるタグと絶対に収集しないものを合意してください。サービス間でイベントをつなぐのに役立つ属性の簡潔な許可リストを作り、シンプルなプライバシールールを定めます。
良いタグは通常、安定したIDや粗いリクエスト情報(ルートテンプレート、メソッド、ステータスコード)を含みます。機密ペイロードは完全に避けてください:パスワード、決済データ、メール全体、認証トークン、リクエスト本体の生データなど。ユーザー関連の値を含める必要がある場合は、追加する前にハッシュ化するかマスキングしてください。
ステップバイステップ:Go HTTP APIにOpenTelemetryトレーシングを追加する
起動時に一度だけトレーサープロバイダを設定します。これがスパンの行き先と、すべてのスパンに付与されるリソース属性を決めます。
1) OpenTelemetryを初期化する
service.name を設定していることを必ず確認してください。これがないと、異なるサービスのトレースが混ざってチャートが読みづらくなります。
// main.go (startup)
exp, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())
res, _ := resource.New(context.Background(),
resource.WithAttributes(
semconv.ServiceName("checkout-api"),
),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
これがGoのOpenTelemetryトレーシングの基礎です。次に、受信した各リクエストに対してスパンを作成する必要があります。
2) HTTPミドルウェアを追加し、重要なフィールドを取得する
自動でスパンを開始し、ステータスコードと所要時間を記録するHTTPミドルウェアを使いましょう。スパン名は生のURLではなくルートテンプレート(/users/:id のような)を使って設定してください。そうしないとユニークなパスが大量に生まれてしまいます。
目指すべきベースラインはシンプルです:リクエストごとに1つのサーバースパン、ルートベースのスパン名、HTTPステータスのキャプチャ、ハンドラの失敗をスパンのエラーとして反映、トレースビューアで見える所要時間。
3) 障害を明示的にする
何か問題が起きたときはエラーを返し、現在のスパンを失敗としてマークしてください。これにより、ログを見る前にトレース上でそれが目立つようになります。
ハンドラ内では次のようにできます:
span := trace.SpanFromContext(r.Context())
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
4) ローカルでtrace IDを確認する
APIを実行してエンドポイントを叩き、リクエストコンテキストからtrace IDを1回ログ出力して、リクエストごとに変わることを確認してください。常に空なら、ミドルウェアがハンドラが受け取るのと同じコンテキストを使っていません。
DBやサードパーティ呼び出しにコンテキストを引き継ぐ
context.Contextを失うとエンドツーエンドの可視化は途切れます。受信したリクエストのコンテキストは、すべてのDB呼び出し、HTTP呼び出し、ヘルパーへ渡すスレッドであるべきです。context.Background()で置き換えたり、下へ渡すのを忘れると、トレースは別個の無関係な作業になります。
外向きHTTPでは、Do(req) の呼び出しごとに子スパンになるよう計測済みのトランスポートを使ってください。外向きのリクエストにはW3Cトレースヘッダを転送して、下流サービスが同じトレースにスパンを付けられるようにします。
データベース呼び出しも同様です。計測済みドライバを使うか、QueryContext や ExecContext の周りにスパンを作るラッパーを用意してください。漏洩しないよう安全な情報だけを記録します。遅いクエリを見つけたい一方で、データは漏らしたくありません。
有用で低リスクな属性には、操作名(例:SELECT user_by_id)、テーブルやモデル名、行数(数だけ)、所要時間、リトライ回数、粗いエラー種別(timeout、canceled、constraint)などがあります。
タイムアウトも物語の一部です。DBやサードパーティ呼び出しには context.WithTimeout を設定し、キャンセルを上位に伝播させてください。呼び出しがキャンセルされたらスパンをエラーとしてマークし、deadline_exceeded のような短い理由を追加します。
バックグラウンドジョブとキューのトレーシング
バックグラウンド作業はトレースが途切れやすい箇所です。HTTPリクエストが終わり、別のマシン上のワーカーが後でメッセージを拾うと、共有コンテキストがなければ2つの独立した物語になります。何もしないとAPIトレースと、どこから始まったかわからないジョブトレースが別々に見えます。
対処は簡単です:ジョブをエンキューするときに現在のトレースコンテキストをキャプチャしてジョブのメタデータ(ペイロード、ヘッダ、属性など)に保存します。ワーカーが開始するときにそのコンテキストを抽出し、元のリクエストの子として新しいスパンを開始してください。
コンテキストを安全に伝搬する
トレースコンテキストだけをコピーし、ユーザーデータは含めないでください。
- トレース識別子とサンプリングフラグ(W3Cのtraceparentスタイル)のみを注入する。
- ビジネスフィールドとは分離して保存する(例:専用の "otel" や "trace" フィールド)。
- 読み戻すときは未検証の入力として扱う(フォーマット検証、欠損データの処理)。
- トークン、メール、リクエスト本体をジョブメタデータに入れない。
ノイズ化させずに追加するスパン
読みやすいトレースは有意義なスパンがいくつかあるだけです。境界や「待ち」のポイントの周りにスパンを作成してください。出発点としては、APIハンドラ内の enqueue スパンとワーカー内の job.run スパンが良いでしょう。
少量のコンテキストを追加します:試行回数、キュー名、ジョブタイプ、ペイロードサイズ(内容ではなくサイズのみ)。リトライが発生する場合は、バックオフの遅延が見えるようにそれらを別スパンやイベントとして記録します。
スケジュールされたタスクにも親が必要です。受信リクエストがない場合は、各実行で新しいルートスパンを作り、スケジュール名をタグとして付けてください。
ログとトレースの相関(かつログの保護)
トレースはどこに時間が使われたかを教えます。ログは何が起き、なぜ起きたかを説明します。それらを接続する最も簡単な方法は、すべてのログエントリに trace_id と span_id を構造化フィールドとして追加することです。
Goでは、context.Context からアクティブスパンを取得してリクエストごと(またはジョブごと)にロガーに情報を追加します。するとすべてのログ行が特定のトレースを指すようになります。
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
logger := baseLogger.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
logger.Info("charge_started", "order_id", orderID)
これだけでログエントリからその時に実行されていた正確なスパンにジャンプできます。コンテキストが欠けている場合は trace_id が空になるのでそれも明白です。
PIIを漏らさずにログを有用に保つ
ログはトレースよりも長く保存され、より広く流通することが多いので、より厳しく扱いましょう。安定した識別子と結果を優先します:user_id、order_id、payment_provider、status、error_code。ユーザー入力をログする必要がある場合は、先にマスキングして長さを制限してください。
エラーをグループ化しやすくする
一貫したイベント名とエラータイプを使えば集計や検索がしやすくなります。文言が毎回変わると同じ問題が多くの異なるものに見えてしまいます。
問題を見つけやすくするメトリクスを追加する
メトリクスは早期警戒システムです。GoのOpenTelemetryトレーシングが既にある設定では、メトリクスは「どれくらい頻繁に、どれだけ酷く、いつから」を答えるべきです。
ほとんどのAPIで役立つ小さなセットから始めます:リクエスト数、エラー数(ステータスクラス別)、レイテンシのパーセンタイル(p50、p95、p99)、同時処理中リクエスト数、DBや主要なサードパーティ呼び出しの依存レイテンシ。
トレースとメトリクスを整合させるために、同じルートテンプレートと名前を使ってください。スパンが /users/{id} を使うならメトリクスも同様にします。そうすればチャートで「/checkout の p95 が上がった」と出たときに、そのルートでフィルタしたトレースに直接飛べます。
ラベル(属性)には注意してください。一つの誤ったラベルがコストを爆発させ、ダッシュボードを無用にします。ルートテンプレート、メソッド、ステータスクラス、サービス名は通常安全です。ユーザーID、メール、完全なURL、生のエラーメッセージは通常避けてください。
ビジネス上重要なイベントのためにいくつかのカスタムメトリクスを追加します(例:checkout started/completed、支払い失敗を結果コード群別に、バックグラウンドジョブの成功対リトライ)。セットは小さく保ち、使わないものは削除してください。
テレメトリのエクスポートと安全なロールアウト
エクスポートはOpenTelemetryを実用にする部分です。サービスはスパン、メトリクス、ログを信頼できる場所へ遅延させずに送る必要があります。
ローカル開発ではシンプルに保ちましょう。コンソールエクスポータ(またはローカルコレクタへのOTLP)は、トレースを素早く確認してスパン名や属性を検証するのに役立ちます。本番ではサービス近傍のエージェントやOpenTelemetry CollectorへのOTLPを推奨します。リトライ、ルーティング、フィルタリングを一か所で扱えます。
バッチ送信が重要です。短い間隔でバッチ送信し、タイムアウトを厳しく設定してネットワークの詰まりがアプリをブロックしないようにします。テレメトリはクリティカルパス上にあってはいけません。エクスポータが追いつかない場合、メモリを溜め込むよりデータを捨てるべきです。
サンプリングはコストを予測可能に保ちます。まずヘッドベースサンプリング(例:1〜10%)で始め、簡単なルールを追加します:エラーは常にサンプル、しきい値を超える遅いリクエストは常にサンプル。高頻度のバックグラウンドジョブがある場合はそれらを低いレートでサンプルします。
段階的にロールアウトしてください:開発では100%サンプリング、ステージングでは現実的なトラフィックで低めのサンプリング、本番では保守的なサンプリングとエクスポータ障害を検出するアラート。
エンドツーエンド可視化を台無しにする一般的な間違い
エンドツーエンドの可視化が失敗するのは多くの場合単純な理由からです:データはあるが繋がらない。
Goで分散トレーシングを壊す典型的な問題は以下です:
- レイヤー間でコンテキストを落とす。ハンドラがスパンを作るが、DB呼び出しやHTTPクライアント、ゴルーチンが
context.Background()を使う。 - エラーを返すだけでスパンにマークしない。エラーを記録してスパンステータスを設定しないと、ユーザーが500を見ていてもトレースは「緑」になります。
- すべてを計測する。すべてのヘルパーがスパンになるとノイズとコストが増えます。
- 高カードinalityな属性を追加する。ID入りの完全なURL、メール、生のSQL値、リクエスト本体、生のエラーストリングは数百万のユニーク値を生みます。
- 平均値でパフォーマンスを判断する。インシデントは平均ではなくパーセンタイル(p95/p99)やエラー率に現れます。
簡単な健全性チェックは、実際の1つのリクエストを選んで境界を越えて追うことです。受信リクエスト、DBクエリ、サードパーティ呼び出し、非同期ワーカーを通じて1つのtrace IDが流れているのが見えなければ、まだエンドツーエンドの可視化はできていません。
実用的な「完了」チェックリスト
ユーザー報告から正確なリクエストを特定し、すべてのホップを追えるようになればほぼ完了です。
- 1つのAPIログ行を選び、
trace_idで正確なトレースを見つける。DBやHTTPクライアント、ワーカーなど同じリクエストの深いログが同じトレースコンテキストを持つことを確認する。 - トレースを開き、ネストを確認する:トップにHTTPサーバースパンがあり、子にDB呼び出しやサードパーティAPIのスパンがあること。フラットな一覧はコンテキストが失われていることを意味します。
- APIリクエストからバックグラウンドジョブを起動し(例:レシートメール送信)、ワーカースパンがリクエストに繋がっていることを確認する。
- メトリクスの基本をチェックする:リクエスト数、エラー率、レイテンシのパーセンタイル。ルートや操作でフィルタできるか確認する。
- 属性とログをスキャンして安全性を確認する:パスワード、トークン、完全なクレジットカード番号、生の個人データがないこと。
簡単なリアリティテストは、決済プロバイダが遅延するようにして遅いチェックアウトをシミュレートすることです。1つのトレースに外部呼び出しスパンが明確にラベル付けされ、checkout ルートの p95 レイテンシがスパイクしているのが見えるはずです。
AppMaster(appmaster.io)のようにGoバックエンドを生成している場合は、このチェックリストをリリース手順の一部にすると、新しいエンドポイントやワーカーがアプリの成長に伴って追跡可能なままになります。AppMasterは実際のGoサービスを生成するので、1つのOpenTelemetryセットアップを標準化してサービスやバックグラウンドジョブに持ち運べます。
例:サービス間で遅いチェックアウトをデバッグする
顧客から「チェックアウトが時々ハングする」と言われました。再現が難しいケースでは、まさにGoのOpenTelemetryトレーシングが威力を発揮します。
まずメトリクスで問題の形を把握します。チェックアウトのリクエストレート、エラー率、p95やp99レイテンシを見ます。遅延が短時間のバーストで一部のリクエストにのみ起きているなら、通常は依存先、キューイング、またはリトライ挙動が原因で、CPUがボトルネックとは限りません。
次に同じ時間帯の遅いトレースを開きます。1つのトレースで十分なことが多いです。正常なチェックアウトはエンドツーエンドで300〜600msかもしれません。悪いものは8〜12秒で、大部分が単一のスパンに費やされていることがあります。
よくあるパターンはこうです:APIハンドラ自体は速いがDBは概ね問題ない、次に決済プロバイダのスパンがリトライとバックオフを示し、その間に下流呼び出しがロックやキューの背後で待っている。レスポンスは200を返すことがあり、エラーだけに基づくアラートでは検出できません。
相関されたログはその経路を平易に教えてくれます:「retrying Stripe charge: timeout」「db tx aborted: serialization failure」「retry checkout flow」のように続けば、いくつかの小さな問題が組み合わさって悪いユーザー体験になっていることが明白です。
ボトルネックを見つけたら、読みやすさを維持するために一貫性を保ってください。スパン名、属性(安全なユーザーIDハッシュ、注文ID、依存名)、サンプリングルールをサービス間で標準化すれば、誰もが同じ方法でトレースを読むことができます。


