19
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsログ改善のその先へ ── 独自の構造化ログを実装した話

19
Last updated at Posted at 2025-12-01

はじめに

Hubbleでバックエンドエンジニアをしている @alstrocrack です。
Hubble Advent Calendar 2025の2日目の記事です。

株式会社Hubbleは、契約書管理クラウド HubbleというSaaSを開発しており、HubbleはRuby on Railsで開発しています。Hubbleは今年、Kaigi on Rails 2025でRuby Sponsorをしていました。そこで私、漆島とCTOの藤井(Sponsor LT)とTech Leadの中島(全問正解率約3%: RubyKaigiで出題したやりがちな危険コード5選)の3人が登壇していたので、Hubbleを知っている方もいらっしゃるのではないでしょうか。

それはさておき、HubbleはRuby on Railsで開発していると言いました。Rails 8系ではログの構造化がしやすくなるようです(詳しくはおググりあれ!)が、今まではログを構造化するのに少し労力がかかりました。2年前くらいまではHubbleのログは特に構造化などはされていませんでしたが、Rails.logger.levelをHashの引数と一緒に呼ぶだけで構造化ログが出力されると嬉しいなと思っていました。

# こんな感じでログが吐き出されると嬉しい
Rails.logger.info({ user_id: 1, org_id: 100 })
# => "{\"user_id\":1,\"org_id\":100}"

実際に試行錯誤しながら(ほぼ)理想の構造化ログを導入してみたので、今回はその実装の内部を紹介してみようと思います。

なぜログを「構造化」すると嬉しいのか?

今回はRuby on Railsアプリから吐き出すログに焦点を当てますが、どんなログであっても構造化することには大きな意義があると考えています。なぜなら、Datadogといった監視ツールとの親和性が高いからです。親和性が高いということはどういうことなのでしょうか?

HubbleではDatadogを使っているので、実際の画面を見ながら説明して行きます。

show.png

ポイントは赤枠で囲ってある「Fields & Attributes」部分です。この画像のように構造化ログをDatadogへ送信すると、良い感じにattributesとして表示してくれます。

また、DataDogの場合(他の監視ツールでも概ね同様かと思います)は、このAttributesをキーにしてログを「フィルタリング」することができます。

index.png

赤枠で囲ってある箇所がログを探すためのプロンプトですが、@action:pdfと入力することにより、Railsのactionでログを上手く「フィルタリング」できています。

つまり、このAttributesでuser_id、controller、actionや操作対象オブジェクトのidなどを用いて「いつ、誰が、何に対して、どんな操作をした」といったログの捜索をより正確な表現で絞り込むことが可能であることを意味します。

他の監視ツールなどでも同様の操作ができるはずです。私の前職ではGCPのCloud Loggingで同様に構造化ログ使用していました。(だったはず、、、、、)

え?それだけですか?

はい、それだけです。 ですが、このありがたみを知るために、同様の条件を構造化されていないログから検索してみると仮定してみてください。もしくは今、自身が所属されている開発チームのログが構造化されていない場合は日々の開発を思い起こしてください。

この場合、プロンプトにどのように入力しますか? 仮にuser_id = 100であると仮定しましょう。100という数字を素直に打ち込めば、use_id = 100に関するログだけが素早く正確に出力されるでしょうか?恐らくそうではないはずです。user_id 100とちょっと工夫して検索したところで、user_id = 1001に関する情報などが出力されたりします。

本番に障害が起きた際にuser_id = 100のユーザーの操作を追いかけたい場合に、ほとんどがノイズだらけのログの中から関連するログのみを早く正確に取得することが本当にできますか?一刻も早く問題を収束させたい焦りと不正確でノイズだらけのログの中で?

確かにログを出力する際の規約を決めるというのは一つの手っ取り早い解決策です。 ただ、果たして開発者は必ずuser_id = 100と記述してくれますか?user_id=100と記述するのはNGですか?

「検索」ではなく「フィルタリング」を

個人的な思想ですが 「検索」 を信用してはなりません。「検索」をするためには、要素をindexしたりなどの労力がかかる割にはやや信憑性を欠きます。 Google検索はあなたの思い通りの検索結果を美しく正確に全て出力してくれますか?(もちろん、Googleは偉大なプロダクトですよ)顧客から「どんな操作が何回実行された結果でこうなりました?」という質問に対して、検索しただけのログで果たして自信を持って答えられますか?

賢い我々は 「フィルタリング」 をしましょう。あらかじめ決まったフォーマットで監視ツールに送ることをコミットする代わりに、フィルタリングによって、より正確に全ての目当てのログを探し出すのです。

