kakudooo docs

Rails.loggerのJSON Formatterを自作してみる | その②

Rails.loggerを拡張して

Rails.logger.info("メッセージ", { hoge: "fuga" })

のようなインターフェースで、JSON形式で構造化されたテキストの出力を目指す回

Rails.loggerの拡張方法について検討

Loggerクラスを継承したStructuredLoggerクラスを作成する。

config/structured_logger.rb

class StructuredLogger < ::Logger
  include ActiveSupport::LoggerSilence

  class JsonFormatter < ::Logger::Formatter
    def call(severity, timestamp, progname, msg)
      json = {
        severity:,
        timestamp:,
        progname:,
        msg:
      }.to_json

      json
    end
  end

  def info(message = nil, data = {}, &block)
    super({
      message:,
      data:
    }, &block)
  end

  def error(message = nil, data = {}, &block)
    super({
      message:,
      data:
    }, &block)
  end
end

config/development.rb

logger = StructuredLogger.new("development.log")
logger.formatter = StructuredLogger::JsonFormatter.new
config.logger = logger

手始めにinfo,errorメソッドをoverloadして引数にHashを渡せるようにしてみる。

Rails.logger.info("メッセージ", { hoge: "fuga" })

このコードで、Rails.logger.infoを呼び出すと、BroadcastLoggerとの兼ね合いでエラーになってしまう。

{"severity":"ERROR","timestamp":"2025-09-20T07:25:57.110958Z","progname":null,"message":"Could not log \"start_processing.action_controller\" event. ArgumentError: wrong number of arguments (given 2, expected 0..1) ...

なぜかと言うと、Rails.loggerは初期化時にBroadcastLoggerでラップされるようになっているから。

...
if Rails.logger.is_a?(ActiveSupport::BroadcastLogger)
  if config.broadcast_log_level
    Rails.logger.level = ActiveSupport::Logger.const_get(config.broadcast_log_level.to_s.upcase)
  end
else
  Rails.logger.level = ActiveSupport::Logger.const_get(config.log_level.to_s.upcase)
  Rails.logger = ActiveSupport::BroadcastLogger.new(Rails.logger)
end
...

https://github.com/rails/rails/blob/1f3494d6327eb7f9025862bc3362cbed97b668c1/railties/lib/rails/application/bootstrap.rb#L56

rails_semantic_loggerもこの挙動を抑制するためにモンキーパッチにより、BroadcastLoggerの機能を抑制している。

https://github.com/reidmorrison/rails_semantic_logger/blob/c60ff0d7c7c960ad1115dcee2664d217d1d5a4a7/lib/rails_semantic_logger/extensions/active_support/logger.rb#L11

なるべく既存のコードを変更したくはないので、Rails.loggerのインターフェースは維持しつつ、引数の型に応じたログの出力を行うことにした。

実装

info,errorなどのメソッドのインターフェースはそのままで、引数としてHashを受取りFormatterで引数に応じた処理を行うようにする。

config/structured_logger.rb

class StructuredLogger < ::Logger
  include ActiveSupport::LoggerSilence

  class JsonFormatter < ::Logger::Formatter
    def call(severity, timestamp, progname, msg)
      base = {
        severity,
        timestamp,
        progname
      }

      payload = case msg
      when Hash then
        msg
      else
        { message: msg }
      end

      base.merge(payload).to_json
    end
  end
end

出力結果

{"severity":"INFO","timestamp":"2025-09-20T07:37:19.349181Z","progname":null,"hoge":"fuga"}

次回

ActiveSupport::LogSubscriberを拡張し、ActionControllerのログをJSON形式で出力できるようにしていく。