Go slogで始める構造化ログ運用—HandlerとLogValuerの使いどころ

Go slogで始める構造化ログ運用—HandlerとLogValuerの使いどころ | mohablog

Go 1.21で標準入りしたlog/slogは、オープンソースの実コードを調査した結果「95%以上のログ呼び出しは属性5個以下に収まる」という事実から設計の出発点が引かれたパッケージです。典型ケースに最適化しつつ逃げ道も残すという意図を踏まえると、slog.Info("hello")を呼ぶだけでは半分しか活かせていません。

手元のプロジェクトをlogrusから置き換えた際、Handler・LogValuer・Contextの3点を押さえるかでログの質がかなり変わると感じました。公式ドキュメントを軸に、本番運用で迷いやすいポイントを順に整理します。検証はGo 1.22.5、Go 1.25・1.26の追加分にも触れます。

目次

log/slogが標準入りした背景—zap・logrusとの関係

提案受理から1.21リリースまでの経緯

slogの提案は2022年8月にGitHub Discussionで公開され、約300件のコメントを経て2023年3月15日に受理、2023年8月8日のGo 1.21でリリースされました。公式のStructured Logging with slogThe design processには、目的が「既存パッケージを置き換えること」ではなく「共通の構造化ログフレームワークを提供すること」と明記されています。zap・zerolog・logrを駆逐するためではなく、それらと相互運用するインターフェースを定めた——と読むのが立ち位置として正確です。

zap・logrusと比較した時の選び所

「速度はzapが最速、機能はlogrusが豊富、標準で揃うのがslog」という整理が個人的にはしっくりきます。

パッケージ標準/外部ハンドラ拡張主な特徴
log/slog標準Handlerインターフェース外部依存ゼロ・1.21以降
zap外部Encoder/Coreゼロアロケーション志向
logrus外部(メンテモード)Hook古くからのデファクト
zerolog外部HookJSON出力に特化

slogは新規プロジェクトの一次選定肢に置けるレベルになりました。zapほどの極限性能は求めず、外部依存を減らしたい・運用しやすさを取りたい案件で特に向きます。エラーをラップしてログに乗せる流儀はGoのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせで扱った話と地続きです。

JSONHandlerとTextHandlerの出力を見比べる

標準で用意された2種類のHandler

slogが標準で持つHandlerはTextHandlerJSONHandlerの2つです。HandlerOptionsでレベル・ソース位置・属性変換関数を設定できます。最小コードはこうです。

package main

import (
    "log/slog"
    "os"
)

func main() {
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level:     slog.LevelInfo,
        AddSource: true,
    })
    logger := slog.New(h)
    slog.SetDefault(logger)

    slog.Info("order created", "order_id", 42, "user", "alice")
}
{"time":"2026-05-03T14:21:08.913+09:00","level":"INFO","source":{"function":"main.main","file":"/tmp/slog_demo/main.go","line":15},"msg":"order created","order_id":42,"user":"alice"}

NewJSONHandlerNewTextHandlerに差し替えると、同じ呼び出しでも次のようなkey=value形式に変わります。ローカル開発はテキスト、本番はJSON、と環境変数で切り替える運用が定番です。

time=2026-05-03T14:21:08.913+09:00 level=INFO source=/tmp/slog_demo/main.go:15 msg="order created" order_id=42 user=alice

レベル定数の刻みは「4」

slogのレベルはLevelDebug=-4, LevelInfo=0, LevelWarn=4, LevelError=8と4刻みで定義されています。なぜ1刻みではなく4なのかというと、カスタムレベル(NoticeやTrace等)を間に挟むことを想定しているからです。たとえばslog.Level(2)でNotice相当を作る、といった用途です。

const LevelNotice slog.Level = 2 // INFOとWARNの間

logger.Log(ctx, LevelNotice, "deprecation warning", "endpoint", "/v1/old")
time=2026-05-03T14:23:11.002+09:00 level=INFO+2 msg="deprecation warning" endpoint=/v1/old

名前を出力したい場合は後述のReplaceAttrでレベル名を差し替える方式が常套手段です。

動的にレベルを切り替えるLevelVar

HandlerOptions.LevelLevelerインターフェースを受けるので、*slog.LevelVarを渡しておくと実行中にレベルを差し替えられます。SIGUSR1でDebugへ落とす、HTTPエンドポイント経由で動的に変更する、といった使い方が王道です。

var lvl = new(slog.LevelVar) // 初期値はLevelInfo
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
logger := slog.New(h)

