Goアプリケーションのログ出力を最適化する

ogp

はじめに

この記事はenechain Advent Calendar 2025の13日目の記事です。

enechainでソフトウェアエンジニアをしている古瀬です。

enechainではGoを使ったサービスが多数動いており、それぞれがリアルタイムな電力取引やAPI提供などを担っています。そうしたサービスを安定運用するうえで、欠かせないのが「ログ機構」です。

  • 障害時に何が起きたかを素早く追いたい
  • 監査・セキュリティ上、ログに残す/残さない情報をきちんと制御したい
  • 複数サービスをまたいでトレースしながら、同じ情報をログに付与したい

といった要件に応えるため、次のような構成でログ周りを実装しています。

  • Go標準の slog をベースにした拡張パッケージ群
    • context … context.Context とDatadogのBaggage経由で値を伝播
  • JSONエンコードとマスキングを担うlogjsonパッケージ
    • json-iterator/go + Extensionで構造体フィールド単位のマスキング/フィルタリング
  • マスクロジックをカプセル化したmaskパッケージ
    • マスキング強度を数値で制御し、logjsonやslogの前段で共通利用

この記事では、これらの設計と実装について、

  1. context.Context とHandlerで値を伝播させる仕組み
  2. DatadogのBaggage的な仕組みで「グローバルな値」を引き継ぐ設計
  3. json-iterator/goを使ったマスキングとバイナリハッシュ化

の3点を中心に、コード例を交えながら紹介します。
実際のログJSONの構造には踏み込まず、あくまで「仕組み」にフォーカスします。


全体像:どんなレイヤーに分けているか

graph TD
    App[アプリケーションコード] -- contextのセットアップ --> SlogCtx["slog/context"]
    App --> StdSlog["log/slog(標準ライブラリ)"]
    App --  マスキング等オプション設定  --> LogJSON

    SlogCtx --> DDTrace["dd-trace-go(Span/Baggage)"]

    SlogCtx -- Handlerから利用 --> LogJSON["logjson"]

    LogJSON --> Mask["mask"]
    LogJSON --> Jsoniter["json-iterator/go"]
    LogJSON -.-> StdJSON["encoding/json互換API"]

まず、ログ出力までのレイヤー構成を簡単に整理します。

  1. アプリケーションコード

    • HTTPハンドラやgRPCサーバなど
    • context.Context とDatadogのSpan/Baggageに値を積む
    • slog.InfoContext(ctx, "something happened", ...) のようにログを出す
  2. slog/context

    • WithLoggerValues などで「ログ用バケット」に値を詰める
    • SharedLogHandler, Handler でコンテキストやBaggageから値を取り出し、slog.Record に属性として載せる
  3. logjson

    • WrapMutable でログ値を「遅延エンコード + キャッシュ」する
    • json-iterator/goのExtensionで構造体フィールド単位のマスキング/ドロップ/バイナリハッシュ化を行う
  4. mask

    • logjsonを経由して「どのくらいマスクするか」を数値レベルで制御する Renderer

アプリケーションから見ると、

  • 「コンテキストとトレースSpan(Baggage)に値を積んでおく」
  • 「あとはslogでログを出すだけ」

という使い方で、マスキングやBaggageの扱いは共通ライブラリ側に閉じ込めています。


Baggageとコンテキストで「グローバルな値」を伝播する

まずは、DatadogのBaggageと context.Context を使って「グローバルな値」を伝播させている部分から見ていきます。

なぜ Baggage を使うのか

1リクエストの処理の中で、次のような属性はどこからログを書いても同じ値であってほしいものです。

  • サービス名
  • 環境(env)
  • バージョン
  • ユーザーID, 組織ID

これらはリクエスト全体に紐づく値であり、関数の引数で毎回渡したり、グローバル変数に置いたりするのではなく、

  • トレーシングのSpanに紐づいたBaggageとして保持
  • そのSpanが伝播する限り、上流・下流でも参照できる

という設計にしています。

ログ側から見ると、

  • context.Context からDatadogのSpanを取得
  • SpanのBaggageからグローバルな値を取り出して、ログに載せる

という流れになります。

Baggage に値を積む:WithTraceInfo

