# Echo の可観測性：OpenTelemetry で分散トレース・メトリクス・slog 相関を自前ミドルウェアで実装する

> Go Echo（v5）の可観測性を OpenTelemetry で本番品質に実装するガイド。otelecho が非推奨・v4 想定の現状を踏まえ、バージョン非依存の自前トレースミドルウェア、context によるトレース伝播（DB・外部HTTP）、リクエスト処理時間ヒストグラム等のメトリクス、slog ログへの trace_id 相関、OTLP エクスポートまでを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, 可観測性, アーキテクチャ設計, 型安全, SRE
- URL: https://tomodahinata.com/blog/go-echo-opentelemetry-distributed-tracing-metrics-observability-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- 可観測性の目的は『止まった処理を一目で追える』こと。トレース・メトリクス・ログを trace_id で相関させ、1リクエストを貫通して辿れるようにする
- otelecho は非推奨かつ Echo v4 想定。v5 では OpenTelemetry SDK を直接使う自前ミドルウェアの方が、バージョン非依存で堅い（ETC）
- トレースは伝播が命。受信ヘッダから context を Extract→span 開始→c.SetRequest(c.Request().WithContext(ctx)) で後続（DB/外部API）まで貫通させる
- メトリクスは RED（Rate/Errors/Duration）。リクエスト処理時間の Histogram と進行中リクエストの UpDownCounter を最小構成で持つ
- slog に trace_id/span_id を載せると、ログ1行からトレースへジャンプできる。RequestLogger(slog) と OTel を相関させるのが要

---

