Go ブログ

slog を使用した構造化ログ

Jonathan Amsterdam
2023年8月22日

Go 1.21 の新しいlog/slogパッケージは、標準ライブラリに構造化ログ機能を追加しました。構造化ログはキーと値のペアを使用するため、迅速かつ確実に解析、フィルタリング、検索、分析できます。サーバーの場合、ロギングは開発者がシステムの詳細な動作を観察し、デバッグを行う際に最初に確認する重要な方法です。そのため、ログは膨大になりがちであり、迅速に検索およびフィルタリングする機能が不可欠です。

標準ライブラリには、Go の最初のリリースから 10 年以上前に、logというロギングパッケージがありました。時とともに、Go プログラマーにとって構造化ログが重要であることを学びました。これは、毎年の調査で常に高い順位にランクインしており、Go エコシステムの多くのパッケージが提供しています。これらのいくつかは非常に人気があります。Go 用の最初の構造化ログパッケージの 1 つであるlogrusは、10 万を超える他のパッケージで使用されています。

多くの構造化ログパッケージから選択できるため、大規模なプログラムでは、依存関係を通じて複数のパッケージが含まれることがよくあります。メインプログラムは、ログ出力が一貫するように、これらの各ロギングパッケージを構成する必要があります。つまり、すべて同じ場所に、同じ形式で出力されます。標準ライブラリに構造化ログを含めることで、他のすべての構造化ログパッケージが共有できる共通のフレームワークを提供できます。

slog の概要

以下は、slog を使用する最も簡単なプログラムです。

package main

import "log/slog"

func main() {
    slog.Info("hello, world")
}

この記事を書いている時点では、以下のように出力されます。

2023/08/04 16:09:19 INFO hello, world

Info関数は、デフォルトのロガー(この場合はlogパッケージのデフォルトロガー、つまりlog.Printfを書いたときに取得するロガーと同じ)を使用して、Info ログレベルでメッセージを出力します。そのため、出力は非常に似て見えます。「INFO」だけが新しく追加されたものです。すぐに使える状態で、slogと元のlogパッケージは連携して、簡単に開始できるようにします。

Info以外にも、DebugWarnErrorの3つのレベルの関数と、レベルを引数として取るより一般的なLog関数があります。slogでは、レベルは単なる整数なので、4つの名前付きレベルに限定されません。たとえば、Infoは0、Warnは4なので、ロギングシステムにそれらの間にレベルがある場合は、2を使用できます。

logパッケージとは異なり、メッセージの後にキーと値のペアを追加するのは簡単です。

slog.Info("hello, world", "user", os.Getenv("USER"))

出力は次のようになります。

2023/08/04 16:27:19 INFO hello, world user=jba

前述のように、slogのトップレベル関数はデフォルトのロガーを使用します。このロガーを明示的に取得し、そのメソッドを呼び出すことができます。

logger := slog.Default()
logger.Info("hello, world", "user", os.Getenv("USER"))

すべてのトップレベル関数は、slog.Loggerのメソッドに対応します。出力は以前と同じです。

最初は、slog の出力はデフォルトの log.Logger を経由し、上記で見た出力を作成します。ロガーによって使用される *ハンドラー* を変更することで、出力を変更できます。slog には 2 つの組み込みハンドラーが付属しています。TextHandler は、すべてのログ情報を key=value の形式で出力します。このプログラムは、TextHandler を使用して新しいロガーを作成し、Info メソッドへの同じ呼び出しを行います。

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("hello, world", "user", os.Getenv("USER"))

出力は次のようになります。

time=2023-08-04T16:56:03.786-04:00 level=INFO msg="hello, world" user=jba

すべてがキーと値のペアに変換され、必要に応じて文字列が引用符で囲まれて構造が維持されます。

JSON 出力の場合は、代わりに組み込みの JSONHandler をインストールします。

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello, world", "user", os.Getenv("USER"))

これで、出力はログ呼び出しごとに 1 つの JSON オブジェクトのシーケンスになります。

{"time":"2023-08-04T16:58:02.939245411-04:00","level":"INFO","msg":"hello, world","user":"jba"}

