Shogo's Blog

Aug 4, 2023 - 2 minute read - go golang

Contextに埋め込んだ値をログに出力してくれるログハンドラーを書いた

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から値を取り出してログに埋め込む、簡単なログハンドラーを書いてみました。

実際のところ、自前のハンドラーを作成したほうが良い性能が得られると思います。 まあそのときは実装の参考に使ってください。

参考