every Tech Blog

株式会社エブリーのTech Blogです。

Go 1.26 slog.MultiHandlerの ユースケースを考える

この記事は every Tech Blog Advent Calendar 2025 の 10 日目の記事です。

開発2部の内原です。 今回は、Go 1.26で追加される予定のslog.MultiHandlerについて調べてみたので書いてみます。

概要

Go 1.21で導入されたlog/slogは構造化ログを扱えるため便利なのですが、複数の出力先(標準出力とファイル、標準出力とFluentdなど)に異なる設定でログを出力したい場合、io.MultiWriter を使うか、サードパーティのライブラリに頼る必要がありました。

Go 1.26では、この問題を解決するためにNewMultiHandler関数が追加されます。これにより、複数のハンドラーを同時に利用できるようになり、出力先ごとに異なるログレベルやフォーマットを設定することが可能になります。

この記事では、slog.MultiHandlerの基本的な使い方と、実際のユースケースを想定した実装例を紹介します。

Go 1.26のインストール方法

Go 1.26はまだ正式リリースされていないため、開発版をビルドする必要があります。以下の手順でソースからビルドしました。

$ git clone https://go.googlesource.com/go
$ cd go/src
$ ./make.bash
$ export GOROOT=$(pwd)/..
$ export PATH=$GOROOT/bin:$PATH
$ go version
go version go1.26-devel_f22d37d574 Mon Dec 1 14:59:40 2025 -0800 darwin/arm64

slog.MultiHandlerとは

NewMultiHandler関数は、複数のハンドラーを受け取り、それらすべてにログを送信するMultiHandlerを返します。以下の関数シグネチャになっています。

func NewMultiHandler(handlers ...Handler) *MultiHandler

従来のio.MultiWriterとの違いは、各ハンドラーに対して異なる設定(ログレベル、フォーマットなど)を適用できる点です。例えば、標準出力にはすべてのログを出力し、ファイルには重要なレベルのログのみを出力する、といった柔軟な設定が可能になります。

slog.MultiHandlerの想定ユースケース

以下では、実際のアプリケーションで利用されそうなユースケースと、その実装を書いてみます。

標準出力とログファイル

開発環境では人間が読みやすい形式で出力し、本番環境ではファイルにも記録を残したいというケースです。 環境別に実装を分けるのも手ですが、両方出力すればよいので簡単に思いました。

実装例

package main

import (
    "log/slog"
    "os"
)

func main() {
    // 標準出力用(テキスト形式、DEBUG以上)
    stdoutHandler := slog.NewTextHandler(
        os.Stdout,
        &slog.HandlerOptions{
            Level: slog.LevelDebug,
        },
    )

    // ファイル用(JSON形式、INFO以上)
    logFile, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
        panic(err)
    }
    defer logFile.Close()

    fileHandler := slog.NewJSONHandler(
        logFile,
        &slog.HandlerOptions{
            Level: slog.LevelInfo,
        },
    )

    multiHandler := slog.NewMultiHandler(stdoutHandler, fileHandler)
    logger := slog.New(multiHandler)
    slog.SetDefault(logger)

    slog.Debug("debug (stdout only)")
    slog.Info("info (stdout & file)")
    slog.Warn("warn (stdout & file)")
    slog.Error("error (stdout & file)")
}

この実装により以下のような運用になります。

  • 標準出力にはDEBUG以上のログがテキスト形式で表示される
  • ファイルにはINFO以上のログがJSON形式で記録される
  • 開発時は標準出力を確認、本番環境ではファイルを解析

標準出力とFluentd

ログをFluentdなどのログ収集システムに送信しつつ、標準出力には人間が読みやすい形式で出力するケースです。

Dockerなどのコンテナ環境であればログ転送機構(docker logging driverなど)を用いることでアプリケーションは標準出力に送信するだけで外部ロギング機構に対応することはできますが、その場合すべてのログが転送されてしまうため、細かいコントロールはできなくなります。

実装例

fluent.conf は以下を想定。

<source>
  @type forward
  port 24224
  bind 0.0.0.0
</source>
package main

import (
    "context"
    "log/slog"
    "os"

    "github.com/fluent/fluent-logger-golang/fluent"
)

