Goテレメトリオブザーバビリティ実践:構造化ログとメトリクスでプロダクション級オブザーバビリティを構築する5つのコアパターン

技术架构

2026年、Go言語のテレメトリエコシステムは「オプション」から「必須」へと進化しました。マイクロサービスアーキテクチャの普及が深まる中、オンラインの問題を5分以内に特定できなければ、それはユーザーの離脱と収益の損失を意味します。Go 1.22で導入されたslog標準ライブラリ、OpenTelemetry Go SDKの成熟、そして主要クラウドプロバイダーのOTLPプロトコル全面サポートにより、Goサービスのオブザーバビリティはついに統一された答えを得ました。しかし現実は異なります:多くのチームが依然としてfmt.Printlnで本番問題をデバッグし、ログとメトリクスは分断され、トレースリンクはサービス境界で途切れ、アラートストームが止まりません。本記事では5つのコアパターンを通じて、本当に使えるプロダクション級オブザーバビリティシステムを構築します。

コア概念

概念 説明 主要パッケージ/ツール
slog構造化ログ Go 1.22+標準ライブラリ、キー・バリュー構造化出力対応 log/slog
OpenTelemetry Metrics 統一メトリクス収集標準、Counter/Gauge/Histogram対応 go.opentelemetry.io/otel/metric
分散トレーシング サービス間リクエストチェーン追跡、W3C TraceContext伝播 go.opentelemetry.io/otel/trace
Instrumentationミドルウェア 自動化されたHTTP/gRPCインターセプト計装 go.opentelemetry.io/contrib/instrumentation
オブザーバビリティダッシュボード Grafana/Prometheus統合の統合監視ビュー Grafana, Prometheus, Loki

問題分析:Goオブザーバビリティの5つのペインポイント

ペインポイント1:ログが非構造化で、デバッグが藁の中から針を探すような状態

従来のlog.Printfはプレーンテキストを出力し、機械による解析が不可能、ログプラットフォームもインデックスを構築できず、問題の調査は目視検索に頼るしかありません。

ペインポイント2:メトリクスとログが分断され、相関分析が不可能

メトリクスはCPU急上昇を示しているが、対応する時間帯のログに直接ジャンプできない。2つのシステムが独立して動作し、問題特定の効率が極めて低い。

ペインポイント3:分散トレースリンクがサービス境界で途切れる

サービスAがサービスBを呼び出す際、トレースIDが正しく伝播されず、チェーンが境界で途切れ、完全なリクエストパスが見えない。

ペインポイント4:手動計装のコード侵入が深刻

各HTTPハンドラに大量の計装コードを手動で書く必要があり、ビジネスロジックがオブザーバビリティコードに埋もれ、保守コストが極めて高い。

ペインポイント5:本番環境のアラートストーム

適切なメトリクス集計とアラート戦略がなく、1つのサービスの変動で数十のアラートがトリガーされ、かえって本当の問題が隠される。

コアパターン1:slog構造化ログとコンテキスト伝播

slogはGo 1.22で導入された構造化ログ標準ライブラリです。キー・バリュー出力をサポートするだけでなく、より重要なのはContext伝播をサポートし、ログがリクエストレベルのコンテキスト情報を自動的に運べることです。

package main

import (
	"context"
	"log/slog"
	"os"
	"time"
)

// RequestIDKey はcontextからリクエストIDを抽出するためのキー
type RequestIDKey struct{}

// Logger はslog.Loggerをラップし、コンテキスト対応のログメソッドを提供
type Logger struct {
	inner *slog.Logger
}

// NewLogger はデフォルトフィールド付きのLoggerを作成
func NewLogger(serviceName string) *Logger {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	})
	logger := slog.New(handler).With(
		"service", serviceName,
		"pid", os.Getpid(),
	)
	return &Logger{inner: logger}
}

// WithContext はcontextからリクエスト情報を抽出しログに付加
func (l *Logger) WithContext(ctx context.Context) *slog.Logger {
	logger := l.inner
	if reqID, ok := ctx.Value(RequestIDKey{}).(string); ok {
		logger = logger.With("request_id", reqID)
	}
	if traceID := getTraceIDFromContext(ctx); traceID != "" {
		logger = logger.With("trace_id", traceID)
	}
	return logger
}

// InfoContext はInfoレベルのログを記録、コンテキストを自動付加
func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) {
	l.WithContext(ctx).InfoContext(ctx, msg, args...)
}

// ErrorContext はErrorレベルのログを記録、コンテキストを自動付加
func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) {
	l.WithContext(ctx).ErrorContext(ctx, msg, args...)
}

// getTraceIDFromContext はOpenTelemetry contextからtraceIDを抽出
func getTraceIDFromContext(ctx context.Context) string {
	span := trace.SpanFromContext(ctx)
	if span.SpanContext().IsValid() {
		return span.SpanContext().TraceID().String()
	}
	return ""
}