「本番で API が遅い」と言われたとき、**どの処理が・どれだけ・なぜ遅いか**を即座に答えられますか。ログを `grep` して当たりを付ける運用は、障害のたびに時間を溶かします。可観測性（Observability）とは、**止まった処理・遅い処理を、推測ではなくデータで一目で追える**状態を作ることです。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)の可観測性編です。OpenTelemetry（OTel）でトレース・メトリクスを取り、[slog 構造化ログ](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide#4-requestloggerslogで構造化アクセスログ)と相関させます。プラットフォーム全体の可観測性設計は[OpenTelemetry 実践ガイド](/blog/opentelemetry-observability-production-tracing-metrics-logs)に譲り、ここでは **Echo v5 で今すぐ動く**実装に集中します。

> **この記事のルール**：Echo の API は **公式ドキュメント（v5・2026年6月時点）** に基づきます。**重要**：定番だった `otelecho`（`go.opentelemetry.io/contrib/.../labstack/echo/otelecho`）は**非推奨**であり、かつ **Echo v4 想定**です。本記事は、それに依存しない **OTel SDK を直接使う自前ミドルウェア**を採用します（[理由は第1章](#1-なぜotelechoを使わず自前ミドルウェアにするのか)）。OTel SDK は更新されるため、API は公式で最新を確認してください。

---

## 0. 三本柱：トレース・メトリクス・ログを「相関」させる

可観測性は3つのシグナルで成り立ちます。**バラバラに集めるのではなく、相関させる**ことに価値があります。

- **トレース（Traces）**：1リクエストが「どの処理を・どの順で・どれだけ」かけたかの分解。遅延の犯人特定に効く。
- **メトリクス（Metrics）**：集計値（リクエスト数・エラー率・処理時間分布）。傾向とアラートに効く。
- **ログ（Logs）**：個別イベントの詳細。原因の文脈に効く。

これらを **`trace_id` で繋ぐ**と、「メトリクスでエラー率の上昇に気づく → 該当時間帯のトレースで遅い span を特定 → その span の `trace_id` でログに飛んで原因を読む」という**一本の調査線**ができます。これが本記事のゴールです。

---

## 1. なぜ otelecho を使わず自前ミドルウェアにするのか

定番は `otelecho` ミドルウェアですが、2026年6月時点で**2つの問題**があります。

1. **非推奨（deprecated）**：パッケージ自体が非推奨化されている。
2. **Echo v4 想定**：v5 でハンドラ署名が `func(c *echo.Context) error` に変わったため、v4 前提の計装はそのままでは噛み合わない。

OpenTelemetry の**コア SDK（`go.opentelemetry.io/otel`）はフレームワーク非依存**です。だから、**Echo のミドルウェアとして OTel SDK を直接呼ぶ薄い計装**を自前で持つ方が、**バージョンに左右されず・非推奨に巻き込まれず・中身を理解できる**——ETC（変更容易性）の観点で堅い選択です。コードは数十行で済みます。

---

## 2. トレース計装ミドルウェア：伝播が命

分散トレースの肝は **context 伝播**です。受信リクエストのヘッダから親トレース文脈を **Extract** し、span を開始し、その `ctx` を**後続（DB・外部 API）に必ず引き継ぐ**——ここが切れると、トレースがブツ切りになります。

```go
import (
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/propagation"
	semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
	"go.opentelemetry.io/otel/trace"
)

func OTelTracing(service string) echo.MiddlewareFunc {
	tracer := otel.Tracer(service)
	propagator := otel.GetTextMapPropagator()

	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c *echo.Context) error {
			req := c.Request()
			// ① 受信ヘッダから親トレース文脈を取り出す（W3C traceparent 等）
			ctx := propagator.Extract(req.Context(), propagation.HeaderCarrier(req.Header))

			// ② span を開始。ルートパターンを名前にする（カーディナリティを抑える）
			route := c.Path() // "/users/:id"（実値ではなくパターン＝低カーディナリティ）
			ctx, span := tracer.Start(ctx, req.Method+" "+route,
				trace.WithSpanKind(trace.SpanKindServer),
				trace.WithAttributes(
					semconv.HTTPRequestMethodKey.String(req.Method),
					semconv.HTTPRouteKey.String(route),
				),
			)
			defer span.End()

			// ③ 後続（ハンドラ→DB→外部API）へ ctx を貫通させる（最重要）
			c.SetRequest(req.WithContext(ctx))

			err := next(c)

			// ④ 結果を span に記録
			status := c.Response().Status
			span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(status))
			if err != nil || status >= 500 {
				span.SetStatus(codes.Error, http.StatusText(status))
				if err != nil {
					span.RecordError(err)
				}
			}
			return err
		}
	}
}
```

**設計上の要点**：

- **span 名は `c.Path()`（ルートパターン）**。`/users/42` のような実値を名前にすると、ID ごとに別 span 扱いになり**カーディナリティが爆発**します。`/users/:id` に正規化します。
- **`c.SetRequest(req.WithContext(ctx))`** が伝播の心臓部。これを忘れると、ハンドラ内の `c.Request().Context()` に span が乗らず、[DB クエリ](/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide#1-最重要contextをハンドラからdbまで貫通させる)や外部 API の子 span が**親に繋がりません**。
- **`Recover` の内側**に置き、panic も[集中エラーハンドラ](/blog/go-echo-request-binding-validation-error-handling-guide)経由で span に記録されるようにします。

---

## 3. 子 span：DB と外部 HTTP まで一本に繋ぐ

ミドルウェアで作った `ctx` を渡せば、下位処理が**子 span**として親にぶら下がります。これで「API は速いが DB が遅い」が**可視化**されます。

```go
// DB：pgx なら otelpgx で自動計装、または手動で子 span
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
	ctx, span := otel.Tracer("repo").Start(ctx, "UserRepo.FindByID") // 親 ctx から子 span
	defer span.End()
	// ... r.pool.Query(ctx, ...) ← ctx 経由で DB span が親に繋がる
}

// 外部 HTTP：otelhttp はフレームワーク非依存なのでそのまま使える
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
// client.Do(req.WithContext(ctx)) ← 送信先へ traceparent を自動伝播
```

> `otelhttp`（outbound HTTP 計装）は `net/http` のトランスポートをラップするだけで、**Echo のバージョンに依存しません**。pgx には `otelpgx` のような計装ライブラリもあります。「**フレームワーク非依存の計装は使い、フレームワーク密結合の計装（otelecho）は自前に置き換える**」のが、移行期の堅い方針です。

---

## 4. メトリクス：RED を最小構成で

メトリクスは **RED（Rate・Errors・Duration）**を基本に持ちます。最小構成は**リクエスト処理時間の Histogram**と**進行中リクエストの UpDownCounter**です。これを同じミドルウェアに足します。

```go
import "go.opentelemetry.io/otel/metric"

func OTelMetrics(service string) echo.MiddlewareFunc {
	meter := otel.Meter(service)
	duration, _ := meter.Float64Histogram("http.server.request.duration",
		metric.WithUnit("s"), metric.WithDescription("HTTP request duration"))
	inflight, _ := meter.Int64UpDownCounter("http.server.active_requests")

	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c *echo.Context) error {
			ctx := c.Request().Context()
			start := time.Now()
			inflight.Add(ctx, 1)
			defer inflight.Add(ctx, -1)

			err := next(c)

			// 属性はルートパターン＋ステータスクラスに絞る（カーディナリティ管理）
			attrs := metric.WithAttributes(
				attribute.String("http.route", c.Path()),
				attribute.String("http.method", c.Request().Method),
				attribute.Int("http.status_code", c.Response().Status),
			)
			duration.Record(ctx, time.Since(start).Seconds(), attrs)
			return err
		}
	}
}
```

> **カーディナリティの罠**：メトリクスの属性に**ユーザー ID や生 URL** を入れると、時系列の組み合わせが爆発し、コストとストレージを破壊します（コスト効率に直結）。属性は**ルートパターン・メソッド・ステータス**のような**低カーディナリティ**に厳しく絞ります。SLO/エラーバジェットの設計は[可観測性・SRE の実践](/blog/opentelemetry-observability-production-tracing-metrics-logs)へ。

---

## 5. ログ相関：slog に trace_id を載せる

最後のピースが**ログとトレースの相関**です。[v5 標準の slog](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide#4-requestloggerslogで構造化アクセスログ)に **`trace_id`/`span_id`** を載せれば、**ログ1行からトレースへジャンプ**できます。`ctx` から span 文脈を取り出すヘルパを噛ませます。

```go
// ctx の span 文脈を slog 属性に変換する
func traceAttrs(ctx context.Context) []slog.Attr {
	sc := trace.SpanContextFromContext(ctx)
	if !sc.IsValid() {
		return nil
	}
	return []slog.Attr{
		slog.String("trace_id", sc.TraceID().String()),
		slog.String("span_id", sc.SpanID().String()),
	}
}

// RequestLogger の LogValuesFunc で相関ログを出す
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
	LogStatus: true, LogURI: true, LogError: true, LogLatency: true, HandleError: true,
	LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
		ctx := c.Request().Context()
		attrs := append(traceAttrs(ctx),
			slog.String("uri", v.URI),
			slog.Int("status", v.Status),
			slog.Duration("latency", v.Latency),
		)
		level := slog.LevelInfo
		if v.Error != nil {
			level = slog.LevelError
			attrs = append(attrs, slog.String("err", v.Error.Error()))
		}
		logger.LogAttrs(ctx, level, "REQUEST", attrs...)
		return nil
	},
}))
```

これで、メトリクスでエラー率の急増に気づき → 該当時間帯のトレースで遅い span を見つけ → その `trace_id` でログを引く、という**三本柱を貫通した調査**が成立します。`RequestLogger` は[ミドルウェア並び順](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide#2-並び順本番の推奨スタック)で OTel ミドルウェアの**内側**に置き、span 文脈が乗った状態でログを出します。

---

## 6. エクスポート：OTLP で収集基盤へ送る

計装したシグナルは、**OTLP（OpenTelemetry Protocol）**で収集基盤（OpenTelemetry Collector → Grafana Tempo / Jaeger / Datadog / Cloud 各社）へ送ります。アプリ起動時に TracerProvider/MeterProvider を構成し、[グレースフルシャットダウン](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#4-グレースフルシャットダウンsigtermを取りこぼさない)で **flush** します。

```go
import (
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initTracing(ctx context.Context, service string) (func(context.Context) error, error) {
	exp, err := otlptracegrpc.New(ctx) // 送信先は OTEL_EXPORTER_OTLP_ENDPOINT 環境変数
	if err != nil {
		return nil, err
	}
	res, _ := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(service)))
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exp),                       // バッチ送信（性能・コスト）
		sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))), // 10%サンプリング
		sdktrace.WithResource(res),
	)
	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.TraceContext{}) // W3C 伝播
	return tp.Shutdown, nil // ← main の defer で呼び、未送信 span を flush
}
```

```go
// main 側：起動時に初期化、終了時に flush
shutdown, err := initTracing(ctx, "user-api")
if err != nil { /* ... */ }
defer shutdown(context.Background()) // グレースフル停止時に未送信分を送る
```

> **コスト最適化**：本番では**サンプリング**（例：`TraceIDRatioBased(0.1)` で 10%）でトレース量とコストを抑えます。エラーや遅い trace を優先して残す「テールサンプリング」は Collector 側で行います。送信先は `OTEL_EXPORTER_OTLP_ENDPOINT` で[環境変数化](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#1-設定は-12-factor-で環境変数から読む)し、エンドポイントをコードに焼きません。

---

## まとめ：Echo の可観測性を本番品質にする7原則

1. **三本柱を `trace_id` で相関**させ、1リクエストを貫通して辿れるようにする。
2. **otelecho は非推奨・v4 想定。OTel SDK を直接使う自前ミドルウェア**がバージョン非依存で堅い（ETC）。
3. **トレースは伝播が命**。`Extract` → span → **`c.SetRequest(req.WithContext(ctx))`** で後続まで貫通。
4. **span 名・属性はルートパターン**に正規化し、カーディナリティ爆発を防ぐ。
5. **メトリクスは RED**（処理時間 Histogram + 進行中 UpDownCounter）を最小構成で。
6. **slog に `trace_id` を載せ**、ログ ↔ トレースを相互ジャンプ可能にする。
7. **OTLP でエクスポートし、サンプリングでコスト最適化**、停止時に flush する。

可観測性は「ログを出すこと」ではなく「**障害時に推測をゼロにすること**」です。Echo v5 では、非推奨ツールに頼らず OTel SDK を薄く自前計装する方が、結果的に堅く・安く・理解可能になります。プラットフォーム横断の可観測性は[OpenTelemetry 実践ガイド](/blog/opentelemetry-observability-production-tracing-metrics-logs)、Echo の全体像は[本番運用ガイド](/blog/go-echo-framework-production-guide)へ。