type FluentdHandler struct {
    logger *fluent.Fluent
    tag    string
    level  slog.Level
}

func NewFluentdHandler(tag string, opts *slog.HandlerOptions) (*FluentdHandler, error) {
    logger, err := fluent.New(fluent.Config{
        FluentHost: "127.0.0.1",
        FluentPort: 24224,
        FluentNetwork: "tcp",
        MarshalAsJSON: true,
    })
    if err != nil {
        return nil, err
    }

    level := slog.LevelInfo
    if opts != nil && opts.Level != nil {
        level = opts.Level.Level()
    }

    return &FluentdHandler{
        logger: logger,
        tag:    tag,
        level:  level,
    }, nil
}

func (h *FluentdHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return level >= h.level
}

func (h *FluentdHandler) Handle(ctx context.Context, r slog.Record) error {
    data := make(map[string]interface{})
    data["level"] = r.Level.String()
    data["msg"] = r.Message
    data["time"] = r.Time

    r.Attrs(func(a slog.Attr) bool {
        data[a.Key] = a.Value.Any()
        return true
    })

    return h.logger.PostWithTime(h.tag, r.Time, data)
}

func (h *FluentdHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return h
}

func (h *FluentdHandler) WithGroup(name string) slog.Handler {
    return h
}

func (h *FluentdHandler) Close() error {
    if h.logger != nil {
        return h.logger.Close()
    }
    return nil
}

func main() {
    // 標準出力用(テキスト形式、DEBUG以上)
    stdoutHandler := slog.NewTextHandler(
        os.Stdout,
        &slog.HandlerOptions{
            Level: slog.LevelDebug,
        },
    )

    // Fluentd用(INFO以上)
    fluentdHandler, err := NewFluentdHandler(
        "app",
        &slog.HandlerOptions{
            Level: slog.LevelInfo,
        },
    )
    if err != nil {
        panic(err)
    }
    defer fluentdHandler.Close()

    multiHandler := slog.NewMultiHandler(stdoutHandler, fluentdHandler)
    logger := slog.New(multiHandler)
    slog.SetDefault(logger)

    slog.Info("app started", "version", "1.0.0")
    slog.Warn("high resource usage", "cpu", 85.5, "memory", 90.2)
    slog.Error("failed to connect database", "error", "connection timeout")
}

この実装により以下が可能になります。

  • 標準出力には開発者が確認しやすい形式でログが表示される
  • Fluentdには構造化されたJSON形式でログが送信され、ログ分析システムで処理可能になる
  • 各ハンドラーで異なるログレベルを設定できるため、標準出力にはすべてのログ、Fluentdには重要なログのみを送信、といった制御が可能になる

環境ごとのログレベル設定

開発環境ではすべてのログを標準出力に、本番環境では重要なログのみをファイルに記録する、といった環境に応じた設定の分岐をするケースです。handlersの内容を変化させるだけなので分かりやすいと感じました。

実装例

func setupLogger(env string) {
    var handlers []slog.Handler

    if env == "development" {
        // 開発環境用 標準出力 DEBUG以上
        handlers = append(handlers, slog.NewTextHandler(
            os.Stdout,
            &slog.HandlerOptions{Level: slog.LevelDebug},
        ))
    }

    if env == "production" {
        // 本番環境 ファイル INFO以上
        logFile, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
        handlers = append(handlers, slog.NewJSONHandler(
            logFile,
            &slog.HandlerOptions{Level: slog.LevelError},
        ))
    }

    multiHandler := slog.NewMultiHandler(handlers...)
    slog.SetDefault(slog.New(multiHandler))
}

まとめ

Go 1.26で追加されるslog.MultiHandlerにより、複数の出力先に対して異なる設定でログを出力できるようになります。これにより、以下のようなメリットが得られます。

  • 出力先ごとに異なるログレベルやフォーマットを設定可能
  • サードパーティのライブラリに依存せずに実装可能
  • 開発時は標準出力で確認し、本番環境ではファイルやログ収集システムに送信する、といった使い分けが容易

適切なユースケースがあれば使っていきたいと考えています。