// --- 使用例 ---

// middlewareRequestID HTTPミドルウェア:リクエストIDをcontextに注入
func middlewareRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		reqID := r.Header.Get("X-Request-ID")
		if reqID == "" {
			reqID = generateUUID()
		}
		ctx := context.WithValue(r.Context(), RequestIDKey{}, reqID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// handleGetUser ビジネスハンドラ:コンテキスト対応ログを使用
func handleGetUser(logger *Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		userID := r.PathValue("id")

		logger.InfoContext(ctx, "fetching user",
			"user_id", userID,
			"method", r.Method,
			"path", r.URL.Path,
		)

		user, err := fetchUserFromDB(ctx, userID)
		if err != nil {
			logger.ErrorContext(ctx, "failed to fetch user",
				"user_id", userID,
				"error", err.Error(),
				"duration_ms", time.Since(time.Now()).Milliseconds(),
			)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}

		logger.InfoContext(ctx, "user fetched successfully",
			"user_id", userID,
			"user_name", user.Name,
		)
		json.NewEncoder(w).Encode(user)
	}
}

重要ポイント

  • JSONHandlerで構造化JSON出力、ログプラットフォームの解析とインデックス構築に便利
  • With()メソッドでサービスレベルのデフォルトフィールドを追加、各ログエントリでの重複を回避
  • ContextからリクエストIDとTraceIDを抽出、ログとトレースの自動関連付けを実現
  • WithContextパターンによりビジネスコードはログコンテキスト伝播の詳細を意識不要

コアパターン2:OpenTelemetry Metricsメトリクス収集

OpenTelemetry Metricsは統一メトリクス収集標準を提供し、Counter、Gauge、Histogramの3つのインストゥルメントタイプをサポートします。Prometheusエクスポーターと組み合わせることで、既存の監視システムとシームレスに統合できます。

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"go.opentelemetry.io/otel/exporters/prometheus"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)

// MetricsProvider はOpenTelemetry MeterProviderをラップ
type MetricsProvider struct {
	provider *sdkmetric.MeterProvider
	meter    metric.Meter
}

// NewMetricsProvider はMetricsProviderを作成しPrometheusエクスポーターを登録
func NewMetricsProvider(serviceName string) (*MetricsProvider, error) {
	exporter, err := prometheus.New()
	if err != nil {
		return nil, fmt.Errorf("create prometheus exporter: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithReader(exporter),
		sdkmetric.WithView(
			sdkmetric.NewView(
				sdkmetric.Instrument{Name: "http.server.duration"},
				sdkmetric.Stream{
					Aggregation: sdkmetric.AggregationExplicitBucketHistogram{
						Boundaries: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
					},
				},
			),
		),
	)

	meter := provider.Meter(
		serviceName,
		metric.WithInstrumentationVersion("1.0.0"),
	)

	return &MetricsProvider{
		provider: provider,
		meter:    meter,
	}, nil
}

// AppMetrics アプリケーションレベルのメトリクスコレクション
type AppMetrics struct {
	httpRequestsTotal    metric.Int64Counter
	httpRequestDuration  metric.Float64Histogram
	activeConnections    metric.Int64UpDownCounter
	dbQueryDuration      metric.Float64Histogram
	businessOpsTotal     metric.Int64Counter
}

// NewAppMetrics は全アプリケーションメトリクスを初期化
func NewAppMetrics(mp *MetricsProvider) (*AppMetrics, error) {
	m := mp.meter
	am := &AppMetrics{}

	var err error

	am.httpRequestsTotal, err = m.Int64Counter(
		"http.server.requests.total",
		metric.WithDescription("Total number of HTTP requests"),
		metric.WithUnit("{request}"),
	)
	if err != nil {
		return nil, err
	}

	am.httpRequestDuration, err = m.Float64Histogram(
		"http.server.duration",
		metric.WithDescription("HTTP request duration"),
		metric.WithUnit("s"),
	)
	if err != nil {
		return nil, err
	}

	am.activeConnections, err = m.Int64UpDownCounter(
		"http.server.connections.active",
		metric.WithDescription("Number of active connections"),
		metric.WithUnit("{connection}"),
	)
	if err != nil {
		return nil, err
	}

	am.dbQueryDuration, err = m.Float64Histogram(
		"db.query.duration",
		metric.WithDescription("Database query duration"),
		metric.WithUnit("s"),
	)
	if err != nil {
		return nil, err
	}

	am.businessOpsTotal, err = m.Int64Counter(
		"business.operations.total",
		metric.WithDescription("Total number of business operations"),
		metric.WithUnit("{operation}"),
	)
	if err != nil {
		return nil, err
	}

	return am, nil
}

// RecordHTTPRequest はHTTPリクエストメトリクスを記録
func (am *AppMetrics) RecordHTTPRequest(ctx context.Context, method, path, status string, duration time.Duration) {
	attrs := metric.WithAttributes(
		attribute.String("http.method", method),
		attribute.String("http.route", path),
		attribute.Int("http.status_code", statusCodeToInt(status)),
	)

	am.httpRequestsTotal.Add(ctx, 1, attrs)
	am.httpRequestDuration.Record(ctx, duration.Seconds(), attrs)
}

// RecordDBQuery はデータベースクエリメトリクスを記録
func (am *AppMetrics) RecordDBQuery(ctx context.Context, query string, duration time.Duration, err error) {
	attrs := metric.WithAttributes(
		attribute.String("db.query.name", query),
		attribute.Bool("db.query.error", err != nil),
	)
	am.dbQueryDuration.Record(ctx, duration.Seconds(), attrs)
}

// RecordBusinessOp はビジネス操作メトリクスを記録
func (am *AppMetrics) RecordBusinessOp(ctx context.Context, op string, success bool) {
	attrs := metric.WithAttributes(
		attribute.String("operation.type", op),
		attribute.Bool("operation.success", success),
	)
	am.businessOpsTotal.Add(ctx, 1, attrs)
}

// --- 使用例 ---

// metricsMiddleware HTTPミドルウェア:リクエストメトリクスを自動収集
func metricsMiddleware(metrics *AppMetrics, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		metrics.activeConnections.Add(r.Context(), 1)

		rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
		next.ServeHTTP(rw, r)

		duration := time.Since(start)
		metrics.RecordHTTPRequest(
			r.Context(),
			r.Method,
			r.URL.Path,
			fmt.Sprintf("%d", rw.statusCode),
			duration,
		)
		metrics.activeConnections.Add(r.Context(), -1)
	})
}

