全社的にGoのロガーをslogに移行した話

ogp

この記事は enechain Advent Calendar 2023 の19日目の記事です。昨日は 近藤 さんの「ダークモードを巡るあれこれ:ダークモードの明るい未来」でした!

はじめに

こんにちは、enechainのApplication Platform Deskでエンジニアをしているendoです。

Application Platform Deskは、全プロダクトが横断で抱える課題を解決するチームであり、今回のロガー対応のような課題を受け持っています。

Go1.21 から、構造化ロガーのslogが標準ライブラリに加わりました

enechainではこれまで主にzapを利用してきましたが、この流れに乗ってslogへの移行を進めております。

そこで本記事では、enechainがどのような方針でslogへの移行を進めているかについてご紹介します。

slog移行前の問題点

まずはslogへの移行前に、既存のログ周りの問題点を洗い出してみました。

  • 全プロダクトでログのキーワードのネーミングが統一されておらずバラバラな状態になっていた
    • 複数のログをDatadogで見たい時に名前を確認しながら検索しないといけないので大変
  • 出力フォーマットのガバナンスが効いておらず、最低限ログに含まれていて欲しい項目が漏れていることがあった
  • 出力を避けたい項目に関するマスキング処理またはログの除外処理を各プロダクトで個別に対応していた

これらの問題を解決しつつプロダクト側にある程度技術的な自由度も持たせる方針で考えていきました。

共通ロガー要件の整理

今回は各プロダクトで個別に対応をせず、共通のロガーを実装して方針を統一することにしました。共通ロガーの実装にあたり何が必要になるのかを、先程の問題点から洗い出しました。

  • ログに必ず落としてほしい必須項目
    • REST / GraphQL / gRPC の全てに対して共通となる必須項目の定義
  • データ基盤連携
    • データ基盤のシステムでBigQueryに取り込み可能なログフォーマットを定義
  • ログのマスキング処理
    • ログに残してはならない項目を含むデータでもそれ以外の部分を残せるように、マスキング処理を実装

上述の必要項目を受けて、以下のように方針を定めました。

  • ログに必ず落としてほしい必須項目の定義データ基盤連携はプロダクト側にボイラープレートという形でサンプルコードを提供
    • もしカスタマイズの必要が発生した場合は、プロダクト側で適宜修正してもらいます
  • マスキング処理 は専用のライブラリを用意して提供
    • 独自にカスタマイズしたい要望も薄いと判断しました

slogの導入

開発組織全体にルールや方針を周知をしようとすると、どこかにログが流れていったり、ドキュメントを探すのに時間がかかったりすることが往々にしてあります。

そこで今回は、ボイラープレートとしてコードサンプルを提供し、ドキュメント検索等の煩わしさを回避してメンテナンス性向上を目指しています。

ボイラープレート内ではconnect-goへのログの導入を行うため下記のようにInterceptorを用意してデフォルトで追加したいAttrを引き回せるようにしました。

slogでは構造化ログを出力する時にAttr(Attributeの略)単位でログを落とすことが大半だと思います。

Attrをどのログでも出力されるようにするためにはWithを使用すること実現できます。この例では必ず入っていてほしい(と仮定した)requestIdが必ず出力されるようにWithを用いて必ずログに出力されるようにしています。

func NewLoggingInterceptor() connect.UnaryInterceptorFunc {
    interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
        return connect.UnaryFunc(func(
            ctx context.Context,
            req connect.AnyRequest,
        ) (connect.AnyResponse, error) {
      requestId := "dummy"
            logger := slogctx.Logger(ctx).With(
                slog.String("requestId", requestId),
            )
            nctx := slogctx.WithLogger(ctx, logger)

            return next(nctx, req)
        })
    }
    return connect.UnaryInterceptorFunc(interceptor)
}

リクエストのendpoint以降では下記のようにslogctxを使いcontextからloggerを取得することで、NewLoggingInterceptorで追加したrequestIdがログの中に必ず出力されます。

slogctx.Logger(ctx).Info("test message", slog.String("type", "web"))

特殊なプロトコルやフレームワークを使いたい(例えばslogctxを使わないでDIコンテナに突っ込みたい)という時はこのボイラープレートをベースに合わないところを修正して使っていただくことになります。

マスキング処理

実際にログに落とすときには下記のように実装するため、Keyは必ず設定するというルールのもとAttrのKeyの単位でマスキング処理を行うようにしました。

slog.Info("log",
    slog.String("service", "your service name"),
)

slog.StringはAttrの構造体を継承していますが、Attrは下記のような構造体になっておりKeyとValueのセットになっています。

このKeyの部分を使ってマスキング処理を行うようにします。

type Attr struct {
    Key   string
    Value Value
}

ライブラリはslogのReplaceAttrで利用できるようにし、その中にキーワード一覧を渡せばいい感じにマスキング処理を行ってくれるようにします。

入力された文字列長もわからないと困るケースもあるので長さを保存するようにしてありますが、パスワードなど長さが知られたくないものの場合にはstringではなくstructにしてパラメータを渡せるようにし、マスキング後の文字列を固定長にした方が良いでしょう。

func main () {
  reader := strings.NewReader(yamlContent)
  masking ,_ := loggerhandler.NewMaskingReplaceAttr([]string{"password", "secret"})
  logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: masking,
  }))
  slog.SetDefault(logger)

  slog.Info("masking test", slog.String("secret", "大事なデータ"))
}

func NewMaskingReplaceAttr(maskKeywords []string) func(groups []string, a slog.Attr) slog.Attr {
    return func(groups []string, a slog.Attr) slog.Attr {
        for _, v := range maskKeywords {
            if v == a.Key {
                return slog.String(a.Key, strings.Repeat("*", len(a.Value.String())))
            }
        }
        return a
    }
}

社内での運用では渡すフォーマットはyamlでも渡せるようにしており、下記のようにマスキングしたいslogのAttr名を並べておけばマスキング処理をしてくれるようにしています。

mask_keywords:
  - password
  - email
  - credit_card

おわりに

本記事ではslogの導入にあたって実際に行ってきた内容を紹介しました。

実際に運用していると様々な要望が出てくると思いますが、なんでもムリに共通化せずに、みんながチャレンジしたいことを受け入れる余白を残すというのも大事なポイントかと思っております。

今回の導入事例が誰かの参考になれば幸いです。

enechain では、事業拡大のために共に技術力で道を切り拓いていく仲間を募集しています。

herp.careers