🧑‍🔧

もっと log/slog を使おう

2023/12/25に公開

はじめに

この記事は Go アドベントカレンダー 2023 の最終日 25 日目の記事です。

皆さん log/slog 使ってますか。便利なのでぜひ使ってください。
slog は構造化ログを出力する為のパッケージで Go 1.21 で導入されました。これまでも zapzerolog といったサードパーティ製のロガーを使う事で構造化ログを出力する事ができましたが、構造化ログを出力する機能が Go の標準ライブラリになりました。

slog とは

通常の log パッケージは、時刻とメッセージの単純な出力になります。

main.go
log.Printf("name=%v, value=%v", name, value)
2023/12/14 15:00:48 name=Orange, value=200

slog を使うとこうなります。

main.go
slog.Info("fruit", "name", name, "value", value)
2023/12/14 15:05:38 INFO fruit name=Orange value=200

コードを見ただけでも INFO というログレベルも出力される様になっていて幾分便利になった気もします。

ただ slog の実際の威力はそこではないのです。

main.go
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
logger.Info("fruit", "name", name, "value", value)

これを実行すると以下が出力されます。

{"time":"2023-12-06T15:05:38.027288704+09:00","level":"INFO","msg":"fruit","name":"Orange","value":200}

引数で key/value を指定するのではなく slog.Intslog.String を使って出力する事もできます。

main.go
logger.Info("fruit", slog.String("name", name), slog.Int("value", value)))

JSON ハンドラを使う事で構造化されたログを出力できる様になりました。これが標準ライブラリで扱える様になったのはとても大きな事なのです。

ちなみに変数 logger を各関数に引き回すのが嫌だという方はこんな感じにデフォルトにしちゃうと良いです。

package main

import (
	"log/slog"
	"os"
)

func init() {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil)))
}

func main() {
	slog.Error("my-app", "name", "foo")
}

slog で何が嬉しい?

ログが JSON で出力される様になって何が嬉しいか。それは運用し始めた後で分かります。

ログをデータとして扱える

みなさんもこれまで、アプリケーションが出力したログから特定の値だけ抜き出したいと思った事が数万回はあると思います。
そして出力してしまった後から「もうちょい正規表現で扱いやすいログの形式にしておけば良かったなぁ」と思ったりもしたと思います。slog で出力したログはそのまま値になるのです。

{"time":"2023-12-14T15:43:37.361994592+09:00","level":"INFO","msg":"fruit","name":"Apple","value":195}
{"time":"2023-12-14T15:43:37.361997454+09:00","level":"INFO","msg":"fruit","name":"Banana","value":199}
{"time":"2023-12-14T15:43:37.362001007+09:00","level":"INFO","msg":"fruit","name":"Banana","value":199}
{"time":"2023-12-14T15:43:37.362005631+09:00","level":"INFO","msg":"fruit","name":"Orange","value":203}
{"time":"2023-12-14T15:43:37.362008957+09:00","level":"INFO","msg":"fruit","name":"Apple","value":195}
{"time":"2023-12-14T15:43:37.362011694+09:00","level":"INFO","msg":"fruit","name":"Banana","value":204}
{"time":"2023-12-14T15:43:37.362015401+09:00","level":"INFO","msg":"fruit","name":"Apple","value":204}
{"time":"2023-12-14T15:43:37.362019635+09:00","level":"INFO","msg":"fruit","name":"Banana","value":197}
{"time":"2023-12-14T15:43:37.362023534+09:00","level":"INFO","msg":"fruit","name":"Apple","value":200}
{"time":"2023-12-14T15:43:37.362028067+09:00","level":"INFO","msg":"fruit","name":"Grape","value":198}
{"time":"2023-12-14T15:43:37.36203106+09:00","level":"INFO","msg":"fruit","name":"Apple","value":202}
{"time":"2023-12-14T15:43:37.36203375+09:00","level":"INFO","msg":"fruit","name":"Orange","value":196}
{"time":"2023-12-14T15:43:37.362036514+09:00","level":"INFO","msg":"fruit","name":"Apple","value":201}
{"time":"2023-12-14T15:43:37.362039196+09:00","level":"INFO","msg":"fruit","name":"Banana","value":199}
{"time":"2023-12-14T15:43:37.362041986+09:00","level":"INFO","msg":"fruit","name":"Orange","value":203}