// responseWriter はhttp.ResponseWriterをラップしてステータスコードをキャプチャ
type responseWriter struct {
	http.ResponseWriter
	statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

重要ポイント

  • ExplicitBucketHistogramでカスタムバケット境界を設定、HTTPリクエストレイテンシ分布に適合
  • UpDownCounterはアクティブ接続数など増減可能なメトリクスの追跡に最適
  • ミドルウェアがメトリクスを自動収集、ビジネスコードへの侵入ゼロ
  • WithAttributesでディメンジョンラベルを追加、多次元メトリクス集計とフィルタリングをサポート

コアパターン3:分散トレーシングとコンテキスト伝播

分散トレーシングはオブザーバビリティの第3の柱です。W3C TraceContext標準により、GoサービスはHTTP/gRPC呼び出し間でトレーシングコンテキストを自動伝播し、サービス間リクエストチェーン追跡を実現できます。

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	"go.opentelemetry.io/otel/trace"
)

// TracingProvider はOpenTelemetry TracerProviderをラップ
type TracingProvider struct {
	provider *sdktrace.TracerProvider
	tracer   trace.Tracer
}

// NewTracingProvider はTracingProviderを作成しOTLPエクスポートを設定
func NewTracingProvider(ctx context.Context, serviceName, otlpEndpoint string) (*TracingProvider, error) {
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(otlpEndpoint),
		otlptracegrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("create OTLP exporter: %w", err)
	}

	provider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceNameKey.String(serviceName),
			semconv.ServiceVersionKey.String("1.0.0"),
		)),
		sdktrace.WithSampler(sdktrace.ParentBased(
			sdktrace.TraceIDRatioBased(0.1), // 本番環境では10%サンプリング
		)),
	)

	// グローバルTracerProviderとプロパゲーターを設定
	otel.SetTracerProvider(provider)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	tracer := provider.Tracer(
		serviceName,
		trace.WithInstrumentationVersion("1.0.0"),
	)

	return &TracingProvider{
		provider: provider,
		tracer:   tracer,
	}, nil
}

// Shutdown はTracerProviderを gracefulにシャットダウン
func (tp *TracingProvider) Shutdown(ctx context.Context) error {
	return tp.provider.Shutdown(ctx)
}

// SpanBuilder はSpanを構築するFluent API
type SpanBuilder struct {
	tracer  trace.Tracer
	name    string
	attrs   []attribute.KeyValue
	options []trace.SpanStartOption
}

// NewSpanBuilder はSpanBuilderを作成
func (tp *TracingProvider) NewSpanBuilder(name string) *SpanBuilder {
	return &SpanBuilder{
		tracer: tp.tracer,
		name:   name,
	}
}

// WithAttr は属性を追加
func (sb *SpanBuilder) WithAttr(key string, value any) *SpanBuilder {
	switch v := value.(type) {
	case string:
		sb.attrs = append(sb.attrs, attribute.String(key, v))
	case int:
		sb.attrs = append(sb.attrs, attribute.Int(key, v))
	case bool:
		sb.attrs = append(sb.attrs, attribute.Bool(key, v))
	}
	return sb
}