trace.goでは、次のようなAPIを定義しています。 logjsonパッケージについての詳細は後ほど説明しますが、マスキングなどの処理を担います。

func WithTraceInfo(ctx context.Context, userID string) (context.Context, error) {
    baseInfo := info.Base()
    span, ok := tracer.SpanFromContext(ctx)
    if !ok {
        return nil, ErrNoSpanFound
    }

    // アプリケーション種別を Baggage に保存
    span.SetBaggageItem("user_id", userID)
    
    // ...その他の情報

    // logjson のオプションを引き継いで Params を WrapMutable でロガー用バケットに追加
    opts := logjson.OptionsFromContext(ctx)
    return WithLoggerValue(ctx, "params", logjson.WrapMutable(baseInfo.Params, opts...))
}

HTTPハンドラなどでは、概ね次のような使い方になります(擬似コードです)。

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // userIDを取得

    // トレース Span がミドルウェアで既に付与されている前提
    // リクエスト情報を Baggage + ログ用バケットに詰める
    ctx, _ = slogctx.WithTraceInfo(ctx, userID)

    // 以降 ctx を使って処理を進め、どこからログを出しても
    // Baggage に積んだ情報が Handler 経由で引き継がれる
    process(ctx)
}

ここまでで、Baggageにリクエスト全体で共有したい「グローバルな値」が保存されています。

Baggage から値を取り出す:SharedLogHandler

次に、実際にログを出力するHandler側の処理を見てみます。
shared.goで定義されている SharedLogHandler は、DatadogのSpanとBaggageから値を取り出し、slog.Record に属性として付与する役割を担います。

type SharedLogHandler struct {
    handler slog.Handler
}

func NewSharedLogHandler(handler slog.Handler) slog.Handler {
    return &SharedLogHandler{
        handler: NewHandler(handler), // 後述の slogctx.Handler をさらに内側に包む
    }
}

func (h *SharedLogHandler) Handle(ctx context.Context, record slog.Record) error {
    if span, ok := tracer.SpanFromContext(ctx); ok {
        attrs := []slog.Attr{
            slog.String("userId", span.BaggageItem("userId")),
            // ...
        }

        // ここで Record に Baggage の情報を一括で載せる
        record.AddAttrs(attrs...)
    }

    // さらに内側の Handler(slogctx.Handler など)に処理を渡す
    return h.handler.Handle(ctx, record)
}

ここで行っていることはシンプルです。

  1. ctx からDatadogのSpanを取得
  2. SpanのBaggageに入っているグローバルな値を全部取り出す
  3. それらを slog.Attr として record.AddAttrs で追加する
  4. 最後に内側のHandlerに処理を委譲

この実装により、

  • リクエストのどこからログを書いても、必ず同じTraceID / UserID / Endpointなどが付与される
  • これらの値はBaggageを通じてマイクロサービス間でも引き継がれる(トレースにぶら下がっているため)

という「グローバルな値の伝播」が実現されています。


slogctx.Handler:コンテキスト上の「ロガーバケット」から値を取り出す

Baggageに加えて、context.Context 自体にも「ロガー専用のバケット」を持たせています。
これは「Span/Baggageまでは紐づけたくないけれど、リクエスト全体で共有したいログ用の値」を扱うための独自の仕組みです。

コンテキストに値を積む:WithLoggerValues

context.goには次のような関数があります。

func GetLoggerValues(ctx context.Context) (map[string]any, bool) {
    if ctx == nil {
        return nil, false
    }

    bucket, ok := ctx.Value(fields).(*sync.Map)
    if !ok {
        return nil, false
    }

    values := make(map[string]any)
    bucket.Range(func(k, v any) bool {
        values[k.(string)] = v.(interface{ Unwrap() any }).Unwrap()
        return true
    })

    return values, true
}

func WithLoggerValues(ctx context.Context, values map[string]any) (context.Context, error) {
    if ctx == nil {
        return nil, fmt.Errorf("cannot create context from nil parent")
    }

    bucket := &sync.Map{}
    // 既にバケットがあればコピーして引き継ぐ
    if v, ok := ctx.Value(fields).(*sync.Map); ok {
        bucket = copySyncMap(v)
    }

    // logjson のオプションを context から取得
    opts := logjson.OptionsFromContext(ctx)

    // 追加分を WrapMutable で包んで保存
    for k, v := range values {
        bucket.Store(k, logjson.WrapMutable(v, opts...))
    }

    return context.WithValue(ctx, fields, bucket), nil
}

