ロギングとオブザーバビリティ
.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> / Serilog | log/slog / zap / zerolog |
| ActivitySource / OpenTelemetry .NET | go.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 ... Critical | slog.LevelDebug / Info / Warn / Error |
logger.BeginScope(...) | logger.With(...) と、ctx に載せた値を取り出す独自ハンドラ |
appsettings.json の Logging:LogLevel | slog.LevelVar + 環境変数や設定からの変更 (自前で実装) |
[LoggerMessage] ソースジェネレーター | logger.LogAttrs (割り当てを抑えた書き方) |
Activity / ActivitySource.StartActivity | span / tracer.Start(ctx, ...) |
Meter / Counter<T> / Histogram<T> | otel.Meter / Int64Counter / Float64Histogram |
Activity.Current | trace.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 で行います。