// WithOption はSpanStartOptionを追加
func (sb *SpanBuilder) WithOption(opt trace.SpanStartOption) *SpanBuilder {
	sb.options = append(sb.options, opt)
	return sb
}

// Do はトレーシング付き関数を実行
func (sb *SpanBuilder) Do(ctx context.Context, fn func(ctx context.Context) error) error {
	if len(sb.attrs) > 0 {
		sb.options = append(sb.options, trace.WithAttributes(sb.attrs...))
	}
	ctx, span := sb.tracer.Start(ctx, sb.name, sb.options...)
	defer span.End()

	if err := fn(ctx); err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		return err
	}

	span.SetStatus(codes.Ok, "")
	return nil
}

// --- 使用例 ---

// tracingMiddleware HTTPミドルウェア:ルートSpanを自動作成
func tracingMiddleware(tp *TracingProvider, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		propagator := otel.GetTextMapPropagator()
		ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

		spanName := fmt.Sprintf("%s %s", r.Method, r.URL.Path)
		ctx, span := tp.tracer.Start(ctx, spanName,
			trace.WithAttributes(
				semconv.HTTPRequestMethodKey.String(r.Method),
				semconv.URLPathKey.String(r.URL.Path),
				semconv.UserAgentOriginalKey.String(r.UserAgent()),
			),
			trace.WithSpanKind(trace.SpanKindServer),
		)
		defer span.End()

		rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
		next.ServeHTTP(rw, r.WithContext(ctx))

		span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(rw.statusCode))
		if rw.statusCode >= 400 {
			span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", rw.statusCode))
		}
	})
}

// callUserService ユーザーサービスを呼び出すHTTPクライアント、トレースコンテキストを自動伝播
func callUserService(ctx context.Context, tp *TracingProvider, userID string) (*User, error) {
	var user User
	err := tp.NewSpanBuilder("call-user-service").
		WithAttr("user.id", userID).
		WithOption(trace.WithSpanKind(trace.SpanKindClient)).
		Do(ctx, func(ctx context.Context) error {
			req, err := http.NewRequestWithContext(ctx, http.MethodGet,
				fmt.Sprintf("http://user-service:8080/users/%s", userID), nil)
			if err != nil {
				return fmt.Errorf("create request: %w", err)
			}

			// トレースコンテキストをHTTPヘッダーに自動注入
			otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				return fmt.Errorf("do request: %w", err)
			}
			defer resp.Body.Close()

			if resp.StatusCode != http.StatusOK {
				return fmt.Errorf("unexpected status: %d", resp.StatusCode)
			}

			return json.NewDecoder(resp.Body).Decode(&user)
		})

	return &user, err
}

重要ポイント

  • ParentBasedサンプリング戦略を使用:ルートリクエストは10%サンプリング、子リクエストは親の決定に従い、チェーンの完全性を確保
  • TextMapPropagatorがHTTPヘッダーにTraceContextを自動注入/抽出、サービス間伝播を実現
  • SpanBuilder Fluent APIがSpan作成を簡素化し、ボイラープレートコードを削減
  • クライアント呼び出し時はInjectを必ず呼び出し、サーバーミドルウェアはExtractを自動呼び出し

コアパターン4:カスタムInstrumentationミドルウェア

OpenTelemetry Contribは豊富なInstrumentationパッケージを提供していますが、本番環境ではビジネスメトリクス収集、機密情報フィルタリング、カスタムSpan属性など、特定のニーズを満たすカスタムミドルウェアが必要になることがよくあります。

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
	"go.opentelemetry.io/otel/trace"
)

// InstrumentationMiddleware 統合オブザーバビリティミドルウェア
type InstrumentationMiddleware struct {
	logger  *Logger
	metrics *AppMetrics
	tracer  trace.Tracer
}

// NewInstrumentationMiddleware はInstrumentationMiddlewareを作成
func NewInstrumentationMiddleware(
	logger *Logger,
	metrics *AppMetrics,
	tp *TracingProvider,
) *InstrumentationMiddleware {
	return &InstrumentationMiddleware{
		logger:  logger,
		metrics: metrics,
		tracer:  tp.tracer,
	}
}

