LoginSignup
12

More than 5 years have passed since last update.

zapでログレベルでログの出力先を振り分ける方法

Last updated at Posted at 2017-03-29

経緯

Golangの構造化ロギングライブラリ『zap』を使ってて、ログレベル毎に出力先を分けたかったんだけど標準のCoreの実装ではできなさそう。
出力先を複数指定することはできるんだけど、同じログを複数の出力先に書き出すってだけで、InfoはこっちでErrorはこっちみたいなことはできなかった。
ログレベル毎にzapのLogger作ってそれらをラップした独自のLoggerみたいのを作ればできそうだけど、それだとログレベル毎にLoggerは独立しちゃうからWithとかNamedの恩恵が受けられない。
ということで、ログレベル毎に利用するCoreを分けるようなCoreを自作したら割りとうまくいったという話。

  • zapの基本的な使い方は以前に書いたので興味があればご参考まで。

やったこと

とりあえず今回はErrorレベル未満のログ(Debug/Info/Warn)の場合は普通の出力先(標準出力とログファイル)に出力して、Errorレベル以上のログ(Error/DPanic/Panic/Fatal)の場合はエラー用の出力先(標準エラーとエラー用のファイル)に出力するようにしたい。
なので、

  • 独自のCore(errorDispatcher)を作成
  • ConfigからBuildできた方がいいかなということでzap.Configを拡張したErrorDispatcherConfigを作成

というところまでやった。

Coreをつくる

core/error_dispatcher.go
import "go.uber.org/zap/zapcore"

func NewErrorDispatcher(base zapcore.Core, err zapcore.Core) zapcore.Core {
    return &errorDispatcher{
        Core: base,
        err:  err,
    }
}

type errorDispatcher struct {
    zapcore.Core
    err zapcore.Core
}

func (e *errorDispatcher) With(fields []zapcore.Field) zapcore.Core {
    clone := e.clone()
    clone.Core = e.Core.With(fields)
    clone.err = e.err.With(fields)
    return clone
}

func (e *errorDispatcher) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
    if ent.Level >= zapcore.ErrorLevel {
        return e.err.Check(ent, ce)
    }
    return e.Core.Check(ent, ce)
}

func (e *errorDispatcher) Sync() error {
    if err := e.err.Sync(); err != nil {
        return err
    }
    return e.Core.Sync()
}

func (e *errorDispatcher) clone() *errorDispatcher {
    return &errorDispatcher{
        Core: e.Core,
        err:  e.err,
    }
}

通常用のCoreをembeddedして、エラー用のCoreをerrメンバに持つerrorDispatcherを作って、zapcore.Coreインターフェースを満たすようにメソッドを実装した。

ポイント

  • 通常用のCoreをembeddedする

embeddedすることでEnabledメソッドやWriteメソッドを定義しなくて済む。
ioCoreと同じだしね。

  • Withメソッドではクローンして新しいerrorDispatcherを返す

クローンしないとWithの呼び出し元のCoreが変わってしまうのでzapの挙動としてはおかしい。Withの呼び出し元のCoreとWithから返されるCoreは別物でないといけない。

  • Withメソッドでは保持している全てのCoreに対してWithメソッドを呼び出す

今回のように複数のCoreを保持している場合、その全てに対してWithメソッドを呼び出してやる。でないとFieldの引き継ぎに不整合が出てきてしまう。

  • Checkでログレベル毎に使うCoreを分ける

ここが本命。
Checkはロギングメソッドから呼び出され、有効なログレベルなどを満たしていればcheckしたCoreを格納したCheckedEntryが返される。CheckedEntryから格納されている全てのCoreのWriteメソッドが呼ばれ、ロギングする。
なので、このCheckメソッドをどのCoreでやるかというのがポイント。
今回はErrorレベル以上はエラー用のCore(errorDispatcher.err)でやりたいので条件を設定して振り分けをしている。

Configをつくる

config/error_dispatcher.go~抜粋~
type ErrorDispatcherConfig struct {
    zap.Config           `json:",inline" yaml:",inline"`
    ErrorDispatcherPaths []string `json:"errorDispatcherPaths" yaml:"errorDispatcherPaths"`
}

