メインコンテンツまでスキップ

ロギングとオブザーバビリティ

.NET (C#) の経験がある開発者向けに、log/slog、構造化ログ、OpenTelemetry、メトリクスを解説します。C# では ILogger<T> を DI で受け取り、Microsoft.Extensions.Logging の基盤にプロバイダーを差し込む形でログを扱います。Go 1.21 以降は標準ライブラリに構造化ログの log/slog が加わり、ログの API (slog.Logger) と出力先 (slog.Handler) が分離された、似た構造を標準だけで持てるようになりました。トレースとメトリクスは OpenTelemetry Go を使い、context.Context を介してログ・トレースを相関させるのが基本の流れです。

1. このドキュメントで扱うこと​

  • slog のハンドラとレベル
  • リクエストIDとcontextによる相関
  • OpenTelemetry のトレース・メトリクス計装

slog のハンドラとレベル​

log/slog は、ログを「メッセージ + 属性 (キーと値の組)」として扱う構造化ログです。

  • slog.Logger: ログを書き込む側の API です。Debug / Info / Warn / Error と、context.Context を受け取る DebugContext / InfoContext / WarnContext / ErrorContext があります。
  • slog.Handler: ログの出力形式と出力先を決める側のインターフェースです。標準で slog.NewTextHandler (key=value 形式) と slog.NewJSONHandler (JSON) が用意されています。本番では JSON が一般的です。
  • レベル: slog.LevelDebug (-4) / LevelInfo (0) / LevelWarn (4) / LevelError (8) です。既定の最小レベルは Info なので、Debug は出力されません。slog.HandlerOptions{Level: ...} で変更でき、slog.LevelVar を使うと実行中にレベルを切り替えられます。C# の Trace / Critical に相当する組み込みレベルはありません (数値で独自レベルを定義することは可能です)。
  • 共通属性とグループ: logger.With("service", "orders") で、以降のすべてのログに付く属性を持つ新しい Logger を作れます。slog.Group や logger.WithGroup で属性をネストできます。C# のログスコープ (BeginScope) に近い使い方ができます。
  • 既定のロガー: slog.SetDefault で設定すると、パッケージ関数 slog.Info(...) と、従来の log.Printf の出力が同じハンドラを通るようになります。

リクエストIDとcontextによる相関​

C# では ILogger.BeginScope や Activity を使って、リクエスト単位の情報を以降のログに自動で付与できます。Go ではスコープの仕組みがなく、context.Context に値を載せて運び、ログを書くときに取り出します。

  • 呼び出しチェーンに沿って ctx を渡し続けます (詳細は「contextパッケージ」を参照)。
  • logger.InfoContext(ctx, ...) のように ctx を渡して書き込みます。logger.Info(...) は context.Background() を渡した扱いになるため、ハンドラは ctx の値を参照できません。
  • slog.Handler を包む独自ハンドラ (ラッパー) の Handle メソッドで、ctx からリクエスト ID やトレース ID を取り出して属性に追加すれば、呼び出し側は何も意識せずに相関 ID を出力できます。

OpenTelemetry のトレース・メトリクス計装​

トレースとメトリクスは OpenTelemetry (go.opentelemetry.io/otel) で扱うのが標準的です。API と SDK が分離されている点は .NET と同じです。

  • API (go.opentelemetry.io/otel、.../otel/trace、.../otel/metric): 計装コードが使う側です。otel.Tracer("名前") や otel.Meter("名前") で取得します。
  • SDK (.../otel/sdk/trace、.../otel/sdk/metric): 実際にデータを処理して送る側です。TracerProvider / MeterProvider を作り、otel.SetTracerProvider / otel.SetMeterProvider でグローバルに登録します。登録するまでは何も出力されない (no-op) 点に注意してください。
  • エクスポーター: OTLP (.../exporters/otlp/otlptrace/otlptracehttp など) でコレクターやバックエンドへ送ります。動作確認には標準出力に書く stdouttrace / stdoutmetric が便利です。
  • トレース: tracer.Start(ctx, "名前") が、新しい ctx と span を返します。子スパンはその ctx を引き継いで作られるため、ctx を渡し続けることが親子関係の前提です。処理が終わったら span.End() を呼びます。
  • メトリクス: meter.Int64Counter や meter.Float64Histogram などの計器を作り、Add / Record に ctx と属性を渡します。
  • HTTP の自動計装: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp が、サーバーのハンドラとクライアントの Transport をラップして、スパンの作成とトレースコンテキストの伝播を行います。

2. .NET との対応表​

.NET (C#)Go
ILogger<T> / Seriloglog/slog / zap / zerolog
ActivitySource / OpenTelemetry .NETgo.opentelemetry.io/otel
Health Checks自前の /healthz ハンドラ

上の表を補う対応関係です。

.NET (C#)Go
ILogger (抽象) とプロバイダーslog.Logger と slog.Handler
logger.LogInformation("... {OrderId}", id)logger.InfoContext(ctx, "...", "order_id", id)
LogLevel.Trace ... Criticalslog.LevelDebug / Info / Warn / Error
logger.BeginScope(...)logger.With(...) と、ctx に載せた値を取り出す独自ハンドラ
appsettings.json の Logging:LogLevelslog.LevelVar + 環境変数や設定からの変更 (自前で実装)
[LoggerMessage] ソースジェネレーターlogger.LogAttrs (割り当てを抑えた書き方)
Activity / ActivitySource.StartActivityspan / tracer.Start(ctx, ...)
Meter / Counter<T> / Histogram<T>otel.Meter / Int64Counter / Float64Histogram
Activity.Currenttrace.SpanFromContext(ctx)
AddOpenTelemetry() (DI 登録)TracerProvider / MeterProvider を作成し、otel.Set...Provider で登録
ASP.NET Core の計装ライブラリotelhttp などの contrib パッケージで明示的にラップ
IHealthCheck / MapHealthChecks/healthz (liveness) と /readyz (readiness) の自前ハンドラ

3. C# 開発者がつまずきやすいポイント​

ILogger<T> のようなカテゴリ別ロガーの DI はない​

C# では型ごとに ILogger<OrderService> が注入され、カテゴリが自動で付きます。Go には DI コンテナもカテゴリの概念もありません。*slog.Logger をコンストラクターで受け取って構造体のフィールドに持つか、logger.With("component", "orders") で識別用の属性を付けます。標準では Logger をグローバルに置く (slog.Default()) こともできますが、テストしやすさのため、依存として明示的に渡す方が扱いやすくなります。

属性は「キー, 値」の交互引数で、型チェックが弱い​

logger.Info("msg", "user_id", id) のような ...any の書き方は、キーと値の数が合わないと !BADKEY という出力になり、コンパイルエラーにはなりません。go vet の slog チェックが引数の不整合を検出します。型安全に書きたい場合は slog.String("user_id", id) のように slog.Attr を渡すか、logger.LogAttrs(ctx, slog.LevelInfo, "msg", attrs...) を使います。

ctx を渡さないと相関 ID が出ない​

logger.Info(...) は ctx を持たないため、ctx に載せた値をハンドラが読めません。リクエストの処理中は InfoContext / ErrorContext を使う習慣にします。

ラッパーハンドラでは WithAttrs / WithGroup も実装する​

slog.Handler を埋め込んだ独自ハンドラが Handle だけを上書きすると、logger.With(...) の呼び出しで内側のハンドラが返され、ラッパーが外れてしまいます (相関 ID が出なくなります)。WithAttrs と WithGroup も、ラッパー自身を返すように実装します (次節の例を参照)。

構造化ログは「値」を渡す。文字列に埋め込まない​

fmt.Sprintf で組み立てたメッセージにすべてを埋め込むと、検索や集計ができません。メッセージは固定の文章にして、変動する値は属性として分けて渡します。これは C# のメッセージテンプレートと同じ考え方です。

エラーは属性として渡す​

エラーは slog.Any("error", err) か、慣習として "error", err で渡します。呼び出し階層をまたぐ場合は、fmt.Errorf("...: %w", err) で文脈を足しながら返し、ログへの出力は 1 か所 (最上位) にします。各層で「ログして、さらに返す」と、同じエラーが何重にも出力されます。

機微情報は LogValuer で隠す​

構造体をそのまま属性に渡すと、全フィールドが出力されます。LogValue() slog.Value を実装した型 (slog.LogValuer) なら、出力する内容を型側で制御でき、パスワードやトークンのマスクをまとめて管理できます。

引数の評価コストと無効なレベル​

logger.Debug("dump", "data", expensive()) の expensive() は、Debug が無効でも呼び出されます。重い処理は if logger.Enabled(ctx, slog.LevelDebug) { ... } で囲みます。

OpenTelemetry は自動では動かない​

.NET の AddOpenTelemetry() と ASP.NET Core の組み込みの計装に相当する自動計装は、Go にはほとんどありません (ネイティブにコンパイルされ、実行時にコードを差し込む手段が乏しいため)。次の点に注意してください。

  • TracerProvider / MeterProvider を自分で作り、グローバルに登録しない限り、すべての計装は no-op です。
  • span.End() の呼び忘れはスパンが送られない原因になります。defer span.End() を取得の直後に書きます。
  • アプリ終了前に tp.Shutdown(ctx) / mp.Shutdown(ctx) を呼ばないと、バッファ中のデータが失われることがあります。
  • HTTP サーバー・クライアントは otelhttp で明示的にラップしないと、スパンも伝播も生まれません。
  • スパンの親子関係は ctx で決まります。途中で context.Background() に差し替えると、トレースが分断されます。
  • エラーはスパンに自動では記録されません。span.RecordError(err) と span.SetStatus(codes.Error, ...) を自分で呼びます。

メトリクスの属性は低カーディナリティに​

メトリクスの属性 (ラベル) に、ユーザー ID やリクエスト ID のような値の種類が多いものを入れると、時系列が爆発してコストと性能の問題を起こします。そうした識別子はログやトレースに載せ、メトリクスには状態やルートのような少数の値だけを使います。

4. コード例​

例 1: slog の基本 (レベル、JSON、共通属性)​

C# (ILogger<T>):

public class OrderService(ILogger<OrderService> logger)
{
public void Process(string orderId)
{
using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = orderId }))
{
logger.LogInformation("Processing order {OrderId}", orderId);
logger.LogWarning("Stock is low");
}
}
}