// InstrumentHTTP 完全なHTTPオブザーバビリティミドルウェア
func (im *InstrumentationMiddleware) InstrumentHTTP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		ctx := r.Context()

		// 1. トレーシング:サーバー側Spanを作成
		propagator := otel.GetTextMapPropagator()
		ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
		spanName := fmt.Sprintf("HTTP %s %s", r.Method, r.URL.Path)
		ctx, span := im.tracer.Start(ctx, spanName,
			trace.WithSpanKind(trace.SpanKindServer),
			trace.WithAttributes(
				attribute.String("http.method", r.Method),
				attribute.String("http.url", r.URL.String()),
				attribute.String("http.user_agent", r.UserAgent()),
				attribute.String("net.peer.ip", r.RemoteAddr),
			),
		)
		defer span.End()

		// 2. メトリクス:アクティブ接続を追跡
		im.metrics.activeConnections.Add(ctx, 1)
		defer im.metrics.activeConnections.Add(ctx, -1)

		// 3. ログ:リクエスト開始を記録
		im.logger.InfoContext(ctx, "request started",
			"method", r.Method,
			"path", r.URL.Path,
			"remote_addr", r.RemoteAddr,
		)

		// 4. ビジネスハンドラを実行
		rw := &instrumentedWriter{
			ResponseWriter: w,
			statusCode:     http.StatusOK,
			bytesWritten:   0,
		}
		next.ServeHTTP(rw, r.WithContext(ctx))

		// 5. レスポンスメトリクスを記録
		duration := time.Since(start)
		im.metrics.RecordHTTPRequest(ctx, r.Method, r.URL.Path,
			fmt.Sprintf("%d", rw.statusCode), duration)

		// 6. トレーシング:レスポンス情報を記録
		span.SetAttributes(
			attribute.Int("http.status_code", rw.statusCode),
			attribute.Int("http.response_size", rw.bytesWritten),
			attribute.Float64("http.duration_ms", float64(duration.Milliseconds())),
		)
		if rw.statusCode >= 500 {
			span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", rw.statusCode))
		}

		// 7. ログ:リクエスト完了を記録
		im.logger.InfoContext(ctx, "request completed",
			"status_code", rw.statusCode,
			"duration_ms", duration.Milliseconds(),
			"bytes_written", rw.bytesWritten,
		)
	})
}

// instrumentedWriter はResponseWriterをラップしてレスポンス情報をキャプチャ
type instrumentedWriter struct {
	http.ResponseWriter
	statusCode   int
	bytesWritten int
}

func (iw *instrumentedWriter) WriteHeader(code int) {
	iw.statusCode = code
	iw.ResponseWriter.WriteHeader(code)
}

func (iw *instrumentedWriter) Write(b []byte) (int, error) {
	n, err := iw.ResponseWriter.Write(b)
	iw.bytesWritten += n
	return n, err
}

// InstrumentDB データベースクエリオブザーバビリティデコレーター
func (im *InstrumentationMiddleware) InstrumentDB(
	ctx context.Context,
	queryName string,
	fn func(ctx context.Context) error,
) error {
	start := time.Now()
	ctx, span := im.tracer.Start(ctx, fmt.Sprintf("DB %s", queryName),
		trace.WithSpanKind(trace.SpanKindClient),
		trace.WithAttributes(
			attribute.String("db.query.name", queryName),
		),
	)
	defer span.End()

	err := fn(ctx)
	duration := time.Since(start)

	im.metrics.RecordDBQuery(ctx, queryName, duration, err)

	if err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		im.logger.ErrorContext(ctx, "db query failed",
			"query", queryName,
			"error", err.Error(),
			"duration_ms", duration.Milliseconds(),
		)
	} else {
		im.logger.InfoContext(ctx, "db query completed",
			"query", queryName,
			"duration_ms", duration.Milliseconds(),
		)
	}

	return err
}

// InstrumentGRPC gRPCオブザーバビリティインターセプター(サーバー側)
func (im *InstrumentationMiddleware) InstrumentGRPC(
	ctx context.Context,
	req interface{},
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (interface{}, error) {
	start := time.Now()

	ctx, span := im.tracer.Start(ctx, info.FullMethod,
		trace.WithSpanKind(trace.SpanKindServer),
	)
	defer span.End()

	resp, err := handler(ctx, req)
	duration := time.Since(start)

	im.metrics.RecordHTTPRequest(ctx, "gRPC", info.FullMethod,
		statusFromError(err), duration)

	if err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		im.logger.ErrorContext(ctx, "grpc call failed",
			"method", info.FullMethod,
			"error", err.Error(),
			"duration_ms", duration.Milliseconds(),
		)
	} else {
		im.logger.InfoContext(ctx, "grpc call completed",
			"method", info.FullMethod,
			"duration_ms", duration.Milliseconds(),
		)
	}

	return resp, err
}

重要ポイント

  • 統合ミドルウェアがログ・メトリクス・トレーシングの3つの柱を同時に処理、データの一貫性を確保
  • instrumentedWriterがレスポンスステータスコードとバイト数をキャプチャ、トレースとメトリクスのディメンジョンを豊かに
  • InstrumentDBデコレーターがデータベースクエリにオブザーバビリティを追加、ビジネスコードの修正不要
  • gRPCインターセプターとHTTPミドルウェアが同じオブザーバビリティインフラを共有

コアパターン5:プロダクション級オブザーバビリティダッシュボード統合

