Go言語にいよいよ構造化ログ用パッケージlog/slogが追加される、と各所で話題になってますね。
しかし、どんなにドキュメントをみても、ロガーを出し入れする関数はありません。そういう使い方を議論する issue もありましたが 現状はハンドラーにそのまま渡しているだけです。 よくよく考えればトレーシングIDなどは、コンテキストに格納されているはずで、ロガーにも属性として持つと2重持ちになってしまいます。出力時だけハンドラ自身がそれを取り出して書き出せば良い、という思想に思えます。 そのためにはハンドラーを自分でつくることになります。 (強調は筆者によるもの)
たしかにトレーシングIDを2重に持つのは効率が悪いかもしれない。 そうかもしれないけど・・・でもやっぱり毎回ハンドラーを書くのは面倒・・・もうちょっと汎用的にはならないものか・・・ と結局書いちゃいました。
使い方
slog
ではログ関数がcontext.Context
を受け取るようになりました。
ここで渡したcontext.Context
はキャンセル処理には使用されず、値の受け渡しのみに使用されます。
このことを利用してctxslog.WithAttrs
でコンテキストに値を埋め込むことができます。
ここで埋め込んだ値をctxslog.New
で作成したハンドラーが受け取って、ログに表示します。
import (
"context"
"log/slog"
)
func main() {
// ログに出力するためにロガーをカスタマイズ
handler := slog.NewTextHandler(os.Stderr, nil)
slog.SetDefault(slog.New(ctxslog.New(handler)))
ctx := context.Background()
// このコンテキスト内のログすべてに my_context=foo-bar を埋め込む
ctx = ctxslog.WithAttrs(ctx, slog.String("my_context", "foo-bar"))
slog.InfoContext(ctx, "hello", "count", 42) // ログ出力、ここで `ctx` を渡しているのがポイント
slog.InfoContext(ctx, "world")
// Output:
// time=2023-08-03T18:10:20.424+09:00 level=INFO msg=hello count=42 my_context=foo-bar
// time=2023-08-03T18:10:20.424+09:00 level=INFO msg=world my_context=foo-bar
}
実装
slog.Handler
は4つのメソッドを持っていますが、毎回全部を実装するのは大変です。
「context.Context
から値を取り出して親のハンドラーに渡す」という処理はよく書きそうなので、
簡単なラッパー構造体を定義しました。
package ctxslog
import (
"context"
"log/slog"
)
var _ slog.Handler = (*wrapper)(nil)
type wrapper struct {
handler func(ctx context.Context, parent func(ctx context.Context, record slog.Record) error, record slog.Record) error
parent slog.Handler
}
func (w *wrapper) Handle(ctx context.Context, record slog.Record) error {
return w.handler(ctx, w.parent.Handle, record)
}
func (w *wrapper) Enabled(ctx context.Context, level slog.Level) bool {
return w.parent.Enabled(ctx, level)
}
func (w *wrapper) WithAttrs(attrs []slog.Attr) slog.Handler {
return &wrapper{
handler: w.handler,
parent: w.parent.WithAttrs(attrs),
}
}
func (w *wrapper) WithGroup(name string) slog.Handler {
return &wrapper{
handler: w.handler,
parent: w.parent.WithGroup(name),
}
}
関数をひとつ実装すればよいだけなので楽ちんですね。
あとから親のcontext.Context
の値を参照できるよう、context.Context
に値を埋め込むときは連結リストを作成します。
package ctxslog
import (
"bytes"
"context"
"log/slog"
"strings"
"time"
)
type ctxKey struct{ name string }
func (key *ctxKey) String() string {
return key.name
}
var key = &ctxKey{"ctxslog"}
type mergedAttrs struct {
parent *mergedAttrs
args []any
attrs []slog.Attr
}
// WithAttrs is a more efficient version of [With] that accepts only [log/slog.Attrs].
func WithAttrs(ctx context.Context, attrs ...slog.Attr) context.Context {
if len(attrs) == 0 {
return ctx
}
value := &mergedAttrs{
parent: contextAttrs(ctx),
attrs: attrs,
}
return context.WithValue(ctx, key, value)
}
// With returns a new context with the given attributes.
// The attributes are added into the log record.
func With(ctx context.Context, args ...any) context.Context {
if len(args) == 0 {
return ctx
}
value := &mergedAttrs{
parent: contextAttrs(ctx),
args: args,
}
return context.WithValue(ctx, key, value)
}
func contextAttrs(ctx context.Context) *mergedAttrs {
attrs := ctx.Value(key)
if attrs == nil {
return nil
}
return attrs.(*mergedAttrs)
}
出力のときに連結リストをたどって、ログに新しい属性を追加します。
func (attrs *mergedAttrs) addToRecord(record *slog.Record) {
if attrs == nil {
return
}
if attrs.parent != nil {
attrs.parent.addToRecord(record)
}
if len(attrs.attrs) != 0 {
record.AddAttrs(attrs.attrs...)
}
if len(attrs.args) != 0 {
record.Add(attrs.args...)
}
}
// New returns a new slog.Handler that injects the attributes from the context.
func New(parent slog.Handler) slog.Handler {
return &wrapper{
handler: inject,
parent: parent,
}
}
func inject(ctx context.Context, parent func(ctx context.Context, record slog.Record) error, record slog.Record) error {
attrs := contextAttrs(ctx)
newRecord := record.Clone()
attrs.addToRecord(&newRecord)
return parent(ctx, newRecord)
}
まとめ
context.Context
から値を取り出してログに埋め込む、簡単なログハンドラーを書いてみました。
実際のところ、自前のハンドラーを作成したほうが良い性能が得られると思います。 まあそのときは実装の参考に使ってください。