lvl.Set(slog.LevelDebug)
logger.Debug("diagnostic", "step", "connect_db")

Info系APIの落とし穴とLogAttrsへの置き換え

NGコード:文字列補間と先行Stringのオーバーヘッド

サンプルでよく見かける書き方ですが、ホットパスでは避けたいパターンがこれです。

// アンチパターン1: r.URL.String() でアロケーション発生
slog.Info("starting request", "url", r.URL.String())
// アンチパターン2: fmt.Sprintfで属性を組み立てる
slog.Info(fmt.Sprintf("user=%s ip=%s", user, ip))

後者は構造化の意味を完全に失っています。前者はr.URL.String()がログレベルDebug閾値以下で呼ばれていなくても毎回評価されるのが問題です。

OKコード:LogAttrsで型を明示する

同じ意味のログをLogAttrsで書くと、属性が型付きになりアロケーションも抑えられます。

slog.LogAttrs(ctx, slog.LevelInfo, "starting request",
    slog.String("method", r.Method),
    slog.Any("url", r.URL), // 内部でStringerが遅延評価される
    slog.Int("port", port),
)

属性をslog.Anyでラップしておくと、Disabled側に倒れたときString()を呼ばないため、出ないログのために文字列を生成するコストを払いません。これは公式のPerformance considerationsセクションで明示されている書き方です。

ベンチで比べる

属性5個・JSON出力・出力先io.Discardの条件で手元(Go 1.22.5, Apple M2)で測った結果がこちらです。

BenchmarkInfo-8           1462051        821 ns/op       248 B/op       7 allocs/op
BenchmarkLogAttrs-8       2304113        519 ns/op        24 B/op       1 allocs/op

アロケーション数が7→1、メモリ使用量が約1/10に縮みます。リクエストあたりのログ件数が多いAPIサーバーでは効いてきます。pprofでヒープを追う案件なら、Goのgoroutineリーク対策—pprofとGo 1.26新機能の使い分けと合わせてログ側のアロケーション削減も見ておくと無駄が減らせます。

WithGroupとGroupでログを階層化する

Group関数で1階層だけ畳む

HTTPリクエストのフィールドをまとめて出したい時、slog.GroupでラップするとJSON出力では入れ子の構造になります。

slog.Info("request received",
    slog.Group("req",
        slog.String("method", "GET"),
        slog.String("url", "/api/users"),
        slog.String("ip", "203.0.113.5"),
    ),
    slog.Int("latency_ms", 23),
)
{"time":"...","level":"INFO","msg":"request received","req":{"method":"GET","url":"/api/users","ip":"203.0.113.5"},"latency_ms":23}

TextHandlerではreq.method=GET req.url=/api/usersのようにドット連結された平らなキーとして出ます。Datadog・Cloud Logging系のクエリと相性が良いのはJSONHandlerの方です。

WithGroupでロガー全体に効かせる

1リクエスト内で繰り返し同じグループに属性を載せるなら、logger.WithGroup("req")で派生ロガーを作ると毎回書く必要がなくなります。

reqLogger := slog.Default().WithGroup("req").With(
    "id", reqID,
    "method", r.Method,
)
reqLogger.Info("start")
reqLogger.Info("completed", "status", 200)

注意点として、WithGroup以降にWithで足した属性も全部そのグループ配下に入る仕様です。「グループの外に出したい属性」がある場合はWithGroupを呼ぶ前にWithしておく必要があります。これは触ってみないと気づきにくい挙動で、最初少しハマりました。

なおGo 1.25でslog.GroupAttrs(key, attrs...)が追加され、slog.Group...anyを内部でAttrへ変換するコストを省けるようになりました。LogAttrs側でグループを使うときはこちらを優先します。

LogValuerで秘密情報をマスクする

Token型をREDACTEDに置き換える

LogValuerインターフェースは「自分自身がログに乗る時の表現を決める」ためのもので、機密情報のマスキングと相性がいいです。たとえば認証トークンを表すToken型をこう書きます。

type Token string

func (Token) LogValue() slog.Value {
    return slog.StringValue("REDACTED")
}

func main() {
    tok := Token("sk-live-9f3c2a...")
    slog.Info("auth ok", "token", tok)
}
{"time":"...","level":"INFO","msg":"auth ok","token":"REDACTED"}

呼び出し側のコードを変えずに、Tokenが流れ込む全箇所で自動的にマスクされる点がポイントです。fmt.Stringerと違い、ログ出力でだけ動くのでfmt.Printlnでの出力には影響しません。