それに、ゴチャゴチャと雑多なログが出てくるよりも、jsonフォーマットで出力される方が美しい、要は美意識です。

じゃあ、Lograge使うか

構造化ログ = Logrageという図式をRuby on Rails界隈では感じますが(私だけですか?)、Logrageでは構造化対象になるのはリクエストログのみです。

ですが、我々はリクエストログ以外にも重要な処理の内部状態がどうなっていたのかをログで吐き出したいという欲求も持っています。しかも正確に絞り込みたい、それもできるだけラクして。

じゃあ、こんな感じにしてみるとどうでしょうか?

  • リクエストログ
    • Lograge
  • それ以外のログ
    • 別の仕組みで何とかする?

ただこれは少し冗長な感じがします。「それ以外のログ」が何とかできるのであれば、リクエストログも同じ仕組みを使えばいいだけです。できるだけ、色んなログが一括で構造化されるようになると嬉しいです。我々はKISSの原則を忘れてはならず、DRYにできるところはDRYにしましょう。

loggerに求める要件

ここで一度、ログに対する要望を整理してみます。

  • リクエストログには何も意識しなくても必要なメタデータを搭載しておきたい
  • traceも意識したくない(traceについての参考: https://newrelic.com/jp/blog/how-to-relic/metrics-events-logs-and-traces)
  • Rails.logger.level({ user_id: 100 }.to_json)は冗長なので、Rails.logger.level({ user_id: 100 })とかでいい感じに構造化ログが出てきて欲しい
  • 例外をrescueした際にRails.logger.error(e)とアッサリ記述されただけの箇所が多いのでだけで、そういう箇所ではbacktraceもいい感じにログ出力しておきたい
  • ただ別に無理に構造化しなくていい時もあるので、loggerの引数をただの文字列だけで渡す怠惰な瞬間も残しておいてほしい(人間が読むための従来のようなログも搭載したい)

色々と調査した結果ではこれらの要望を叶えるためには、自前で実装をするしかないという結論に至りました。 そんな技術記事がないかなとインターネット上をガサガサ探すと良い感じの記事が出てきました。

探し出した解決策

インターネットをガサガサした結果、探し出した答えがこちらです。

詳しくは上記の資料を確認してください。

やりたいことは 「Ruby on Railsのオリジナルのloggerを少し改造して、思った通りの構造化ログが何も意識しなくても出力されるようにしたい」 です。

そのヒントとして、上記の資料中では 「Logrageの基本戦略」 として以下のような記載があります。

  • Railsが出力するRequest関連のログを無効にする
  • 自前のLogSubscriberをattachしてログ出力

この「Logrageの基本戦略」と同様のことをすれば、Logrageと同じように「ログを中継して思った通りのフォーマッターで出力」することができそうです。

このスライドには有り難いことに、実装の例も示していただいています。これらの情報を元に実装してみましょう。

実際のlogger実装の中身

では、具体的にlogger改造のコードを見ていきましょう。

基底コントローラーとして定義したApplicationControllerappend_info_to_payloadメソッドを用いることによって、リクエストログの出力時に良い感じにpayloadを上乗せしましょう。

append_info_to_payloadは、リクエストに情報を載せるために使用されるメソッドですね。

ちなみに、before_action@cur_item@cur_userなどに値を入れる処理を書くことがよくあるのですが、そういった値があれば勝手にログに含めるようにします。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
    # ...
    def append_info_to_payload(payload)
        super

        # リクエストログに含めたいメタデータ系をセット
        payload[:referer] = request.referer
        payload[:params] = request.params

        # 誰が何に対してといった情報が取得できれば自動でログに載せる
        cur_user = @cur_user || @current_user || nil
        if cur_user.present? && cur_user.is_a?(User)
            payload[:cur_user_id] = cur_user.id
        end
        
        return unless @cur_item.present? && @cur_item.is_a?(Item)
        payload[:cur_item_id] = @cur_item.id
        # ...
    end
    # ...
end

また、ログをどのように出力するのかを定義するフォーマッターを実装します。Rails.logger.level(msg)msgが、どのクラスのインスタンスかによって処理を変えています。

lib/json_log_formatter.rb
require "ddtrace"

class JsonLogFormatter < Logger::Formatter
  def call(severity, time, _prog_name, msg)
    correlation = Datadog::Tracing.correlation

    # Datadogでtraceさせるためにtrace_idを入れておく
    # 他の監視ツールでも同様のtraceの仕組みがあるはずなので組み込む
    log = {
      trace_id: correlation.trace_id,
      span_id: correlation.span_id,
      time: time.iso8601(6),
      level: severity
    }.compact

    # loggerの引数によって、msgの扱いを分岐させる
    # 
    # String -> jsonログのmessageプロパティに引き渡す
    # Hash -> デフォルトで設定している項目に追加してjson化する
    # Exception -> messageとtraceをいい感じにセットする
    # それ以外 -> String化してmessageプロパティに引き渡す
    if msg.instance_of?(String)
      log[:message] = msg
    elsif msg.instance_of?(Hash)
      log.merge!(msg)
    elsif msg.instance_of?(Exception)
      log[:message] = msg.message
      log[:backtrace] = msg.backtrace
    else
      log[:message] = msg.to_s
    end

    # ログをjson化する
    "#{JSON.generate(log, ascii_only: false)}\n"
  end
end

そのフォーマッターを使うように設定してみましょう。同時にRuby on Railsアプリ内のloggerの基本的な設定も同時にここで行います。

config/environments/application.rb
require "#{Dir.pwd}/lib/json_log_formatter"
# ...
# logの設定
config.logger = ActiveSupport::Logger.new(STDOUT)
config.logger.formatter = JsonLogFormatter.new
config.logger.level = 0
config.log_level = :info
# ...

次にLogSubscriberです。

ここではApplicationController#append_info_to_payloadで引き受けたプロパティを再度セットします。(もうちょっと上手いやり方がありそうな気がするので、知っていれば教えてください)

また、リクエストログに関しては、これまではどんなステータスであろうと、ずっとinfoレベルでログが出ていました。しかし、リクエストログのレベルをここで決めちゃって、今後はより正確なログレベルで出力されるようにしましょう。

lib/hubble_log_subscriber.rb
# frozen_string_literal: true

class HubbleLogSubscriber < ActiveSupport::LogSubscriber
  def process_action(event)
    # ApplicationController#append_info_to_payloadの内容をここで拾う
    log_message = {
      method: event.payload[:method],
      db: event.payload.key?(:db_runtime) ? event.payload[:db_runtime].to_f.round(2) : nil,
      # ...
      user_id: event.payload[:cur_user_id],
      org_id: event.payload[:cur_org_id],
      item_id: event.payload[:cur_item_id],
    }.compact

    # リクエストログに関してはここでlevelを決めてしまう
    level = case event.payload[:status].to_i
            when 500..599
              Logger::ERROR
            when 400..499
              Logger::WARN
            when 100..399
              Logger::INFO
            else
              logger.level
            end

    logger.add(level, log_message)
  end
end

HubbleLogSubscriber.attach_to :action_controller

次に独自loggerを定義します。これは「logrageの次を考える」の資料でも出てきたことを、そのまま記述しています。

lib/hubble_logger.rb
class HubbleLogger
  def initialize(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  ensure
    ActiveSupport::LogSubscriber.flush_all!
  end
end

controllerのloggerを先ほどの独自loggerに挿げ替えてあげましょう。

config/initializers/log_settings.rb
require "#{Dir.pwd}/lib/hubble_logger"
require "#{Dir.pwd}/lib/hubble_log_subscriber"
require "#{Dir.pwd}/lib/json_log_formatter"
require "action_controller/log_subscriber"

ActionController::LogSubscriber.detach_from :action_controller
Rails.application.middleware.swap(Rails::Rack::Logger, HubbleLogger)

これで実装は完了です!

ドキュメントの記載

最後にloggerの使い方をどこかに書き記しておきましょう。せっかく、良い感じにloggerを改造したのに、使われないのでは悲しいので。

私は以下のようにNotionに記載をしました。これはHubbleにjoinしていただいたBackendエンジニアの方々に展開する資料の一部になっています。

screencapture-notion-so-hubbleteam-hubble-api-logger-32d1e54d3a4e45ab9809e110c2008bcd-2025-11-23-18_17_33.png

まとめ

現代のシステム開発においてはRailsアプリケーションに限らず、各所から出てくるログは構造化しておくとより良いと考えています。人間が読むためのログもその中に入れられますし、まさに機械と人間にとってwin-winと言えるかと思います。

ただ、Rails 8系ではデフォルトでログを構造化できると聞いたので、この記事はすぐに不要になるかもしれませんが、知見を共有すること自体は悪いことではないと思うので悪しからず。詳しくはこちらから。

明日は @krabbe16 さんです!

19
0
0

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
19
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?