Will Jessop's Writings

Sailing, Food, Programming, Technology, and other things

Do you have a Ruby on Rails application you'd like to be faster, more scalable, or just upgraded safely? I'm currently open to new contracts doing Ruby on Rails and Postgres scaling and performance work, and Rails upgrades. Contact me at will@willj.net to get started.
| tags:programming ruby rails ruby on rails performance tech

You should use the Ruby on Rails logger block syntax

Summary

Passing strings to the Rails logger methods (eg. Rails.logger.info(…)) causes unecessary object allocations, and if you’re calling methods to generate data for your log messages then it can cause unecessary CPU work too. In this post I’ll show you how to use block syntax when using your logger with Rails to save object allocations and CPU time.

The Context

When you’re logging in Ruby on Rails log messages sent with a level above the currently configured log level will not be logged. The default in production is to log at info or below, so anything logged at debug level will not be output to the logs:

debug  ⬆️ Ignored
-----------------
info   ⬇️  Logged
warn
error
fatal
unknown

The log level can be set in the environment configs, from config/environments/production.rb:

config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

The log level can also be changed at any time, including in the Rails console, by setting the log level directly:

> Rails.logger.level = :warn
=> :warn

The problem - Object allocations for unused logs

Let’s say you want to log some debug data for an API call that a user is making. You might do something like this:

Rails.logger.debug "Processed API request to host #{somehost} for user \
  #{user.name} (#{user.id}): status: #{request_status} user_details: #{user.to_json}"

You push this code to production, it’s useful to have a way to debug user API issues just by setting the Rails log level to debug from info using the RAILS_LOG_LEVEL environment variable, you can always set it back to info when you’re done. Let’s look at what this statement allocates when the application is logging at the debug level:

# Set the log level
> Rails.logger.level = :debug
=> :debug

# Do the logging
> stats = AllocationStats.trace {
	Rails.logger.debug "Processed API request to \
      host #{somehost} for user #{user.name} (#{user.id}): status: #{request_status} \
      user_details: #{user.to_json}"
}
Processed API request to host for user Slartibartfast (42): status: DENIED user_details: {"id":42,"name":"Slartibartfast","email":"s.bartfast@magrathea.biz","favourite_colour":"blue"}
=> [#<AllocationStats::Allocation:0x000000015489fe18 @object=#<Proc:0x000000015471e9e0 activesupport-7.1.3.4/lib/active_support/broadcas...

# Get an allocation count, source paths shortened for brevity
> puts stats.allocations.group_by(:sourcefile, :sourceline, :class).to_text
                           sourcefile                             sourceline                      class                      count
----------------------------------------------------------------- ----------  ---------------------------------------------  -----
activesupport-7.1.3.4/lib/active_support/broadcast_logger.rb             231  Proc                                               1
(irb)                                                                     45  Array                                              1
ruby/3.3.0/json/common.rb                                                303  JSON::Ext::Generator::State                        1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb         188  String                                             4
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                 23  ActiveSupport::JSON::Encoding::JSONGemEncoder      1
(irb)                                                                     13  String                                             1
(irb)                                                                     45  String                                             2
<internal:timev>                                                         226  Time                                               2
ruby/3.3.0/json/common.rb                                                305  String                                             1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                 92  Hash                                               1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                 77  Hash                                               1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb         186  Hash                                               1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb          78  Hash                                               1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                 33  Hash                                               1
ruby/3.3.0/logger/log_device.rb                                           42  String                                             1
activesupport-7.1.3.4/lib/active_support/logger.rb                        38  String                                             2
ruby/3.3.0/logger/log_device.rb                                          128  File::Stat                                         1

This is fine! We’re logging some data, the Ruby VM is doing work and allocates objects. Let’s look at what happens when we change the log level to info when we’re done debugging:

# Set the log level
> Rails.logger.level = :info
=> :info

# Do the logging
> stats = AllocationStats.trace {
	Rails.logger.debug "Processed API request to \
      host #{somehost} for user #{user.name} (#{user.id}): status: #{request_status} \
      user_details: #{user.to_json}"
}
=> [#<AllocationStats::Allocation:0x000000015489fd28 @object=#<Proc:0x000000015471e648 activesupport-7.1.3.4/lib/active_support/broadcas...

# Get an allocation count, source paths shortened for brevity
> puts stats.allocations.group_by(:sourcefile, :sourceline, :class).to_text
                           sourcefile                              sourceline                      class                      count
-----------------------------------------------------------------  ----------  ---------------------------------------------  -----
activesupport-7.1.3.4/lib/active_support/broadcast_logger.rb              231  Proc                                               1
(irb)                                                                      48  Array                                              1
/Users/will/.rbenv/versions/3.3.0/lib/ruby/3.3.0/json/common.rb           303  JSON::Ext::Generator::State                        1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb          188  String                                             4
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                  23  ActiveSupport::JSON::Encoding::JSONGemEncoder      1
(irb)                                                                      13  String                                             1
(irb)                                                                      48  String                                             2
/Users/will/.rbenv/versions/3.3.0/lib/ruby/3.3.0/json/common.rb           305  String                                             1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                  92  Hash                                               1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                  77  Hash                                               1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb          186  Hash                                               1
activesupport-7.1.3.4/lib/active_support/core_ext/object/json.rb           78  Hash                                               1
activesupport-7.1.3.4/lib/active_support/json/encoding.rb                  33  Hash                                               1

This code didn’t log anything beacuse the log level is set to info and we called the debug log level method, however we still allocated almost as many objects!.

Why this happens

When we call Rails.logger.debug with a string, say "Hello #{name}, here's some #{generated_data}!", the string is created as an object, including any interpolation that is required which may include methods that are called to retrieve or generate the data which also allocates objects. This happens before the string is passed to the method regardless of the current log level, it is up to the logger when passed this now created string to decide if it is going to log it or not. That’s wasteful!

The solution, passing a block to the logger

Let’s run that debug code again but this time pass a block to the logger:

# Set the log level
> Rails.logger.level = :info
=> :info

# Do the logging
> stats = AllocationStats.trace {
	Rails.logger.debug { "Processed API request to \
      host #{somehost} for user #{user.name} (#{user.id}): status: #{request_status} \
      user_details: #{user.to_json}" }
}
=> [#<AllocationStats::Allocation:0x000000015489ffa8 @object=#<Proc:0x000000015471ea58 /Users/will/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/gems/activesupport-7.1.3.4/lib/active_support/broadcas...

# Get an allocation count, source paths shortened for brevity
> puts stats.allocations.group_by(:sourcefile, :sourceline, :class).to_text
                          sourcefile                          sourceline  class  count
------------------------------------------------------------  ----------  -----  -----
activesupport-7.1.3.4/lib/active_support/broadcast_logger.rb         231  Proc       2
(irb)                                                                 51  Array      1

That’s a lot better, we’re still not logging anything but the allocations are now significantly reduced. Ideally we’d allocate zero objects when logging nothing, but allocating three is a lot better than what we were allocationg before.

This works because when you pass a block to the logger method (debug in this case) the block will only be called if the log level is currently being logged. We had set the log level to info which meant that the block passed to the debug method was never called, and the object allocations to generate the string never happened.

Why should I care?

The examples given here are admittedly trivial (and a little contrived to show the difference), and for an application that doesn’t log much or doesn’t get much traffic then the benefit is going to be tiny, but the effort involved in making the change to block syntax is really small, it’s not detrimental to the code readability either. Small applications can eventually grow into large ones, a handful of requests per second can change to thousands, and if that happens the block syntax can make a difference and you’ll likely be glad you made it a habit.