つきなみGo

Goの新しい構造化ロガーを体験しよう

logパッケージ

Goには標準ライブラリとしてlogパッケージが提供されています。logパッケージで行えることはそう多くはありません。たとえば、デフォルトではログは標準エラー出力に出力されますが、log.SetOutput関数で出力先を変更できます。また、利用する関数によってログを出力した後の挙動をコントロールできます。たとえば、log.Print関数はログを出力するだけですが、log.Fatal関数はログ出力後にos.Exit(1)を呼び出します。log.Panicはログ出力後に出力したログと同じ文言を引数としてパニックを発生させます。

logパッケージでは、ログとともに関連するデータを出力したい場合は、log.Printf関数を用います。次のように、書式を指定して出力します。

log.Printf("request_url=%s request_method=%s", r.URL, r.Method)

ログを単なるテキストデータとして出力したい場合はlogパッケージで十分かもしれません。しかし、実際はGoogle Cloud Platformで提供されているBigQueryのようなデータウェアハウスに流し込んだり、機械的に処理する必要がでてきます。そのため、JSONのような機械的に処理しやすい形式で出力することが求められます。もちろん、encoding/jsonパッケージを用いてstring型のJSON形式に変換した後にlogパッケージを使ってログ出力することも可能です。しかし、利便性を考えると現実的ではありません。

また、logパッケージにはログレベルという概念が存在しません。ログレベルごとに*log.Logger型の値を生成することもできますが、こちらも現実的ではありません。そのため、多くのGoエンジニアはZapなどのサードパーティのロギングライブラリを用います。

新しい構造化ロギングパッケージ

2022年10月に、Discussion#54763で議論されていた構造化ロギングパッケージの標準ライブラリへの導入がプロポーザルIssue#56345となりました。プロポーザルとは、Go(コンパイラや標準ライブラリ、ツールチェイン)の開発プロセスにおける機能提案を指します。大きめの機能は必ずプロポーザルという形でDesign Docとして機能が必要とされる背景や設計、実装などを示した上でコミュニティで議論され、GoogleのGoチームによって採択されるか決定されます。

新たに提案されたロギングパッケージは、すでにgolang.org/x/exp/slogパッケージ(以下、slogパッケージと記載)という形で実装が公開されています。slogのsはおそらくStructured(構造化)のsでしょう。標準ライブラリとして公開された場合は、logパッケージのサブパッケージとなり、"log/slog"でインポートできるようになるでしょう。

本稿ではプロポーザルで示されたデザインドキュメントと公開されているパッケージを元に新しいロギングパッケージを解説します。なお、執筆時点(2023年2月)でプロポーザルは採択されておらず、slogパッケージも実験段階です。今後の議論によっては破壊的な変更が入る可能性があります。実際にslogパッケージが公開されてから何度も破壊的変更が入っています。

slogパッケージは、logパッケージで提供できなかった次のような機能を提供します。

  • 構造化ログとログレベルを提供
  • Zapのようにパフォーマンスにも重点をおいている
  • logrのようにロギングライブラリのためのインタフェースを提供
  • 既存のロギングライブラリを置き換えるものではない
  • 既存のロギングライブラリに親しみが無いユーザでも扱える

slogパッケージはJSONなどの機械処理しやすい形式にログを出力できるようになっています。また、ログレベルも提供しており、ログレベルによって処理したり、ログが出ないように制御できます。

簡単なログの出力

それでは、まずは簡単なログの出力方法について解説します。slogパッケージは、Zapなどのロギングライブラリに馴染みがないユーザでも利用できるように作られています。

たとえば、ログレベルがINFOで、デフォルトロガーで出力するには次のように書きます。

slog.Info("hello", "id", 100)

実行すると次のように出力されます。

2023/01/12 01:00:00 INFO hello id=100

デフォルトでは、時刻、ログレベル、ログメッセージ、キー=値という形で標準エラー出力に出力されます。2023年2月現在の実装では、log.Output関数経由で出力するように作られています。

slog.Info関数の第2引数以降は、キーと値を交互に並べる形式になっており、ログで出力したいデータなどを書き出します。

ログレベルを変えたい場合には、slog.Debug関数、slog.Error関数、slog.Warn関数を用います。これらの関数の引数はほとんど同じですが、slog.Error関数のみ、引数にerror型の値を渡せるようになっており、次のように⁠err⁠というキーで出力されます。