遅延評価で重い計算をスキップする

もう1つの定番用途が計算コストの遅延化です。DisabledなレベルのログではLogValueが呼ばれないため、JSON化のような重い処理をDebugに仕込んでも本番(Info閾値)では実コスト0で済みます。

type bigPayload struct{ rows []Row }

func (p bigPayload) LogValue() slog.Value {
    b, _ := json.Marshal(p.rows) // 重い
    return slog.StringValue(string(b))
}

slog.Debug("dump", "payload", bigPayload{rows})

公式のLogValuerセクションに「expensive computation」のサンプルとして載っているパターンです。Pythonの遅延評価ロギングはPythonのloggingモジュール完全ガイド:本番環境で使える設計パターンに同じ発想で整理しています。

ReplaceAttrとContextでログ出力を磨き込む

ReplaceAttrでlevel名を小文字化・timeを丸める

サジェストでもよく出る「go slog json」周りの悩みは、出力フォーマットを後から触りたいというものです。HandlerOptions.ReplaceAttrはトップレベル属性ごとに呼ばれるフックで、キー名・値の差し替えができます。

opts := &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        // levelを小文字に
        if a.Key == slog.LevelKey {
            return slog.String(slog.LevelKey, strings.ToLower(a.Value.String()))
        }
        // timeをUnix秒に丸める
        if a.Key == slog.TimeKey && a.Value.Kind() == slog.KindTime {
            return slog.Int64(slog.TimeKey, a.Value.Time().Unix())
        }
        return a
    },
}
{"time":1746252068,"level":"info","msg":"order created","order_id":42}

groupsは現在ネストしているグループ名のスライスです。passwordのような特定キーをグループ問わず潰したい時にもこの引数で判定できます。ただしキー単位の判定なのでLogValuerと役割が被りやすいのは注意点で、型レベルで隠したいならLogValuer、出力フォーマット調整ならReplaceAttr、と切り分けるのが自分の中でのルールです。

InfoContextでtraceIDを横断的に乗せる

OpenTelemetry連携をやるならInfoContext系のctx付きAPIを使い、自前HandlerでctxからtraceID/spanIDを抽出する形が素直です。

type traceHandler struct{ slog.Handler }

func (h traceHandler) 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)
}

呼び出し側はslog.InfoContext(ctx, "...")と書くだけで自動的にspan情報が乗ります。slog.Info(ctxなし)では空になるので、リクエストハンドラ配下ではInfoContextを選ぶ習慣にしておくと事故が減ります。

SetLogLoggerLevelで既存logと橋渡しする

Go 1.22で追加されたslog.SetLogLoggerLevelは、標準logパッケージへ落ちたログ(log.Print等)をslogのどのレベルとして扱うかを設定します。サードパーティライブラリが古いlogを直で叩いていても、これでslog側に統合できます。

slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.SetLogLoggerLevel(slog.LevelWarn) // log.Printf 等は WARN 扱い

log.Print("connection refused") // slogに乗る
{"time":"...","level":"WARN","msg":"connection refused"}

段階的にslogへ寄せていく時の足場として便利です。なお複数Handlerに同じレコードを送りたい場合は、Go 1.26でslog.NewMultiHandlerが標準入りしたので外部ラッパーが不要になりました。

まとめ

  • log/slogはGo 1.21以降の標準構造化ログ。レベルは4刻み、Handlerインターフェース経由で出力先を差し替える設計
  • JSONHandlerとTextHandlerは環境で切り替え。本番JSON・開発Textが定番。HandlerOptions.Level*LevelVarを渡せば動的変更も可能
  • ホットパスはLogAttrs+slog.String系でアロケーションを抑える。fmt.Sprintfでの組み立てや先行のString()呼びはアンチパターン
  • Group・WithGroupで階層化。WithGroup以降に足したWith属性は全部そのグループに入る点に注意。1.25以降はGroupAttrsを優先
  • LogValuerはマスキングと遅延評価の2用途で強力。型レベルで秘密情報を隠せる
  • ReplaceAttrは出力フォーマット調整、InfoContextはtraceID連携に使い分ける
  • SetLogLoggerLevelで既存log→slogの橋渡し、Go 1.26のMultiHandlerで多段出力

slogは「最低限知っておけば書ける」一方、Handler・LogValuer・Contextの3点を意識した時から本領が出てきます。新規プロジェクトなら最初からこの3つを前提に設計しておくと、後からログを出し直す時間が確実に減ります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次