func (c *ErrorDispatcherConfig) Build(opts ...zap.Option) (*zap.Logger, error) {
    enc, err := c.buildEncoder()
    if err != nil {
        return nil, err
    }

    sink, errDispSink, errSink, err := c.openSinks()
    if err != nil {
        return nil, err
    }

    baseCore := zapcore.NewCore(enc, sink, c.Level)
    errCore := zapcore.NewCore(enc, errDispSink, c.Level)
    errorDispatcher := core.NewErrorDispatcher(baseCore, errCore)
    log := zap.New(
        errorDispatcher,
        c.buildOptions(errSink)...,
    )
    if len(opts) > 0 {
        log = log.WithOptions(opts...)
    }
    return log, nil
}

基本的にはzap.Configを参考に作ってる。
ErrorDispatcherConfigを作って、メンバにembeddedしたzap.ConfigとError以上のログの出力先を指定するErrorDispatcherPathsを定義する。
それぞれjson/yaml用にタグを設定してあげればファイルからの読み込みもできるようになりますね。

Buildメソッドもほぼほぼzap.Configを参考。
違うのはSinksでエラー用のSinkも作って返すようにしてるところと、自作したErrorDispatcherを作って、それをベースにLoggerを作ってるところ。

試してみる

それでは確認してみます。

main.go
func main() {
    configYaml, err := ioutil.ReadFile("example/config.yaml")
    if err != nil {
        panic(err)
    }
    var myConfig config.ErrorDispatcherConfig
    if err := yaml.Unmarshal(configYaml, &myConfig); err != nil {
        panic(err)
    }
    logger, err := myConfig.Build()
    if err != nil {
        panic(err)
    }

    logger.Debug("debug log")
    logger.Error("error log")
    logger = logger.With(zap.String("with", "with_value"))
    logger.Info("debug log")
    logger.DPanic("dpanic log")
    logger = logger.Named("named")
    logger.Warn("warn log")
    logger.Fatal("fatal log")
}

Yamlファイルから読み込んでErrorDispatcherConfigにUnmarshal。
BuildメソッドからLoggerを生成してロギング。
標準のzapと同じように扱えるのはうれしいことだと思う。

config.yaml
level: "debug"
development: false
disableCaller: true
disableStacktrace: true
encoding: "console"
encoderConfig:
  messageKey: "Msg"
  levelKey: "Level"
  timeKey: "Time"
  nameKey: "Name"
  callerKey: "Caller"
  stacktraceKey: "St"
  levelEncoder: "capital"
  timeEncoder: "iso8601"
  durationEncoder: "string"
  callerEncoder: "short"
outputPaths:
  - "stdout"
  - "example/yaml_out.log"
errorDispatcherPaths:
  - "stderr"
  - "example/yaml_err.log"
errorOutputPaths:
  - "stderr"

Yamlファイルはこんな感じ。
今回追加したerrorDispatcherPathsには標準エラーとエラー用のファイルパスを設定してる。

yaml_out.log
2017-03-29T13:38:19.847+0900    DEBUG   debug log
2017-03-29T13:38:19.847+0900    INFO    debug log   {"with": "with_value"}
2017-03-29T13:38:19.847+0900    WARN    named   warn log    {"with": "with_value"}
yaml_err.log
2017-03-29T13:38:19.847+0900    ERROR   error log
2017-03-29T13:38:19.847+0900    DPANIC  dpanic log  {"with": "with_value"}
2017-03-29T13:38:19.847+0900    FATAL   named   fatal log   {"with": "with_value"}

結果はこんな感じで、Errorレベル未満のログとそれ以上のログで分かれて出力されるようになった。
Withで繋いだところも大丈夫そう。

所感

比較的簡単にできた。
今回やったのは単純にする為に2つのCoreでやったけど、より柔軟にするなら、

type levelDispatcher struct {
    zapcore.Core
    cores map[zapcore.Level]zapcore.Core
}

とかにして、レベル毎にCoreをセットできるようにすればどんなログレベルでも対応できるはず。

zapはCoreレベルまで弄れば柔軟にやりたいことができるのがいいですね。
とはいえ、これくらい標準でできないのかなーとは思えてならない。

今回のサンプルはgithubに置いてあるので参考まで。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12