Go:

package main

import (
"log/slog"
"os"
)

func main() {
// ログレベルは実行中に切り替えられる
var level slog.LevelVar
level.Set(slog.LevelInfo)

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &level,
})
logger := slog.New(handler).With("service", "orders") // 以降のすべてのログに付く

orderLogger := logger.With("order_id", "A-1001")
orderLogger.Info("processing order", "items", 3)
orderLogger.Warn("stock is low", slog.Group("sku", slog.String("id", "X-1"), slog.Int("left", 2)))

logger.Debug("not printed") // Info 未満なので出力されない
level.Set(slog.LevelDebug)
logger.Debug("now printed")

slog.SetDefault(logger) // slog.Info(...) と log.Printf の出力もこのハンドラを通る
slog.Info("started")
}

出力は、{"time":"...","level":"INFO","msg":"processing order","service":"orders","order_id":"A-1001","items":3} のような 1 行 1 レコードの JSON になります。

例 2: context からリクエスト ID を取り出す独自ハンドラ​

HttpContext.Items やログスコープに入れていた値を、context.Context と独自ハンドラで扱う例です。

package main

import (
"context"
"log/slog"
"net/http"
"os"
)

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}

// contextHandler は ctx からリクエスト ID を取り出して属性に追加する
type contextHandler struct {
slog.Handler
}

