Carpe Diem

備忘録

Google Cloud Logging と Cloud Trace でログとトレースを関連付ける

背景

オブザーバビリティ環境を整備する中で、Go で Google Cloud Logging と Cloud Trace のログとトレースを紐付けることで横断調査しやすいよう対応した際のメモです。

環境

  • Go 1.26.2
  • zap v1.21.0
  • zapdriver v1.3.1

対応

前提

Cloud Logging にはいくつかの予約フィールドがあります。

cloud.google.com

その中で次のフィールドを設定すると、Cloud Logging と Cloud Trace の関連付けができます。

JSON キー 中身
logging.googleapis.com/trace projects/<PROJECT_ID>/traces/<TRACE_ID>(TRACE_ID 単体でも可)
logging.googleapis.com/spanId 16 桁 hex(例: 000000000000004a
logging.googleapis.com/trace_sampled true / false

各ロガーで、上記フィールドに必要な情報を付与します。OTel なら値は以下の様に trace.SpanContext から取れます。

sc := span.SpanContext()
traceName := fmt.Sprintf("projects/%s/traces/%s", projectID, sc.TraceID().String())
spanID := sc.SpanID().String()
sampled := sc.IsSampled()

実装方針

先ほどの値を毎回の Info に 3 フィールドを直書きしても動きますが、本番でそれを愚直に書くと実装漏れが出るので、ロガーのハンドラー層で共通実装すると良いです。

slog と zap での実装例を紹介します。

slog

slogは次のように出力時にHandlerインタフェースのHandle()を通ります。

slog.InfoContext(ctx, "charged", ...)
  └─ Default().log(ctx, LevelInfo, "charged", ...)
       ├─ ctx == nil なら context.Background()
       ├─ Logger.Enabled(ctx, level)
       │    └─ Handler.Enabled(ctx, level)     // false ならここで終了(Handle は呼ばない)
       ├─ runtime.Callers で PC を取る
       ├─ slog.NewRecord(now, level, msg, pc)
       ├─ record.Add(args...)
       └─ Handler.Handle(ctx, record)          // ← ここが spanHandler.Handle
            ├─ SpanContextFromContext(ctx) で予約キーを AddAttrs
            └─ 内側の JSONHandler.Handle(ctx, record)
                 └─ commonHandler.handle(record) が JSON を Write

なのでそこに埋め込んであげれば共通実装が可能です。

func setupLogging(projectID string) {
    h := slog.NewJSONHandler(os.Stdout, nil)
    slog.SetDefault(slog.New(&spanHandler{
        Handler:   h,
        projectID: projectID,
    }))
}

type spanHandler struct {
    slog.Handler
    projectID string
}

func (h *spanHandler) Handle(ctx context.Context, r slog.Record) error {
    if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
        r.AddAttrs(
            slog.String("logging.googleapis.com/trace", fmt.Sprintf("projects/%s/traces/%s", h.projectID, sc.TraceID().String())),
            slog.String("logging.googleapis.com/spanId", sc.SpanID().String()),
            slog.Bool("logging.googleapis.com/trace_sampled", sc.IsSampled()),
        )
    }
    return h.Handler.Handle(ctx, r)
}

func (h *spanHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &spanHandler{Handler: h.Handler.WithAttrs(attrs), projectID: h.projectID}
}

func (h *spanHandler) WithGroup(name string) slog.Handler {
    return &spanHandler{Handler: h.Handler.WithGroup(name), projectID: h.projectID}
}

公式の Go instrumentation sample の実装方法はこちらですね。

注意点として、ctxを引き継げるようslog.Info ではなく slog.InfoContext を使いその時点の ctx を渡します。

Zap

Zap本家のAPIはctxを受け取るシグネチャになっていないので、ヘルパー関数を用意するなど軽くラップしてあげるのが良いでしょう。

またCloud Loggingであれば以前紹介した

christina04.hatenablog.com

zapdriverにある、zapdriver.TraceContext に任せるとシンプルに書けます。

func gcpTrace(ctx context.Context, projectID string) []zap.Field {
    sc := trace.SpanContextFromContext(ctx)
    if !sc.IsValid() {
        return nil
    }
    return zapdriver.TraceContext(sc.TraceID().String(), sc.SpanID().String(), sc.IsSampled(), projectID)
}

func Info(ctx context.Context, l *zap.Logger, msg string, fields ...zap.Field) {
    l.Info(msg, append(gcpTrace(ctx, projectID), fields...)...)
}

使う時はこんな感じです。

// 呼び出し
Info(ctx, logger, "charged")

// ヘルパーを使わず直接足す場合
sc := trace.SpanContextFromContext(ctx)
logger.Info("charged", zapdriver.TraceContext(
    sc.TraceID().String(),
    sc.SpanID().String(),
    sc.IsSampled(),
    projectID,
)...)

まとめ

Google Cloud Logging と Cloud Trace のログとトレースを関連付ける方法を紹介しました。

参考