func WithLoggerValue(ctx context.Context, key string, val any) (context.Context, error) {
    return WithLoggerValues(ctx, map[string]any{key: val})
}

ここでのポイントは次の2つです。

  • sync.Map を「ロガー用バケット」としてコンテキストに保持している
  • 値はすべて logjson.WrapMutable で包み、まだ JSON には変換しない

実際のアプリケーションコード側では、例えば次のような使い方になります。

func process(ctx context.Context) {
    // この関数以降のログには、operation と requestId を必ず載せたい
    ctx, _ = slogctx.WithLoggerValues(ctx, map[string]any{"operation": "AnyOperation"})

    slog.InfoContext(ctx, "start create order")
    // ...
    slog.InfoContext(ctx, "finish create order")
}

process の中で出てくるログは、Handler側で operation を自動的に付与してくれる、というイメージです。

slog Handler 側でバケットから値を取り出す

このバケットから値を取り出して slog.Record に載せるのが、handler.goの Handler です。

type Handler struct {
    handler slog.Handler
}

func NewHandler(handler slog.Handler) slog.Handler {
    return Handler{
        handler: handler,
    }
}

func (h Handler) Handle(ctx context.Context, record slog.Record) error {
    // ロガー用バケットから値を取得し、Attr に追加
    values, ok := GetLoggerValues(ctx)
    if ok {
        for k, v := range values {
            record.AddAttrs(slog.Any(k, v))
        }
    }

    // 共通のタイムスタンプを付与
    t := time.Now()
    record.AddAttrs(slog.String("ts", t.Format("2006-01-02T15:04:05.000Z07:00")))

    return h.handler.Handle(ctx, record)
}

このHandlerは

  • Baggageからグローバルな値を付与する SharedLogHandler
  • コンテキストのロガーバケットから値を付与する Handler
  • さらにその内側にあるJSONハンドラ(slog.NewJSONHandler など)

という構成の一部として動きます。

h := slogctx.NewHandler(
    slog.NewJSONHandler(
        os.Stdout, &slog.HandlerOptions{
            Level:       slog.LevelInfo,
        },
    ),
)
logger := slog.New(h)

アプリケーション側はこの logger をグローバルに保持しておき、slog.SetDefault(logger) することで、どこからでも同じロギングハンドラを出せるようになります。


logjson と json-iterator/go によるマスキングとバイナリハッシュ

ここからは、さらに内側でJSONエンコードを担うlogjsonパッケージの中身を見ていきます。

WrapMutable:遅延エンコード + キャッシュ

logjsonの中心にあるのが WrapMutable です。

type mutableParams struct {
    obj           any
    marshalFn     func() marshalResult
    marshaler     Marshaler
    jsonExtension *jsonExtension
}

func WrapMutable(obj any, opts ...Option) *mutableParams {
    var p *mutableParams

    p = &mutableParams{
        obj: obj,
        marshalFn: sync.OnceValue(func() marshalResult {
            enc := p.marshaler
            if enc == nil {
                json := jsonConfig.Froze()
                json.RegisterExtension(p.jsonExtension)
                enc = json.Marshal
            }

            res := marshalResult{}
            res.bytes, res.err = enc(obj)

            // 一度計算した結果をキャッシュ
            p.marshalFn = func() marshalResult {
                return res
            }
            return res
        }),
        jsonExtension: getDefaultJSONExtension(),
    }

    for _, opt := range opts {
        opt(p)
    }

    return p
}

func (p *mutableParams) MarshalJSON() ([]byte, error) {
    cache := p.marshalFn()
    return cache.bytes, cache.err
}

WrapMutable されたオブジェクトは、

  • 最初に MarshalJSON() が呼ばれたときだけ実際にjsoniterでエンコード
  • その結果をキャッシュし、以降の呼び出しではキャッシュ済みの []byte を返す

という挙動をします。