組み込みのハンドラーに限定されません。slog.Handlerインターフェースを実装することで、誰でもハンドラーを作成できます。ハンドラーは特定の形式で出力を生成したり、別のハンドラーをラップして機能を追加したりできます。slogドキュメントのの1つは、ログメッセージが表示される最小レベルを変更するラッピングハンドラーの書き方を示しています。

これまで使用してきた属性の交互のキーと値の構文は便利ですが、頻繁に実行されるログステートメントの場合は、Attr型を使用し、LogAttrsメソッドを呼び出す方が効率的です。これらは連携してメモリ割り当てを最小限に抑えます。文字列、数値、その他の一般的な型からAttrを作成するための関数が用意されています。このLogAttrsへの呼び出しは、上記と同じ出力を生成しますが、より高速です。

slog.LogAttrs(context.Background(), slog.LevelInfo, "hello, world",
    slog.String("user", os.Getenv("USER")))

slogには他にも多くの機能があります。

  • LogAttrsへの呼び出しが示すように、一部のログ関数にcontext.Contextを渡すことができるため、ハンドラーはトレースIDなどのコンテキスト情報を抽出できます。(コンテキストをキャンセルしても、ログエントリが書き込まれるのを防ぎません。)

  • Logger.Withを呼び出して、そのすべての出力に表示される属性をロガーに追加できます。これにより、複数のログステートメントの共通部分を効果的に抽出できます。これは便利であるだけでなく、以下で説明するようにパフォーマンスの向上にも役立ちます。

  • 属性はグループにまとめることができます。これにより、ログ出力にさらに構造を追加し、それ以外の場合は同一になるキーのあいまいさを解消するのに役立ちます。

  • LogValueメソッドを使用して値の型を提供することにより、ログに値が表示される方法を制御できます。これを使用して構造体のフィールドをグループとしてログに記録するか、機密データを削除するなどを行うことができます。

slogのすべての機能について学習する最適な場所は、パッケージドキュメントです。

パフォーマンス

slogを高速にすることを目指しました。大規模なパフォーマンス向上のため、Handlerインターフェースを設計して、最適化の機会を提供しました。Enabledメソッドは、すべてのログイベントの開始時に呼び出され、ハンドラーは不要なログイベントを迅速に削除する機会が与えられます。WithAttrsおよびWithGroupメソッドにより、ハンドラーはLogger.Withによって追加された属性を一度にフォーマットできます。この事前フォーマットは、http.Requestなどの大規模な属性がLoggerに追加され、多くのログ呼び出しで使用される場合、速度を大幅に向上させることができます。

パフォーマンス最適化作業の情報を提供するために、既存のオープンソースプロジェクトでのロギングの典型的なパターンを調査しました。ロギングメソッドへの呼び出しの95%以上が5個以下の属性を渡すことがわかりました。また、属性の種類を分類し、少数の一般的な型が大部分を占めていることを発見しました。次に、一般的なケースを捉えたベンチマークを作成し、それをガイドとして時間のかかっている場所を確認しました。最大の成果は、メモリ割り当てに細心の注意を払うことで得られました。

設計プロセス

slogパッケージは、2012年にGo 1がリリースされて以降、標準ライブラリに追加された最大の追加機能の1つです。時間をかけて設計し、コミュニティからのフィードバックが不可欠であることを知っていました。

2022年4月までに、Goコミュニティにとって構造化ログの重要性を示すのに十分なデータを集めました。Goチームは、それを標準ライブラリに追加することを検討することにしました。

既存の構造化ロギングパッケージの設計方法を調べることから始めました。また、Goモジュールプロキシに保存されている大規模なオープンソースGoコードのコレクションを活用して、これらのパッケージが実際にどのように使用されているかを学びました。最初の設計は、この調査とGoのシンプルさの精神に基づいていました。パフォーマンスを犠牲にすることなく、ページが軽く、理解しやすいAPIを望んでいました。