この様なログが出力されていた時、「名前と値と日付で抽出して CSV 形式で出力したいな」と思った時、これまでの形式だと、日付が先頭に出力されていたので正規表現などを使って加工する必要がありました。
しかし構造化されたログであれば

$ cat output.log | jq -r '[.name, .time] | @csv'
"Banana","2023-12-06T15:43:37.362011694+09:00"
"Apple","2023-12-06T15:43:37.362015401+09:00"
"Banana","2023-12-06T15:43:37.362019635+09:00"
"Apple","2023-12-06T15:43:37.362023534+09:00"
"Grape","2023-12-06T15:43:37.362028067+09:00"
"Apple","2023-12-06T15:43:37.36203106+09:00"
"Orange","2023-12-06T15:43:37.36203375+09:00"
"Apple","2023-12-06T15:43:37.362036514+09:00"
"Banana","2023-12-06T15:43:37.362039196+09:00"
"Orange","2023-12-06T15:43:37.362041986+09:00"

こんなに簡単にフォーマット変換ができるのです。例えばログレベルが INFO の場合だけ抽出したいのであれば

$ cat output.log | jq -r 'select(.level == "INFO") | [.name, .time] | @csv'

こうです。便利!

CloudWatch Logs で使える

構造化されていると嬉しいのはこれだけではありません。AWS CloudWatch Logs を使っておられる皆さんであれば、CloudWatch Logs Insights を使ってログを検索された事があると思います。通常のログであれば日付とメッセージ部にしか分けられないので

fields @timestamp, @message

というクエリを流さざるを得ません。もちろん parse コマンドを使ってフィールド定義を頑張る事もできますが、構造化されていると

fields @timestamp, level, msg, name, value
| filter level = "INFO"

これだけで良いのです。

便利すぎませんか?CloudWatch Logs で検索窓に

{ $.level = "INFO" }

を入力し、INFO のみに絞られた結果からメトリクスフィルタを作れば

サブスクリプションフィルタからアラームも構築できる。便利。

PostgreSQL で使える

fluent-bit を使えばログをそのまま PostgreSQL や MySQL に格納できます。

config.conf
[SERVICE]
  flush 3
  Parsers_File ./parsers.conf

[INPUT]
  name stdin
  tag my-log

[FILTER]
  Name         parser
  Parser       slog
  Match        my-log
  Key_Name     time
  Reserve_Data On
  Preserve_Key On

[OUTPUT]
  name stdout
  match *

[OUTPUT]
  Name     pgsql
  Match    *
  Host     127.0.0.1
  Port     5432
  User     postgres
  Password postgres
  Database slog-example
  Table    fluentbit
parser.conf
[PARSER]
  Name        slog
  Format      json
  Time_Key    time
  Time_Keep   On

例では入力に stdin プラグインを使っていますが実際は tail プラグインを使い、ログ出力ディレクトリのファイルを扱って下さい。もし検証の為に実行されるのであれば以下のソースコードをビルドして実行して下さい。

main.go
package main

import (
	"log/slog"
	"math/rand"
	"os"
	"time"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
	ss := []string{
		"Orange",
		"Banana",
		"Apple",
		"Grape",
	}
	for i := 0; i < 100; i++ {
		s := ss[rand.Int()%len(ss)]
		logger.Info("fruit", "name", s, "value", 200+rand.Int()%10-5)
		time.Sleep(500 * time.Millisecond)
	}
}
$ ./slog-example |& /opt/fluent-bit/bin/fluent-bit -c config.conf

この様に指定してログを fluent-bit に入力すると PostgreSQL のデータベース slog-example、テーブル fluentbit にデータが追加されます。テーブルのカラム情報は以下の通り。

slog-example=# \d fluentbit;
                       Table "public.fluentbit"
 Column |            Type             | Collation | Nullable | Default 
--------+-----------------------------+-----------+----------+---------
 tag    | character varying           |           |          | 
 time   | timestamp without time zone |           |          | 
 data   | jsonb                       |           |          | 