ログ・メトリクス・トレーシングの3つの柱をGrafanaダッシュボードに統合し、アラートからログ、トレースへのワンクリックナビゲーションを実現することが、プロダクション級オブザーバビリティの最終目標です。

package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.opentelemetry.io/otel/exporters/prometheus"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

// ObservabilityStack プロダクション級オブザーバビリティスタック
type ObservabilityStack struct {
	logger          *Logger
	metricsProvider *MetricsProvider
	tracingProvider *TracingProvider
	middleware      *InstrumentationMiddleware
	metrics         *AppMetrics
	shutdownFuncs   []func(ctx context.Context) error
}

// Config オブザーバビリティスタック設定
type Config struct {
	ServiceName    string
	OTLPEndpoint   string
	MetricsPath    string
	LogLevel       string
	SamplingRatio  float64
}

// NewObservabilityStack は完全なオブザーバビリティスタックを初期化
func NewObservabilityStack(ctx context.Context, cfg Config) (*ObservabilityStack, error) {
	stack := &ObservabilityStack{}

	// 1. 構造化ログを初期化
	stack.logger = NewLogger(cfg.ServiceName)

	// 2. メトリクス収集を初期化
	promExporter, err := prometheus.New()
	if err != nil {
		return nil, fmt.Errorf("create prometheus exporter: %w", err)
	}

	metricProvider := sdkmetric.NewMeterProvider(
		sdkmetric.WithReader(promExporter),
	)
	stack.metricsProvider = &MetricsProvider{
		provider: metricProvider,
		meter:    metricProvider.Meter(cfg.ServiceName),
	}
	stack.shutdownFuncs = append(stack.shutdownFuncs, metricProvider.Shutdown)

	// 3. 分散トレーシングを初期化
	otlpExporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
		otlptracegrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("create OTLP exporter: %w", err)
	}

	traceProvider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(otlpExporter),
		sdktrace.WithSampler(sdktrace.ParentBased(
			sdktrace.TraceIDRatioBased(cfg.SamplingRatio),
		)),
	)
	stack.tracingProvider = &TracingProvider{
		provider: traceProvider,
		tracer:   traceProvider.Tracer(cfg.ServiceName),
	}
	stack.shutdownFuncs = append(stack.shutdownFuncs, traceProvider.Shutdown)

	// 4. アプリケーションメトリクスを初期化
	stack.metrics, err = NewAppMetrics(stack.metricsProvider)
	if err != nil {
		return nil, fmt.Errorf("create app metrics: %w", err)
	}

	// 5. 統合ミドルウェアを初期化
	stack.middleware = NewInstrumentationMiddleware(
		stack.logger,
		stack.metrics,
		stack.tracingProvider,
	)

	return stack, nil
}

// Shutdown は全オブザーバビリティコンポーネントを gracefulにシャットダウン
func (s *ObservabilityStack) Shutdown(ctx context.Context) error {
	var firstErr error
	for _, fn := range s.shutdownFuncs {
		if err := fn(ctx); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}

// --- 完全な起動例 ---

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// オブザーバビリティスタックを初期化
	stack, err := NewObservabilityStack(ctx, Config{
		ServiceName:   "user-service",
		OTLPEndpoint:  "otel-collector:4317",
		MetricsPath:   "/metrics",
		LogLevel:      "info",
		SamplingRatio: 0.1,
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "init observability: %v\n", err)
		os.Exit(1)
	}
	defer stack.Shutdown(context.Background())

	// HTTPルーターを作成
	mux := http.NewServeMux()

	// ビジネスルートを登録(オブザーバビリティミドルウェア付き)
	mux.HandleFunc("GET /users/{id}", handleGetUser(stack.logger))
	mux.HandleFunc("POST /users", handleCreateUser(stack.logger, stack.metrics))

	// Prometheusメトリクスエンドポイントを登録
	mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
		promHandler := prometheus.Handler()
		promHandler.ServeHTTP(w, r)
	})

	// ヘルスチェック
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	// オブザーバビリティミドルウェアを適用
	handler := stack.middleware.InstrumentHTTP(mux)
	handler = middlewareRequestID(handler)

	// HTTPサーバーを起動
	server := &http.Server{
		Addr:         ":8080",
		Handler:      handler,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	// Gracefulシャットダウン
	go func() {
		sigCh := make(chan os.Signal, 1)
		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
		<-sigCh

		shutdownCtx, shutdownCancel := context.WithTimeout(
			context.Background(), 10*time.Second,
		)
		defer shutdownCancel()

		stack.logger.InfoContext(ctx, "shutting down server...")
		server.Shutdown(shutdownCtx)
		stack.Shutdown(shutdownCtx)
	}()

	stack.logger.InfoContext(ctx, "server starting",
		"addr", server.Addr,
		"service", "user-service",
	)

	if err := server.ListenAndServe(); err != http.ErrServerClosed {
		stack.logger.ErrorContext(ctx, "server error", "error", err.Error())
		os.Exit(1)
	}
}

Grafanaダッシュボード設定のポイント

# docker-compose.yaml - 完全オブザーバビリティスタック
version: '3.8'
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol/config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP

  prometheus:
    image: prom/prometheus:v2.50.0
    volumes:
      - ./prometheus.yaml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

  loki:
    image: grafana/loki:2.9.4
    ports:
      - "3100:3100"

  tempo:
    image: grafana/tempo:2.3.1
    command: ["-config.file=/etc/tempo/tempo.yaml"]
    volumes:
      - ./tempo.yaml:/etc/tempo/tempo.yaml
    ports:
      - "3200:3200"

  grafana:
    image: grafana/grafana:10.3.3
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
      - ./grafana-dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml
    ports:
      - "3000:3000"

重要ポイント

  • ObservabilityStackが3つの柱のライフサイクルを統合管理、Gracefulシャットダウンを確保
  • Prometheus + Loki + Tempo + Grafanaが完全なオブザーバビリティバックエンドを構成
  • Grafanaデータソース設定でTraceIDナビゲーションを有効化、ログからトレースへのワンクリック関連付けを実現
  • サンプリング率は設定で制御、本番環境では10%サンプリングを推奨、エラーチェーンは自動100%サンプリング

よくある落とし穴

落とし穴1:slogのWith()メソッドは属性をコピーしない

// ❌ 間違い:With()は新しいLoggerを返し、元のLoggerは影響を受けない
logger := slog.Default().With("request_id", "abc123")
logger.Info("message") // request_idあり
slog.Info("message")   // request_idなし!

// ✅ 正しい:With()が返す新しいLoggerを常に使用
baseLogger := slog.Default()
requestLogger := baseLogger.With("request_id", "abc123")
requestLogger.Info("message") // request_idあり

落とし穴2:HTTPクライアントでトレースコンテキストの伝播を忘れる

// ❌ 間違い:http.NewRequestを直接使用、トレースチェーンが途切れる
req, _ := http.NewRequest("GET", "http://user-service/users/123", nil)
resp, _ := http.DefaultClient.Do(req)

// ✅ 正しい:NewRequestWithContextを使用しプロパゲーターを注入
req, _ := http.NewRequestWithContext(ctx, "GET", "http://user-service/users/123", nil)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
resp, _ := http.DefaultClient.Do(req)

落とし穴3:Histogramバケット境界の不適切な設定

// ❌ 間違い:デフォルトバケット境界を使用、正常と異常レイテンシを区別できない
am.httpRequestDuration, _ = m.Float64Histogram("http.server.duration")

// ✅ 正しい:カスタムバケット境界、HTTPリクエストレイテンシ分布に適合
am.httpRequestDuration, _ = m.Float64Histogram(
	"http.server.duration",
	metric.WithUnit("s"),
)
// MeterProviderでViewを設定しカスタムバケット境界を構成
sdkmetric.WithView(
	sdkmetric.NewView(
		sdkmetric.Instrument{Name: "http.server.duration"},
		sdkmetric.Stream{
			Aggregation: sdkmetric.AggregationExplicitBucketHistogram{
				Boundaries: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
			},
		},
	),
)

落とし穴4:ホットパスで大量の属性を作成

// ❌ 間違い:リクエストごとに新しいattribute sliceを作成
span.SetAttributes(
	attribute.String("user.id", userID),
	attribute.String("request.path", path),
	attribute.String("user.agent", userAgent),
)

// ✅ 正しい:よく使う属性を事前作成し、GCプレッシャーを削減
var (
	attrUserID    = attribute.Key("user.id")
	attrReqPath   = attribute.Key("request.path")
	attrUserAgent = attribute.Key("user.agent")
)

span.SetAttributes(
	attrUserID.String(userID),
	attrReqPath.String(path),
	attrUserAgent.String(userAgent),
)

落とし穴5:TracerProviderのGracefulシャットダウン未実行でSpanが損失

// ❌ 間違い:プロセスを直接終了、BatchSpanProcessor内のSpanが損失
func main() {
	tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
	// ... プログラム実行 ...
	// プロセス終了、未FlushのSpanが損失!
}

// ✅ 正しい:終了前にShutdownを呼び出し全Spanのエクスポートを確保
func main() {
	tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
	defer func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		tp.Shutdown(ctx) // 全SpanのFlushを確保
	}()
	// ... プログラム実行 ...
}

エラートラブルシューティング

