「本番で API が遅い」と言われたとき、どの処理が・どれだけ・なぜ遅いかを即座に答えられますか。ログを grep して当たりを付ける運用は、障害のたびに時間を溶かします。可観測性(Observability)とは、止まった処理・遅い処理を、推測ではなくデータで一目で追える状態を作ることです。
この記事は、Go Echo 本番運用ガイドの可観測性編です。OpenTelemetry(OTel)でトレース・メトリクスを取り、slog 構造化ログと相関させます。プラットフォーム全体の可観測性設計はOpenTelemetry 実践ガイドに譲り、ここでは Echo v5 で今すぐ動く実装に集中します。
この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。重要:定番だった
otelecho(go.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つの問題があります。
- 非推奨(deprecated):パッケージ自体が非推奨化されている。
- 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 標準の slogに trace_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原則
- 三本柱を
trace_idで相関させ、1リクエストを貫通して辿れるようにする。 - otelecho は非推奨・v4 想定。OTel SDK を直接使う自前ミドルウェアがバージョン非依存で堅い(ETC)。
- トレースは伝播が命。
Extract→ span →c.SetRequest(req.WithContext(ctx))で後続まで貫通。 - span 名・属性はルートパターンに正規化し、カーディナリティ爆発を防ぐ。
- メトリクスは RED(処理時間 Histogram + 進行中 UpDownCounter)を最小構成で。
- slog に
trace_idを載せ、ログ ↔ トレースを相互ジャンプ可能にする。 - OTLP でエクスポートし、サンプリングでコスト最適化、停止時に flush する。
可観測性は「ログを出すこと」ではなく「障害時に推測をゼロにすること」です。Echo v5 では、非推奨ツールに頼らず OTel SDK を薄く自前計装する方が、結果的に堅く・安く・理解可能になります。プラットフォーム横断の可観測性はOpenTelemetry 実践ガイド、Echo の全体像は本番運用ガイドへ。