data が jsonb 型になっています。
つまり JSON 演算子を使って data の中身を SELECT できるという事です。

slog-example=# SELECT data->>'name' name, data->'value' value
slog-example-#   FROM fluentbit WHERE data->>'level' = 'INFO';
  name  | value 
--------+-------
 Apple  | 202
 Banana | 191
 Banana | 190
 Apple  | 191
 Apple  | 209
 Banana | 201
 Grape  | 206
 Banana | 203
 Apple  | 204
 Apple  | 195
(10 rows)

とても便利。

slog はカスタマイズできる

slog はかなり柔軟な作りになっており、カスタマイズすればいろいろな実装を行う事ができます。

slog.Group

slog.Infoslog.Warnslog.Error の第2引数以降で渡した属性名および属性値は通常、JSON のルート属性に出力されますが、slog.Group を使う事でグルーピング出来ます。

main.go
logger.Info("This is info log", slog.Group("attr",
	slog.String("name", "banana"),
	slog.Int("count", 1),
))
{
  "time": "2023-12-17T23:34:42.120558315+09:00",
  "level": "INFO",
  "msg": "This is info log",
  "attr": {
    "name": "banana",
    "count": 1
  }
}

※ jq にて整形済み

付加した属性値だけを取り出す場合に

$ jq .attr

で取り出せて便利です。

slog.HandlerOptions

slog のハンドラを作成する NewXXX 関数には一般的に slog.HandlerOptions を渡す様になっています。

type HandlerOptions struct {
	AddSource bool
	Level Leveler
	ReplaceAttr func(groups []string, a Attr) Attr
}

ソースコードのパスを出力する

AddSource に true を設定するとログ出力関数を呼び出したソースコードのパスが出力されます。

{
  "time": "2023-12-17T22:43:52.528248746+09:00",
  "level": "INFO",
  "source": {
    "function": "main.main",
    "file": "/home/mattn/dev/slog-example/main.go",
    "line": 83
  },
  "msg": "This is info log"
}
{
  "time": "2023-12-17T22:43:53.229435794+09:00",
  "level": "WARN",
  "source": {
    "function": "main.main",
    "file": "/home/mattn/dev/slog-example/main.go",
    "line": 85
  },
  "msg": "This is warn log"
}

※ jq にて整形済み

ログレベルを動的に変更する

Leveler を設定すると、ログレベルを動的に変更する事ができます。

type Leveler interface {
	Level() Level
}

だいたい実装が想像できると思いますので実装は割愛しますが、この Level は書いたプログラムの意図 (Info/Warn/Error) とは異なるレベルが出力される事になるため、デバッグをし辛くする可能性があります。

特定の属性を変更する

ReplaceAttr を使うと特定の属性の値を変更する事ができます。これを利用する事で Info/Warn/Error の第一引数である msg 属性を消すこともできます。

jh := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
		if a.Key == slog.MessageKey && len(groups) == 0 {
			return slog.Attr{}
		}
		return a
	},
})

Leveler 同様に、プログラムコードの意図とは異なる動作になり得ます。ご注意を。

付加情報を足すログハンドラ

slog.Infoslog.Warn ではメッセージしか出力しないけれど、かならず勝手に付加情報を与えたい場合もあります。例えば固定のキーで変動しうる属性を付け足したいとします。現在のメトリクス値でも良いですね。そういう場合に便利なのが slog.Handler です。slog.Handler はインタフェースであり、これに合った実装を行うと slog.New にハンドラとして渡す事ができます。

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

もともと用意されたハンドラを wrap する形式で新しいハンドラを作るとカスタマイズできます。

main.go
package main

import (
	"context"
	"log/slog"
	"math/rand"
	"os"
)

type ExtraAttrHandler struct {
	handler slog.Handler
	fn      ExtraAttrFunc
}

type ExtraAttrFunc func() []slog.Attr

func NewExtraAttrHandler(h slog.Handler, fn ExtraAttrFunc) *ExtraAttrHandler {
	if lh, ok := h.(*ExtraAttrHandler); ok {
		h = lh.Handler()
	}
	return &ExtraAttrHandler{handler: h, fn: fn}
}