err := errors.New("this is error")
slog.Error("message", err, "id", 100)

実行すると次のように出力されます。

2023/01/1201:00:00 ERROR message err="this is error" id=100

また、ログレベルを引数で指定した場合には、slog.Log関数が使用できます。第1引数にslog.LevelDebugslog.LevelInfoslog.LevelWarnslog.LevelErrorなどのログレベルが指定できます。

構造化ログ

ログをコンピュータで処理しやすい形で出力するには、使用するロギングライブラリがログメッセージと解析に扱いデータを出し分けれる必要があります。すでに紹介したように、slogパッケージはキーと値を紐付けてデータを渡すことができます。

slog.Info関数などで渡したキーと値のペアは、slog.Any関数を使用してslog.Attr型に変換されます。slog.Attr型は、次のように定義された構造体型で、KeyフィールドとValueフィールドを持ちます。なお、AttrはAttribute(アトリビュート)の略です。

type Attr struct {
	Key   string
	Value Value
}

Valueフィールドは、slog.Value型で任意の型の値を表します。slog.Value型はany型とは違って、十分小さな値の場合はアロケーション無しで扱えるようにパフォーマンスの観点から工夫がされています。

slog.Attr型の値を生成するには、slog.Any型を使用しても可能ですが、パフォーマンスの観点からslog.Int64関数やslog.String関数のように型ごとに用意された関数を用いるとよいでしょう。

1つのキーに関連した複数の値を扱いたい場合、次のようにslog.Group関数を用います。

slog.Group("request",
	slog.String("method",req.Method),
	slog.String("url", req.URL.String()))

methodurlは、requestというキーのもと、request.methodrequest.urlのように扱われます。たとえば、ログをJSONで出力する場合には、次のようにrequestというフィールドで1つのオブジェクトとなり、methodurlはそのフィールドになります。

{"request":{"method":"GET","url":"localhost"}}

slog.Attr型の値を指定して、ログを出力したい場合は、slog.LogAttrs関数を用います。slog.LogAttrs関数は、キーと値を並べるのではなく、可変長引数としてslog.LogAttr型の値を指定します。

ロガーとハンドラ

slog.Info関数やslog.LogAttrs関数は、出力形式がプレーンテキストに固定されていました。これらの出力形式を変更するには、slog.SetDefault関数を用いてデフォルトのロガーを変更する必要があります。

slog.SetDefault関数の引数は、*slog.Logger型です。slog.Logger型はロガーを表す構造体で、slog.New関数を用いて生成できます。そして、slog.New関数は、slog.Hanlder型というインタフェース型の値を引数にとります。

slog.Hanlderインタフェースは、slogパッケージでは*slog.TextHandler型と*slog.JSONHandler型が実装しています。デフォルトのロガーは*slog.TextHandler型の値が設定されているため、出力形式がプレーンテキストになります。

ログの出力形式をJSONにしたい場合には、slog.NewJSONHandler関数で出力先のio.Writer型の値を指定してハンドラを作成し、slog.New関数の引数に指定してロガーを作成します。作成したロガーをslog.SetDefault関数に指定すれば、slog.Info関数やslog.LogAttrs関数の出力形式がJSONになり、指定した出力先に出力されます。

slog.SetDefault関数に指定しなくても、*slog.Logger型はInfoメソッドやLogAttrsメソッドを持つため、直接利用しても問題ありません。

*slog.TextHanlder型や*slog.JSONHandler型が実装しているslog.Handlerインタフェースは、次のように定義され、ログの処理方法を表します。サードパーティのロギングライブラリはslog.Handlerインタフェースを実装することにより、slogパッケージと接続できます。

type Handler interface {
	Enabled(context.Context, Level) bool
	Handle(r Record) error
	WithAttrs(attrs []Attr) Handler
	WithGroup(name string) Handler
}

slog.Handlerインタフェースで中心となるメソッドは、Handleメソッドです。Handleメソッドは、引数にログの1レコードを表す構造体のslog.Record型を取ります。slog.Record構造体は、ログが出力された時刻やログレベル、メッセージなどを公開されたフィールドに保持します。