func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}

// WithAttrs / WithGroup もラッパー自身を返す (でないと logger.With でラッパーが外れる)
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return contextHandler{h.Handler.WithAttrs(attrs)}
}

func (h contextHandler) WithGroup(name string) slog.Handler {
return contextHandler{h.Handler.WithGroup(name)}
}

var logger = slog.New(contextHandler{slog.NewJSONHandler(os.Stdout, nil)})

func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = "req-12345" // 実際には UUID などを生成する
}
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}

func handler(w http.ResponseWriter, r *http.Request) {
// ctx を渡して書くと、request_id が自動で付く
logger.InfoContext(r.Context(), "handling request", "path", r.URL.Path)
w.WriteHeader(http.StatusOK)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", handler)
http.ListenAndServe(":8080", requestIDMiddleware(mux))
}

例 3: OpenTelemetry のトレース・メトリクスとログの相関​

C# では ActivitySource でスパンを作り、Meter でカウンターを記録する場面です。

C#:

static readonly ActivitySource Source = new("Orders");
static readonly Meter Meter = new("Orders");
static readonly Counter<long> Processed = Meter.CreateCounter<long>("orders.processed");

using var activity = Source.StartActivity("ProcessOrder");
activity?.SetTag("order.id", orderId);
Processed.Add(1, new KeyValuePair<string, object?>("result", "ok"));

Go (標準出力に出すエクスポーターを使った確認用の構成です。本番では OTLP エクスポーターに差し替えます):

go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/sdk/metric \
go.opentelemetry.io/otel/exporters/stdout/stdouttrace \
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric
package main

import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)

var (
tracer = otel.Tracer("example/orders")
processed metric.Int64Counter
)

// setupOTel は TracerProvider / MeterProvider を作ってグローバルに登録する
func setupOTel(ctx context.Context) (func(context.Context) error, error) {
res := resource.NewSchemaless(attribute.String("service.name", "orders-api"))

traceExp, err := stdouttrace.New()
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)

metricExp, err := stdoutmetric.New()
if err != nil {
return nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)

otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))

// 終了時にバッファ中のデータを送り切る
return func(ctx context.Context) error {
return errors.Join(tp.Shutdown(ctx), mp.Shutdown(ctx))
}, nil
}