func (h *ExtraAttrHandler) Enabled(ctx context.Context, level slog.Level) bool {
	return h.handler.Enabled(ctx, level)
}

func (h *ExtraAttrHandler) Handle(ctx context.Context, r slog.Record) error {
	r.AddAttrs(h.fn()...)
	return h.handler.Handle(ctx, r)
}

func (h *ExtraAttrHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return NewExtraAttrHandler(h.handler.WithAttrs(attrs), h.fn)
}

func (h *ExtraAttrHandler) WithGroup(name string) slog.Handler {
	return NewExtraAttrHandler(h.handler.WithGroup(name), h.fn)
}

func (h *ExtraAttrHandler) Handler() slog.Handler {
	return h.handler
}

この様に実装する事で、例えば以下のように JSONHandler に付加情報を与える事ができます。

main.go
var janken = []string{"ぐー", "ちょき", "ぱー"}

func main() {
	jh := slog.NewJSONHandler(os.Stderr, nil)
	logger := slog.New(NewExtraAttrHandler(jh, func() []slog.Attr {
		return []slog.Attr{
			slog.String("じゃんけん", janken[rand.Int()%len(janken)]),
		}
	}))
	logger.Info("This is info log")
	logger.Warn("This is warn log")
}

実行すると以下の様に毎行 "じゃんけん" の結果が出力されます。

{
  "time": "2023-12-17T22:19:00.596395695+09:00",
  "level": "INFO",
  "msg": "This is info log",
  "じゃんけん": "ぱー"
}
{
  "time": "2023-12-17T22:19:00.596856731+09:00",
  "level": "WARN",
  "msg": "This is warn log",
  "じゃんけん": "ぐー"
}

※ jq にて整形済み

この与えた属性付与の関数はログの出力を遅らせてしまう可能性があり本意ではないはずです。そういった場合には sync.Map と併用すると簡単です。

main.go
func main() {
	m := sync.Map{}

	go func() {
		for {
			m.Store("じゃんけん", janken[rand.Int()%len(janken)])
			time.Sleep(time.Second)
		}
	}()

	jh := slog.NewJSONHandler(os.Stderr, nil)
	logger := slog.New(NewExtraAttrHandler(jh, func() []slog.Attr {
		attrs := []slog.Attr{}
		m.Range(func(key, value any) bool {
			attrs = append(attrs, slog.Any(fmt.Sprint(key), value))
			return true
		})
		return attrs
	}))

	time.Sleep(700 * time.Millisecond)
	logger.Info("This is info log")
	time.Sleep(700 * time.Millisecond)
	logger.Warn("This is warn log")
}

高速に logger.Info を使うと同じ値が出力されてしまいますが、例えば CPU の負荷やメモリの負荷を付与するといった要件では使える方法だと思います。ただし実用的には、一般のメッセージを出力するログと、メトリクス情報を出力するログは、別途出力を行い、区別できる付加属性(typeなど)を付与するべきです。例えば以下の様に。

{
  "time": "2023-12-17T22:19:00.596395695+09:00",
  "level": "INFO",
  "msg": "This is info log",
  "type": "app"
}
{
  "time": "2023-12-17T22:19:00.596856731+09:00",
  "level": "WARN",
  "msg": "This is warn log",
  "type": "app"
}
{
  "time": "2023-12-17T22:19:00.596856731+09:00",
  "level": "WARN",
  "msg": "metrics",
  "type": "metric",
  "cpu": 66,
  "memory": 25
}

※ jq にて整形済み

こうする事で後から jq で抽出したり AWS CloudWatch Logs Insights でメトリクスの値だけを抽出したりしやすくなります。

おわりに

今まで雑に出力していたログが簡単にデータとなりました。今まで苦労して大量のログファイルを grep し sed で加工していた作業はもう必要ありません。ぜひ slog を使って「楽」をして下さい。

さて Go アドベントカレンダーに参加して頂いた皆様、ありがとうございました。そして今年も1年、お疲れ様でした。後少しで今年も終わりです。風邪などひかず暖かくしてお過ごし下さい。

Discussion