コンテキストのロガーバケットに WrapMutable された値をたくさん入れておいても、実際にJSONに変換するのは 最終的に本当にログとして出すときだけ になります。

jsonExtension:構造体フィールド単位の制御

WrapMutable が内部で使っている jsonExtension は、json-iterator/goのExtensionとして登録され、以下のような仕事をします。

  1. []byte フィールドをバイナリハッシュとして出す(生データはログに残さない)
  2. フィールド名に応じて、Drop / Secret / Maskingを切り替える

大まかな実装イメージは次の通りです。

type jsonExtension struct {
    jsoniter.DummyExtension

    withBinaryHash bool

    dropParams matchers

    secretRenderer  encoder.MaskRenderer
    secretParams    matchers
    maskingRenderer encoder.MaskRenderer
    maskingParams   matchers
}

func getDefaultJSONExtension() *jsonExtension {
    secretRenderer, _ := mask.NewMaskingRenderer(1.0)   // 全マスク
    maskingRenderer, _ := mask.NewMaskingRenderer(0.8)  // 80% マスク

    return &jsonExtension{
        secretRenderer:  secretRenderer,
        maskingRenderer: maskingRenderer,
        withBinaryHash:  true,
    }
}

1. バイナリハッシュ化(DecorateEncoder)

func (ext jsonExtension) DecorateEncoder(rt reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
    if ext.withBinaryHash && rt.Kind() == reflect.Slice && rt.Type1().Elem().Kind() == reflect.Uint8 {
        return encoder.NewBinaryEncoder(enc)
    }
    return enc
}

[]byte 型だと判定されたフィールドに対しては、通常のエンコーダではなく binaryEncoder を挟みます。binaryEncoder は、バイト列をそのまま出すのではなく、

  • サイズ
  • ハッシュ(SHA-256)

だけをJSONに出力します。
4KBを超えるような大きなデータの場合でも、先頭・中間・末尾をサンプリングしてハッシュを計算し、ログ量を抑えつつ内容の同一性だけ確認できるようにしています。

2. Drop / Secret / Masking(UpdateStructDescriptor)

秘密情報の出力に関する制御をするコードは以下の通りでせす。 キーの削除、部分/全部のマスキングを行います。

func (ext jsonExtension) UpdateStructDescriptor(sd *jsoniter.StructDescriptor) {
    fields := make([]*jsoniter.Binding, 0, len(sd.Fields))

    for _, f := range sd.Fields {
        name := f.Field.Name()

        // 完全に落としたいフィールドはここでスキップ
        if ext.dropParams.match(name) {
            continue
        }

        var (
            enc1, enc2 encoder.LogJSONEncoder
            ok         bool
        )
        if enc1, ok = f.Encoder.(encoder.LogJSONEncoder); !ok {
            enc1 = encoder.Noop(f.Encoder)
        }

        switch {
        case ext.secretParams.match(name):
            // secret 対象:文字列なら中身全部をマスク、それ以外は「読めない値」としてマスク
            if f.Field.Type().Kind() == reflect.String {
                enc2 = encoder.NewStringMaskingEncoder(f.Encoder, ext.secretRenderer)
            } else {
                enc2 = encoder.NewAnyMaskingEncoder(f.Encoder, ext.secretRenderer)
            }
        case ext.maskingParams.match(name):
            // masking 対象:一部だけ残してマスク
            if f.Field.Type().Kind() == reflect.String {
                enc2 = encoder.NewStringMaskingEncoder(f.Encoder, ext.maskingRenderer)
            } else {
                enc2 = encoder.NewAnyMaskingEncoder(f.Encoder, ext.maskingRenderer)
            }
        default:
            // 何もしない場合は Noop
            enc2 = encoder.Noop(f.Encoder)
        }

        // どちらのエンコーダを使うかは Priority で決める
        if enc1.GetPriority() < enc2.GetPriority() {
            f.Encoder = enc2
        }

        fields = append(fields, f)
    }

    sd.Fields = fields
}

dropParams, secretParams, maskingParams は、それぞれ「フィールド名にマッチするかどうか」を判定するmatcher群です。
WithSecretParamsWithMaskingParams といったOptionで、外部から設定できます。

