Chuyển đến nội dung chính
友田 陽大
Go & Echo in production
Go
Echo
可観測性
アーキテクチャ設計
型安全
SRE

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

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

Published
Reading time
9 min read
Author
友田 陽大
Chia sẻ

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

この記事は、Go Echo 本番運用ガイドの可観測性編です。OpenTelemetry(OTel)でトレース・メトリクスを取り、slog 構造化ログと相関させます。プラットフォーム全体の可観測性設計はOpenTelemetry 実践ガイドに譲り、ここでは Echo v5 で今すぐ動く実装に集中します。

この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。重要:定番だった otelechogo.opentelemetry.io/contrib/.../labstack/echo/otelecho)は非推奨であり、かつ Echo v4 想定です。本記事は、それに依存しない OTel SDK を直接使う自前ミドルウェアを採用します(理由は第1章)。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)に必ず引き継ぐ——ここが切れると、トレースがブツ切りになります。

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 クエリや外部 API の子 span が親に繋がりません
  • Recover の内側に置き、panic も集中エラーハンドラ経由で span に記録されるようにします。

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

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

// 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です。これを同じミドルウェアに足します。

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 の実践へ。


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

最後のピースがログとトレースの相関です。v5 標準の slogtrace_id/span_id を載せれば、ログ1行からトレースへジャンプできます。ctx から span 文脈を取り出すヘルパを噛ませます。

// 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ミドルウェア並び順で OTel ミドルウェアの内側に置き、span 文脈が乗った状態でログを出します。


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

計装したシグナルは、**OTLP(OpenTelemetry Protocol)**で収集基盤(OpenTelemetry Collector → Grafana Tempo / Jaeger / Datadog / Cloud 各社)へ送ります。アプリ起動時に TracerProvider/MeterProvider を構成し、グレースフルシャットダウンflush します。

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
}
// 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環境変数化し、エンドポイントをコードに焼きません。


まとめ: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 実践ガイド、Echo の全体像は本番運用ガイドへ。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc