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を自動注入/抽出、サービス間伝播を実現SpanBuilderFluent 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.KeyValueFilterやCardinalityLimit設定でラベルカーディナリティを制限し、メトリクス爆発を防止。
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のハッシュ値を計算、ログのマスキングとサンプリングバケッティングに使用
ブラウザローカルツールを無料で試す →