opts := []logjson.Option{
    logjson.WithSecretParams("password", "token"),
    logjson.WithMaskingParams("email", "phone"),
}

ctx, _ := logjson.WithOptions(ctx, opts...)
ctx, _ = slogctx.WithLoggerValues(ctx, map[string]any{
    "params": logjson.WrapMutable(requestParams, opts...),
})

このようにしておくと、requestParams 構造体をログに出すとき、

  • パスワードやトークン系のフィールドは完全にマスク
  • メールアドレスや電話番号は一部だけ残してマスク
  • そもそもログに出したくないフィールドはDrop

といった挙動が、型定義を意識せずに裏側で適用されます。


mask.Renderer:マスクレベルの制御とマスクロジックの共通化

mask.Renderer:数値レベルでマスク強度を指定

maskパッケージの Renderer は、実際に文字列をマスクする責務を持っています。

type Renderer struct {
    Level float64
}

func NewMaskingRenderer(level float64) (*Renderer, error) {
    level = min(1, level)
    if level < 0.5 {
        return nil, fmt.Errorf("mask level must be over 0.5, got %f", level)
    }
    return &Renderer{Level: level}, nil
}

func (m *Renderer) Render(s string) string {
    maskLen := int(math.Ceil(float64(len(s)) * m.Level))
    showLen := len(s) - maskLen
    if showLen < 0 {
        showLen = 0
        maskLen = len(s)
    }
    return s[:showLen] + strings.Repeat("*", maskLen)
}

func (m *Renderer) GetLevel() float64 {
    return m.Level
}

Level は0.5〜1.0の間で指定し、どのくらいの割合を * にするかを決めます。Render は先頭 showLen 文字を残し、残りを * で埋めるシンプルな仕組みです。

logjsonのExtensionはこの GetLevel() を優先度計算にも使っており、例えば「完全に秘匿したい値(Level=1.0)」と「一部だけ残したい値(Level=0.8)」を同じAPIで表現できます。アプリケーションコードから直接使う場合も、次のようにして簡単にマスク処理を共通化できます。

renderer, _ := mask.NewMaskingRenderer(0.8)

maskedEmail := renderer.Render("user@example.com")
// => user*************

このように、maskパッケージは「どの程度マスクするか」というポリシーを1カ所に閉じ込め、logjsonやslogの前段から再利用できるようにするためのコンポーネントになっています。


まとめ

本記事では、enechainのGo共通ライブラリにおけるログ基盤の設計について、

  1. Baggage(Datadog Baggage)と context.Context を使って、グローバルな値をサービス間・関数間で引き継ぐ仕組み
  2. SharedLogHandler / Handler でBaggageとロガーバケットから値を集約し、slog.Record に一括で付与する設計
  3. logjson + json-iterator/goのExtensionで、構造体フィールド単位のマスキング/ドロップ/バイナリハッシュ化を実現する仕組み
  4. mask.Renderer によるマスクレベルの制御とマスクロジックの共通化

といった観点から紹介しました。

ポイントを改めてまとめると、

  • Baggage
    • Traceに紐づくグローバル情報(サービス名 / Env / ユーザー / Endpointなど)を、どのサービス・どのレイヤーからでも同じように参照できるようにしている
  • ロガーバケット(コンテキスト)
    • WithLoggerValues で「この処理の間だけログに付けたい値」を積み、Handler がまとめて取り出す
  • logjson + json-iterator/go
    • WrapMutable で遅延エンコード + キャッシュ
    • Extensionで []byte のハッシュ化やフィールド単位のマスキング/ドロップを一括制御
  • mask
    • マスキング強度を数値レベルで制御しつつ、logjsonやslogから共通のマスクロジックとして呼び出せる

これらを組み合わせることで、

  • アプリケーションコード側は「コンテキストとBaggageに値を積む」「普通にslogでログを出す」だけ
  • セキュリティ・プライバシーのポリシーやログの形式は共通ライブラリ側に閉じ込める

という責務分離ができています。

この記事が、Goのslogjson-iterator/goを使ってセキュアなログ基盤を設計する際の参考になれば幸いです。


enechainでは、事業拡大のために共に挑戦する仲間を募集しています。