チーム開発参加の記録【2023-10～2024-03】(7) slogでErrorレベルのログをSlackに飛ばしてみた

# 本シリーズのリンク

- [チーム開発参加の記録【2023-10～2024-03】(1) Go言語用ORM「Bun」をDBファーストで使う試み（SQLite使用）](https://qiita.com/kanedaq/items/97ca682602b626d5d717)
- [チーム開発参加の記録【2023-10～2024-03】(2) Go言語からTursoを使ってみた](https://qiita.com/kanedaq/items/0c81946306d2412df555)
- [チーム開発参加の記録【2023-10～2024-03】(3) slogを使ってリクエスト・レスポンス情報をログ出力してみた](https://qiita.com/kanedaq/items/3914de42cb111c8817bc)
- [チーム開発参加の記録【2023-10～2024-03】(4) Go言語用ORM「Bun」をDBファーストで使う試み（PostgreSQL使用）](https://qiita.com/kanedaq/items/ec9bd42fefc755559df6)
- [チーム開発参加の記録【2023-10～2024-03】(5) Go言語用ORM「Bun」でトランザクション、UPSERT、JOINを使ってみた](https://qiita.com/kanedaq/items/0d3f5f44202548839d78)
- [チーム開発参加の記録【2023-10～2024-03】(6) Go言語用ORM「Bun」で複数のクエリーをまとめてDBサーバーで実行](https://qiita.com/kanedaq/items/c446f937cee101e24c3e)
- チーム開発参加の記録【2023-10～2024-03】(7) slogでErrorレベルのログをSlackに飛ばしてみた

# 本記事で行うこと

[本シリーズの3番目の記事](https://qiita.com/kanedaq/items/3914de42cb111c8817bc)で、slogを使って標準出力とログファイルにログを出力しました。
本記事ではそれに加えて、ErrorレベルのログをSlackに飛ばしてみます。

# サードパーティーのライブラリを探す

## Slackにログを飛ばすライブラリ

「golang slog slack」でググったところ、「[slog: Slack handler](https://github.com/samber/slog-slack)」というライブラリが見つかりました。
[exampleコード](https://github.com/samber/slog-slack/blob/main/example/example.go)があったので動かしてみたところ、あれ、Slackにログが飛ばない．．
原因を探ってみると、[handler.go](https://github.com/samber/slog-slack/blob/main/handler.go)に以下の記述がありました。

```go
	go func() {
		_ = h.postMessage(ctx, message)
	}()
```

どうやら、ゴルーチンでSlackにログを飛ばそうとしたけど、その前にメインルーチン自体が終わってしまったようです。
試しにexample.goの最後に以下を追加したら、無事にSlackにログが飛びました。

```go
	time.Sleep(3 * time.Second)
```

Slackの画面：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/55d743ee-edb8-99c0-eb3e-912176bbeb13.png)

このライブラリ、使ってみます。

## slogで複数のHandlerを使うライブラリ

今まで使っていたJSONHandlerに加えて、上記のSlackHandlerも使いたいので、slogで複数のHandlerを使えるライブラリを探します。
「golang slog multiple handlers」でググったところ、「[slog: Handler chaining, fanout, routing, failover, load balancing...](https://github.com/samber/slog-multi)」というライブラリが見つかりました。
先ほどの「slog: Slack handler」と作者が同じですね。
これも使ってみることにします。

# ソースコード

それでは、今回書いたソースコード全体を載せてしまいます。
[3番目の記事](https://qiita.com/kanedaq/items/3914de42cb111c8817bc)のコードを少しアレンジしました。

## ディレクトリ構成とファイル一覧

log/ディレクトリはログファイルの出力先です。

```
Project Root
  ├── log/
  │     └── *.log
  ├── utils/
  │     ├── middlewares/
  │     │     └── LoggingMiddleware.go
  │     └── log.go
  ├── app.go
  └── go.mod
```

## utils/middlewares/LoggingMiddleware.go

LoggingMiddleware.goは、[3番目の記事](https://qiita.com/kanedaq/items/3914de42cb111c8817bc#utilsmiddlewaresloggingmiddlewarego)と全く同じなので割愛します。

## utils/log.go

SlackHandlerも使うように記述します。
コード中のwebhook変数は書き換える必要があります。

```log.go
package utils

import (
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"time"

	"github.com/labstack/echo/v4"
	slogmulti "github.com/samber/slog-multi"
	slogslack "github.com/samber/slog-slack/v2"
)

// GetRootLogger は以下の設定を持つロガーを返します:
// - ログを標準出力とログファイルの両方に出力します
// - ログファイルは "./log" ディレクトリ配下に作成されます
// - "./log" ディレクトリがなければ、0755の権限で作成されます
// - ログファイル名は "YYYY-MM-DD-HH-MM.log" 形式です
// - ロガーはINFO以上のログをJSON形式で書き込みます
// - コード中のAddSourceをtrueにすると、ログ出力したソースコードの場所も出力されます
func GetRootLogger() *slog.Logger {
	// ログ出力先
	dir := "./log/"

	// パスが存在しなければディレクトリを作成
	if _, err := os.Stat(dir); os.IsNotExist(err) {
		err = os.MkdirAll(dir, 0755) // ディレクトリがない場合は作成
		if err != nil {              // ディレクトリの作成に失敗したらエラー
			panic(err)
		}
	}

	filename := time.Now().Format("2006-01-02-15-04") + ".log"
	path := filepath.Join(dir, filename)

	logfile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
	if err != nil {
		panic(err)
	}

	// JSONHandlerで標準出力とログファイルの両方に出力
	multiWriter := io.MultiWriter(os.Stdout, logfile)
	// SlackHandlerでSlackに出力
	webhook := "https://hooks.slack.com/services/書き換えてください"
	return slog.New(slogmulti.Fanout(
		slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{AddSource: true, Level: slog.LevelInfo}),
		slogslack.Option{Level: slog.LevelError, WebhookURL: webhook}.NewSlackHandler(),
	))
}

// GetApiLogger はAPI用のロガーを返す関数です。
// 各APIのハンドラから呼ばれることを想定しています。
// 引数としてecho.Contextを受け取り、その中から"trace"値を抽出して
// 自動で出力ログに加えます。
//
// 使用例:
//
//	logger := GetApiLogger(c)
//	logger.Info("こんにちは")
//
// 上記の例では、GetApiLogger関数を使用してロガーを取得し、
// "こんにちは"とメッセージをログ出力します。
func GetApiLogger(c echo.Context) *slog.Logger {
	getLoggingTrace := func() string {
		trace := c.Get("trace")
		if trace != nil {
			return trace.(string)
		} else {
			return "nil"
		}
	}
	return slog.With(slog.String("trace", getLoggingTrace()))
}
```

3番目の記事のlog.goとの差分は以下の通りです。

```diff
@@ -8,6 +8,8 @@
        "time"

        "github.com/labstack/echo/v4"
+       slogmulti "github.com/samber/slog-multi"
+       slogslack "github.com/samber/slog-slack/v2"
 )

 // GetRootLogger は以下の設定を持つロガーを返します:
@@ -37,12 +39,14 @@
                panic(err)
        }

-       // ログを標準出力とログファイルの両方に出力
+       // JSONHandlerで標準出力とログファイルの両方に出力
        multiWriter := io.MultiWriter(os.Stdout, logfile)
-       return slog.New(slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{
-               AddSource: true,
-               Level:     slog.LevelInfo,
-       }))
+       // SlackHandlerでSlackに出力
+       webhook := "https://hooks.slack.com/services/書き換えてください"
+       return slog.New(slogmulti.Fanout(
+               slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{AddSource: true, Level: slog.LevelInfo}),
+               slogslack.Option{Level: slog.LevelError, WebhookURL: webhook}.NewSlackHandler(),
+       ))
 }

 // GetApiLogger はAPI用のロガーを返す関数です。
@@ -68,4 +72,3 @@
        }
        return slog.With(slog.String("trace", getLoggingTrace()))
 }
```

## app.go

動作確認用に、Errorレベルでログ出力するコードを加えました。

```app.go
package main

import (
	"exercise_log/utils"
	"exercise_log/utils/middlewares"
	"log/slog"
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

type (
	httpError struct {
		Error string `json:"error"`
	}
)

func handler(c echo.Context) error {
	type (
		requestBodyType struct {
			No int `json:"no"`
		}
		responseType struct {
			PathParam  string `json:"pathParam"`
			QueryParam string `json:"queryParam"`
			Body       int    `json:"body"`
		}
	)

	// ハンドラ先頭でGetApiLogger()を呼ぶ
	logger := utils.GetApiLogger(c)

	// ハンドラへの出入りをログ出力
	logger.Info(".. in")
	defer logger.Info(".. out")

	// Errorレベルでログ出力
	logger.Error("エラーです")

	// パスパラメータ
	pathParam := c.Param("pathParam")

	// クエリパラメータ
	queryParam := c.QueryParam("queryParam")

	// リクエストボディ
	reqBody := new(requestBodyType)
	if err := c.Bind(reqBody); err != nil {
		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, responseType{PathParam: pathParam, QueryParam: queryParam, Body: reqBody.No})
}

func main() {
	slog.SetDefault(utils.GetRootLogger())

	// EchoでAPIサーバー
	e := echo.New()
	e.Use(middleware.Recover())
	e.Use(middlewares.LoggingMiddleware)

	// Routing
	api := e.Group("/api")
	api.POST("/foo/:pathParam", handler)

	e.Logger.Fatal(e.Start(":1323"))
}
```

3番目の記事のapp.goとの差分は以下の通りです。

```diff
@@ -35,6 +35,9 @@
        logger.Info(".. in")
        defer logger.Info(".. out")

+       // Errorレベルでログ出力
+       logger.Error("エラーです")
+
        // パスパラメータ
        pathParam := c.Param("pathParam")

@@ -64,4 +67,3 @@

        e.Logger.Fatal(e.Start(":1323"))
 }
```

## go.mod

```go.mod
module exercise_log

go 1.21

require (
	github.com/labstack/echo/v4 v4.11.3
	github.com/matoous/go-nanoid/v2 v2.0.0
	github.com/samber/slog-multi v1.0.2
	github.com/samber/slog-slack/v2 v2.2.0
)

require (
	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
	github.com/gorilla/websocket v1.4.2 // indirect
	github.com/labstack/gommon v0.4.0 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.19 // indirect
	github.com/samber/lo v1.38.1 // indirect
	github.com/samber/slog-common v0.11.0 // indirect
	github.com/slack-go/slack v0.12.1 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasttemplate v1.2.2 // indirect
	golang.org/x/crypto v0.14.0 // indirect
	golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
	golang.org/x/net v0.17.0 // indirect
	golang.org/x/sys v0.13.0 // indirect
	golang.org/x/text v0.13.0 // indirect
	golang.org/x/time v0.3.0 // indirect
)
```

# 動かしてみる

[3番目の記事](https://qiita.com/kanedaq/items/3914de42cb111c8817bc#%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)と同じ手順で動かしてみます。

## APIを呼んでみる

```
http://localhost:1323/api/foo/bar?queryParam=baz
```

実行結果です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/4152f1eb-8bca-2aa5-893e-09abcb35b719.png)

## ログを確認する

SlackにErrorレベルのログが飛んできました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/a085c3be-de65-38c5-f60c-7a54085014c1.png)

それと同時に、標準出力とログファイルに以下が出力されました。
本記事で処理を追加したErrorレベルのログは、2行目に出力されています。

```
{"time":"2023-12-24T15:54:28.665017123+09:00","level":"INFO","source":{"function":"main.handler","file":"/home/user/GolandProjects/exercise_log/app.go","line":35},"msg":".. in","trace":"fL5pBUnAE_tzLQOnt9JjE"}
{"time":"2023-12-24T15:54:28.665120317+09:00","level":"ERROR","source":{"function":"main.handler","file":"/home/user/GolandProjects/exercise_log/app.go","line":39},"msg":"エラーです","trace":"fL5pBUnAE_tzLQOnt9JjE"}
{"time":"2023-12-24T15:54:28.66519141+09:00","level":"INFO","source":{"function":"exercise_log/utils/middlewares.reqLogging","file":"/home/user/GolandProjects/exercise_log/utils/middlewares/LoggingMiddleware.go","line":127},"msg":"[request]","trace":"fL5pBUnAE_tzLQOnt9JjE","request":{"remoteIp":"::1","method":"POST","path":"/api/foo/bar","queryParams":{"queryParam":["baz"]},"body":{"no":12345}}}
{"time":"2023-12-24T15:54:28.665271209+09:00","level":"INFO","source":{"function":"main.handler","file":"/home/user/GolandProjects/exercise_log/app.go","line":53},"msg":".. out","trace":"fL5pBUnAE_tzLQOnt9JjE"}
{"time":"2023-12-24T15:54:28.66611766+09:00","level":"INFO","source":{"function":"exercise_log/utils/middlewares.respLogging","file":"/home/user/GolandProjects/exercise_log/utils/middlewares/LoggingMiddleware.go","line":166},"msg":"[response]","trace":"fL5pBUnAE_tzLQOnt9JjE","response":{"method":"POST","path":"/api/foo/bar","status":200,"contentType":"application/json; charset=UTF-8","body":{"body":12345,"pathParam":"bar","queryParam":"baz"}}}
```

# まとめ

Errorレベルのログはすぐに確認したいです。
サードパーティーのライブラリを利用して、ErrorレベルのログをSlackに飛ばすことができました。