エラー現象 可能な原因 トラブルシューティング方法 解決策
ログにtrace_idがない contextからtrace情報を抽出していない WithContextが正しく呼ばれているか確認 ミドルウェアがログより前に初期化されていることを確認
Prometheusにメトリクスデータがない MeterProviderにReaderが登録されていない /metricsエンドポイントが正常か確認 prometheus.New()WithReaderに渡されていることを確認
トレースチェーンがサービス境界で途切れる TraceContextが伝播されていない HTTPクライアントがInjectを呼んでいるか確認 クライアントリクエスト前にプロパゲーターを注入
SpanデータがJaegerに遅延表示される BatchSpanProcessorのバッチ送信 Shutdownが呼ばれているか確認 Gracefulシャットダウン時にtp.Shutdown(ctx)を呼び出し
メトリクス値が異常に高い UpDownCounterが正しく減少していない Add(-1)が全パスで実行されているか確認 deferで減少を確保
GrafanaがLokiログをクエリできない Lokiデータソースの設定エラー Loki URLとLabel設定を確認 {service="xxx"}ラベルの一致を確認
サンプリング率設定後も全量サンプリング サンプラー設定が上書きされている AlwaysSampleによる上書きがないか確認 ParentBasedでサンプラーをラップ
gRPCトレースSpanが欠落 インターセプターが未登録 gRPC Serverにインターセプターが追加されているか確認 otelgrpc.ServerInterceptorを使用
ログ出力がプレーンテキストでJSONではない デフォルトのTextHandlerを使用 slog.NewのHandlerパラメータを確認 JSONHandlerに置き換え
OTLPエクスポート接続タイムアウト Collectorが未起動またはポート誤り Collectorの状態とポートを確認 ポート4317の到達可能性を確認

高度な最適化

1. 適応型サンプリング戦略

リクエスト特性に基づいてサンプリング率を動的に調整:エラーリクエストは100%サンプリング、遅延リクエストは50%、通常リクエストは1%。ShouldSampleインターフェースでカスタムサンプリングロジックを実装。

2. ログとトレースの自動関連付け

カスタムslog.Handlerを実装し、各ログエントリに現在のSpanのTraceIDとSpanIDを自動注入。手動伝播なしでログからトレースへのワンクリックナビゲーションを実現。

3. メトリクスカーディナリティ制御

高カーディナリティラベル(user_idなど)はPrometheusのメモリ爆発を引き起こす可能性。attribute.KeyValueFilterCardinalityLimit設定でラベルカーディナリティを制限し、メトリクス爆発を防止。

4. Exemplarによるメトリクスとトレースの関連付け

PrometheusメトリクスにExemplar(TraceIDを含むサンプル)を埋め込み、メトリクスチャートからトレース詳細への直接ナビゲーションを実現。OpenTelemetry SDKはExemplarをネイティブサポート。

5. マルチクラスター連合監視

マルチクラスター展開では、Prometheus Federation + Thanosでグローバルメトリクスビューを実現し、Grafanaで複数データソースを設定してクラスター間オブザーバビリティを実現。

比較

次元 slog + OTel Zap + Prometheus Logrus + Jaeger
構造化ログ ✅ 標準ライブラリネイティブ ✅ サードパーティライブラリ ⚠️ Hookが必要
メトリクス収集 ✅ OTel統合SDK ✅ ネイティブPrometheus ❌ 追加統合が必要
分散トレーシング ✅ OTelネイティブサポート ❌ 追加統合が必要 ✅ Jaegerクライアント
コンテキスト伝播 ✅ Contextネイティブ統合 ⚠️ 手動伝播が必要 ❌ 非サポート
サンプリング制御 ✅ 複数サンプラー内蔵 ❌ 該当なし ⚠️ 限定的サポート
標準互換性 ✅ W3C/CNCF標準 ⚠️ Prometheus専用 ⚠️ Jaeger専用
保守コスト ✅ 標準ライブラリ+CNCF保守 ⚠️ コミュニティ保守 ❌ 保守終了
学習曲線 ⚠️ OTel概念が多い ✅ シンプルで直接的 ✅ シンプル

まとめ

2026年のGoオブザーバビリティにおいて、slog + OpenTelemetryはデファクトスタンダードとなりました。fmt.Printlnで本番問題をデバッグするのはやめましょう。ログとメトリクスを分断したままにしないでください。トレースチェーンがサービス境界で途切れるのを防ぎましょう。構造化ログから始め、段階的にメトリクスとトレーシングを統合し、最終的に3つの柱を統一したオブザーバビリティシステムを構築する——それがGoサービスをプロダクション化する正しいアプローチです。

オンラインツールおすすめ

  • JSONフォーマッター — slog出力のJSONログをフォーマットし、問題フィールドを素早く特定
  • cURL to Code — cURLコマンドをGo HTTPクライアントコードに変換、トレースコンテキスト伝播を素早く検証
  • ハッシュ計算 — リクエストIDのハッシュ値を計算、ログのマスキングとサンプリングバケッティングに使用

ブラウザローカルツールを無料で試す →

#Go遥测#可观测性#OpenTelemetry#2026#技术架构