// otelHandler は ctx のスパンから trace_id / span_id をログに付与する
type otelHandler struct {
slog.Handler
}

func (h otelHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}

func (h otelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return otelHandler{h.Handler.WithAttrs(attrs)}
}

func (h otelHandler) WithGroup(name string) slog.Handler {
return otelHandler{h.Handler.WithGroup(name)}
}

func processOrder(ctx context.Context, logger *slog.Logger, orderID string) error {
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End()
span.SetAttributes(attribute.String("order.id", orderID))

logger.InfoContext(ctx, "processing order", "order_id", orderID)

if err := charge(ctx, orderID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
processed.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "error")))
return fmt.Errorf("process order %s: %w", orderID, err)
}

processed.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "ok")))
return nil
}

func charge(ctx context.Context, orderID string) error {
_, span := tracer.Start(ctx, "Charge") // 子スパン (ctx を引き継ぐ)
defer span.End()
time.Sleep(20 * time.Millisecond)
return nil
}

func main() {
ctx := context.Background()

shutdown, err := setupOTel(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "setup:", err)
os.Exit(1)
}
defer shutdown(ctx)

processed, err = otel.Meter("example/orders").Int64Counter(
"orders.processed",
metric.WithDescription("Number of processed orders"),
)
if err != nil {
fmt.Fprintln(os.Stderr, "counter:", err)
os.Exit(1)
}

logger := slog.New(otelHandler{slog.NewJSONHandler(os.Stdout, nil)})
if err := processOrder(ctx, logger, "A-1001"); err != nil {
logger.ErrorContext(ctx, "failed", "error", err)
}
}

ポイントは次のとおりです。

  • 処理の入り口で tracer.Start(ctx, ...) が返す ctx を、以降の呼び出しとログに渡し続けます。これでスパンの親子関係と、ログの trace_id / span_id の相関が成立します。
  • メトリクスの属性は result のような少数の値に限ります。
  • OTLP で送る場合は、標準出力のエクスポーターを otlptracehttp / otlpmetrichttp などに置き換えます。エンドポイントは OTEL_EXPORTER_OTLP_ENDPOINT などの環境変数で指定できます。
  • ログを OpenTelemetry のログパイプラインへ送りたい場合は、contrib の otelslog ブリッジ (go.opentelemetry.io/contrib/bridges/otelslog) があります。ログの SDK は他のシグナルより新しい領域のため、利用時に安定性を確認してください。

例 4: ヘルスチェック (/healthz と /readyz)​

C# の MapHealthChecks に相当するものを、自前のハンドラで書きます。

C#:

builder.Services.AddHealthChecks().AddSqlServer(connectionString);
app.MapHealthChecks("/healthz");

Go:

package main

import (
"context"
"database/sql"
"net/http"
"time"
)

// liveness: プロセスが生きていれば 200 を返す (依存先は見ない)
func healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}

// readiness: 依存先 (DB) に接続できるかを確認する
func readyz(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

if err := db.PingContext(ctx); err != nil {
http.Error(w, "db unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
}

func newMux(db *sql.DB) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("GET /readyz", readyz(db))
return mux
}

func main() {
// 実際には sql.Open でドライバーを指定して接続する
var db *sql.DB
_ = http.ListenAndServe(":8080", newMux(db))
}

Kubernetes では、/healthz を livenessProbe、/readyz を readinessProbe に割り当てるのが一般的です。liveness で依存先を確認すると、DB 障害のときに全 Pod が再起動を繰り返すことがあるため、依存先の確認は readiness に限ります。

5. まとめ​

  • 構造化ログは標準の log/slog で書けます。Logger (書く側) と Handler (出力形式・出力先) が分かれ、本番では NewJSONHandler が一般的です。
  • ログレベルは Debug / Info / Warn / Error で、slog.LevelVar により実行中に切り替えられます。
  • 相関 ID は context.Context に載せ、InfoContext と、Handle / WithAttrs / WithGroup を実装した独自ハンドラで自動的にログへ付与します。
  • 引数は「キー, 値」の交互で型チェックが弱いため、go vet や slog.Attr を活用し、値は文字列に埋め込まず属性で渡します。
  • OpenTelemetry は API と SDK が分離され、TracerProvider / MeterProvider を自分で作って登録し、defer span.End() と Shutdown を忘れずに呼びます。
  • スパンの親子関係は ctx で決まるため、tracer.Start が返した ctx を渡し続けます。エラーは RecordError と SetStatus で明示的に記録します。
  • メトリクスの属性は低カーディナリティに保ちます。
  • ヘルスチェックは自前のハンドラで、liveness と readiness を分け、依存先の確認は readiness で行います。

参考資料​