レコードが保持するアトリビュートは、書き込みできないように(slog.Record).Attrsメソッドから取得できます。また、スライスの要素が変更されないように、引数にイテレートを行う関数を取るように工夫されています。余談ですが、これはDiscussion#56413で議論されているpush型の意テーレーション関数の形をとっているので、将来的にfor range文で繰り返せるようになれば便利そうです。

ハンドラの実装によって、アトリビュートを追加したい場合は、(*slog.Record).AddAttrsメソッドを利用します。一方、アトリビュートを削除したい場合や変更したい場合は、slog.HandlerOptions型のReplaceAttrフィールドに変更を行うための関数を設定しています。

ReplaceAttrフィールドは、次のように定義された関数型のフィールドです。引数に、string型のスライスとアトリビュートを取ります。

type HandlerOptions struct {
	/* 略 */
	ReplaceAttr func(groups []string, a Attr) Attr
}

アトリビュートがslog.Group関数などで生成されたアトリビュートの子アトリビュートの場合は、親のキーが渡されます。また、グループにはなっていないアトリビュートの場合は、第1引数はnilが渡されます。

たとえば、メールアドレスなどをマスキングしたい場合は、次のように設定します。

func(groups []string, a slog.Attr) slog.Attr {
	if len(groups) == 0 && a.Key == "email" {
		return slog.String(a.Key, "******")
	}
	return a
}

また、アトリビュートごと消したい場合は、次のようにキーを空にして返すと削除されます。

func(groups []string, a Attr) Attr {
	if len(groups) == 0 && a.Key == "email" {
		return slog.Any("", struct{}{})
	}
	return a
}

slog.HandlerOptions型からは、NewJSONHanlderメソッドやNewTextHandlerメソッドを用いてハンドラが生成できます。なお、自前のハンドラを作成している場合で特定のアトリビュートを変更または削除したい場合は、これらのハンドラをベースにするかslog.HanlderインタフェースのHanldeメソッドで処理する必要があります。

ロガーに時刻timeやメッセージmsgのように、デフォルトのアトリビュートを紐付けたい場合には、次のように*slog.Logger型のWithメソッドを用います。

slog.SetDefault(slog.Default().With(
	"method",req.Method,
	"url", req.URL.String(),
))

なお、slog.SetDefault関数やslog.Default関数は、それぞれはatomicパッケージを用いているため、複数のゴールーチンから呼び出しても問題ありません。しかし、slog.Default関数の呼び出しとslog.SetDefault関数の呼び出しのタイミングによっては、競合が起きてしまう可能性があります。

コンテキスト

ログをリクエストごとやOpenTelemetryなどのトレーサーにSpanによって、ログをまとめられると非常に便利です。どの方法を使ってログをまとめるにしても、Goの場合はcontext.Context型を用いることが多いでしょう。

そのため、ログとcontext.Contextをうまく扱うことはロギングライブラリの利便性を議論する上で非常に重要です。slogパッケージでもロガー経由でレコードにコンテキストを保持させることができます。

具体的には、(*slog.Logger).WithContextメソッドによって特定のコンテキストをロガーに設定できます。WithContextメソッドはロガーのクローンを生成して、その新しいロガーにコンテキストを設定し、戻り値として返します。設定されたコンテキストは(*slog.Logger).Contextメソッドから取得できます。

ロガーに設定されたコンテキストは、ハンドラにレコードが渡される際にレコードに設定されます。そのため、トレースIDなどをコンテキストから取得したい場合は、ハンドラをラップする形を取るとよいでしょう。

コンテキストとロガーの関係は、逆も考えられます。つまり、コンテキストにログを保持するということです。slogパッケージでも、slog.Ctx関数やslog.FromContext関数、slog.WithContext関数などが提案されてきましたが、現在の実装ではなくなっています。

これらのコンテキストとロガーの扱いは、今後も大きく変わりそうな点です。Goでは、slog.Logger構造体のように、フィールドとしてコンテキストを保持するのはアンチパターンとされてきました。そのため、他の代替案がIssue#5824で議論されています。

まとめ

本記事では、新しいロギングライブラリについて紹介しました。slogパッケージは、標準ライブラリになることを視野にいれたパッケージなので、まだ触っていない読者はぜひ触ってみてください。ただし、まだ実験段階ではあるため、今後も変更が加わるでしょう。しかしながら、その変更点に注目することも非常に勉強になりますので、変わることに臆せず積極的に読んでみるとよいでしょう。

おすすめ記事

記事・ニュース一覧