既存のサードパーティ製ロギングパッケージを置き換えることは、決して目標ではありませんでした。それらはすべて得意なことをうまくこなしており、うまく機能している既存のコードを置き換えることは、めったに開発者の時間の有効活用になりません。APIを、バックエンドインターフェースHandlerを呼び出すフロントエンドLoggerに分割しました。このようにして、既存のロギングパッケージは共通のバックエンドと通信できるため、それらを使用するパッケージは書き直すことなく相互運用できます。多くの一般的なロギングパッケージ(Zaplogrhclogなど)のハンドラーが作成中または作成済みです。

Goチームと、広範なロギング経験を持つ他の開発者と、初期の設計を共有しました。彼らのフィードバックに基づいて変更を加え、2022年8月までに実行可能な設計ができたと感じました。8月29日、実験的な実装を公開し、GitHubのディスカッションを開始して、コミュニティの意見を聞きました。反応は熱心で、おおむね肯定的でした。他の構造化ロギングパッケージの設計者やユーザーからの洞察に富んだコメントのおかげで、いくつかの変更を加え、グループやLogValuerインターフェースなどの機能を追加しました。ログレベルから整数のマッピングを2回変更しました。

2ヶ月と約300件のコメントの後、実際の提案とそれに付随する設計ドキュメントの準備ができたと感じました。提案に関する問題は800件以上のコメントを集め、APIと実装の多くの改善につながりました。以下は、APIの変更の2つの例であり、どちらもcontext.Contextに関するものです。

  1. 当初、APIはコンテキストにロガーを追加することをサポートしていました。多くの人が、これはロガーをコードのレベルを通して簡単に流す便利な方法だと感じていました。しかし、他の人々は、それが暗黙の依存関係を密輸し、コードの理解を難しくしていると感じていました。最終的に、あまりにも物議を醸すため、この機能を削除しました。

  2. また、ロギングメソッドにコンテキストを渡すという関連する問題にも取り組んでおり、さまざまな設計を試みました。すべてのログ呼び出しでコンテキストが必要になることを避けたいので、当初はコンテキストを最初の引数として渡す標準パターンには抵抗していましたが、最終的にコンテキスト付きとコンテキストなしの2つのロギングメソッドセットを作成しました。

変更しなかった点の1つは、属性を表すための交互のキーと値の構文に関するものです。

slog.Info("message", "k1", v1, "k2", v2)

多くの人が、これは悪いアイデアだと強く感じていました。可読性が低く、キーや値を省略することで誤りやすいと考えたからです。彼らは、構造表現には明示的な属性を好みました。

slog.Info("message", slog.Int("k1", v1), slog.String("k2", v2))

しかし、私たちは、特にGo初心者にとって、軽量な構文がGoを簡単で楽しく使うために重要だと考えました。また、logrgo-kit/logzapSugaredLoggerを含む)など、いくつかのGoロギングパッケージが、交互にキーと値を使用していることを知っていました。一般的なミスを検出するためのvetチェックを追加しましたが、設計は変更しませんでした。

2023年3月15日、この提案は承認されましたが、いくつかの軽微な未解決の問題が残っていました。その後数週間で、さらに10個の変更が提案され、解決されました。7月初旬までに、log/slogパッケージの実装が、ハンドラーを確認するためのtesting/slogtestパッケージと、交互のキーと値の正しい使用に関するvetチェックと共に完成しました。

そして8月8日、Go 1.21がリリースされ、slogも同時にリリースされました。皆様にとって有用であり、開発時と同じくらい楽しく使用していただけることを願っています。

そして、議論と提案プロセスに参加してくださった皆様に心から感謝申し上げます。皆様の貢献によって、slogは非常に改善されました。

リソース

log/slogパッケージのドキュメントでは、使用方法といくつかの例を示しています。

Wikiページには、Goコミュニティが提供する追加のリソース(さまざまなハンドラーを含む)があります。

ハンドラーを作成する場合は、ハンドラー作成ガイドを参照してください。

次の記事:完全に再現可能な、検証済みのGoツールチェーン
前の記事:Go 1.21における前方互換性とツールチェーン管理
ブログインデックス