
- はじめに
- 全体像:どんなレイヤーに分けているか
- Baggageとコンテキストで「グローバルな値」を伝播する
- slogctx.Handler:コンテキスト上の「ロガーバケット」から値を取り出す
- logjson と json-iterator/go によるマスキングとバイナリハッシュ
- mask.Renderer:マスクレベルの制御とマスクロジックの共通化
- まとめ
はじめに
この記事はenechain Advent Calendar 2025の13日目の記事です。
enechainでソフトウェアエンジニアをしている古瀬です。
enechainではGoを使ったサービスが多数動いており、それぞれがリアルタイムな電力取引やAPI提供などを担っています。そうしたサービスを安定運用するうえで、欠かせないのが「ログ機構」です。
- 障害時に何が起きたかを素早く追いたい
- 監査・セキュリティ上、ログに残す/残さない情報をきちんと制御したい
- 複数サービスをまたいでトレースしながら、同じ情報をログに付与したい
といった要件に応えるため、次のような構成でログ周りを実装しています。
- Go標準の
slogをベースにした拡張パッケージ群- context …
context.ContextとDatadogのBaggage経由で値を伝播
- context …
- JSONエンコードとマスキングを担うlogjsonパッケージ
json-iterator/go+ Extensionで構造体フィールド単位のマスキング/フィルタリング
- マスクロジックをカプセル化したmaskパッケージ
- マスキング強度を数値で制御し、logjsonや
slogの前段で共通利用
- マスキング強度を数値で制御し、logjsonや
この記事では、これらの設計と実装について、
context.ContextとHandlerで値を伝播させる仕組み- DatadogのBaggage的な仕組みで「グローバルな値」を引き継ぐ設計
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"]
まず、ログ出力までのレイヤー構成を簡単に整理します。
アプリケーションコード
- HTTPハンドラやgRPCサーバなど
context.ContextとDatadogのSpan/Baggageに値を積むslog.InfoContext(ctx, "something happened", ...)のようにログを出す
slog/context
WithLoggerValuesなどで「ログ用バケット」に値を詰めるSharedLogHandler,HandlerでコンテキストやBaggageから値を取り出し、slog.Recordに属性として載せる
logjson
WrapMutableでログ値を「遅延エンコード + キャッシュ」するjson-iterator/goのExtensionで構造体フィールド単位のマスキング/ドロップ/バイナリハッシュ化を行う
mask
- logjsonを経由して「どのくらいマスクするか」を数値レベルで制御する
Renderer
- logjsonを経由して「どのくらいマスクするか」を数値レベルで制御する
アプリケーションから見ると、
- 「コンテキストとトレース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) }
ここで行っていることはシンプルです。
ctxからDatadogのSpanを取得- SpanのBaggageに入っているグローバルな値を全部取り出す
- それらを
slog.Attrとしてrecord.AddAttrsで追加する - 最後に内側の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として登録され、以下のような仕事をします。
[]byteフィールドをバイナリハッシュとして出す(生データはログに残さない)- フィールド名に応じて、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群です。
WithSecretParams や WithMaskingParams といった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共通ライブラリにおけるログ基盤の設計について、
- Baggage(Datadog Baggage)と
context.Contextを使って、グローバルな値をサービス間・関数間で引き継ぐ仕組み SharedLogHandler/HandlerでBaggageとロガーバケットから値を集約し、slog.Recordに一括で付与する設計- logjson +
json-iterator/goのExtensionで、構造体フィールド単位のマスキング/ドロップ/バイナリハッシュ化を実現する仕組み mask.Rendererによるマスクレベルの制御とマスクロジックの共通化
といった観点から紹介しました。
ポイントを改めてまとめると、
- Baggage
- Traceに紐づくグローバル情報(サービス名 / Env / ユーザー / Endpointなど)を、どのサービス・どのレイヤーからでも同じように参照できるようにしている
- ロガーバケット(コンテキスト)
WithLoggerValuesで「この処理の間だけログに付けたい値」を積み、Handlerがまとめて取り出す
- logjson + json-iterator/go
WrapMutableで遅延エンコード + キャッシュ- Extensionで
[]byteのハッシュ化やフィールド単位のマスキング/ドロップを一括制御
- mask
- マスキング強度を数値レベルで制御しつつ、logjsonや
slogから共通のマスクロジックとして呼び出せる
- マスキング強度を数値レベルで制御しつつ、logjsonや
これらを組み合わせることで、
- アプリケーションコード側は「コンテキストとBaggageに値を積む」「普通に
slogでログを出す」だけ - セキュリティ・プライバシーのポリシーやログの形式は共通ライブラリ側に閉じ込める
という責務分離ができています。
この記事が、Goのslogやjson-iterator/goを使ってセキュアなログ基盤を設計する際の参考になれば幸いです。
enechainでは、事業拡大のために共に挑戦する仲間を募集しています。