Onlylogs

At Renuo, we are building a new platform/Rails Engine that allows you to analyse your logs via a Web GUI. Because of that, we had the chance of digging deeper into how Rails loggers work.

Here is a summary of our findings:

Rails.logger default

The default Rails logger is an instance of ActiveSupport::BroadcastLogger.

The Broadcast logger is a logger used to write messages to multiple IO. It is commonly used in development to display messages on STDOUT and also write them to a file (development.log). With the Broadcast logger, you can broadcast your logs to a unlimited number of sinks.

This is initialized by bootstrap.rb in an initializer :initialize_logger block.

The BroadcastLogger is just a wrapper around the “real” logger. By default, this is an instance of ActiveSupport::Logger.

ActiveSupport::Logger extends the default ruby Logger class with some differences:

  • it offers a silence: which allows to silence log messages up to a certain level in a block:
Rails.logger.silence(Logger::INFO) do
  # sets the minimum level to INFO for this block
  Rails.logger.debug { "hello" } # not printed
end
  • it configures a default SimpleFormatter that displays the message as it is.

So in development, by default, we have a AS::BroadcastLogger that wraps a AS::Logger, which extends Logger.

It also comes with a default maximum file size of 100 * 1024 * 1024 (100MB) and a single retention file.

But this is not all, by default, AS::TaggedLogging features and methods are also attached to the logger. This module extends the logger with a new method .tagged that allows to prepend the message with tags.

Rails.logger.tagged("tag1", "tag2").debug { "hello" }
#=> [tag1] [tag2] hello
flowchart TD
    BL[ActiveSupport::BroadcastLogger]
    TL[ActiveSupport::TaggedLogging]
    BL -->|Wraps| ASL[ActiveSupport::Logger]
    ASL -->|Writes to| LOGFILE["path: development.log\nSize: 100MB\nRetention: 1 file"]
    ASL -->|Uses| SF[SimpleFormatter]
    ASL -->|Extended with| TL
    TL -->|Adds| TaggedMethod[.tagged method]

Configuration options

Rails offers different configuration options on every level, and here stuff gets complicated. Let’s see them:

config.log_level: changes the minimum log level printed on screen

config.default_log_file: change the file path from log/development.log to something else

config.log_file_size: you can change the default maximum file size

config.log_formatter: changes the formatter from SimpleFormatter to whatever you want

config.log_tags: adds tags to your logged line

Let’s see some examples and some counter-intuitive cases:

Rails.logger.info "my message"
#=> "my message"

if we configure log tags and repeat the example:

config.log_tags = [
  :request_id, # a unique request id
  ->(req) { Time.now.utc.iso8601 } # the current timestamp
]

Rails.logger.info "my message"
#=> "my message"

log tags were not used. But, if you perform a controller request, they are going to be there:

[bca3c681-4d82-4690-ad60-37ce36158256] [2025-08-25T11:17:05Z] Started GET "/" for 127.0.0.1 at 2025-08-25 13:17:05 +0200

this is because when we invoke Rails.logger.info directly the tags have not been “pushed” into the logger. What we need to do is:

Rails.logger.push_tags("mytag1", Time.now.utc.iso8601)
Rails.logger.info "my message"
#=> [mytag1] [2025-08-25T11:23:38Z] my message
Rails.logger.pop_tags(2)

Rails::Rack::Logger is the missing piece of the puzzle here. It basically takes care of pushing and popping tags into the logger around your request. This is also why a direct call to Rails.logger.info would have no tags when done via console, but will have its tags correctly attached when done in a controller:


def index
  Rails.logger.info("here is a test")
end

# [bb6cae30-6031-4b3e-8384-8352e0cab32e] [2025-08-25T11:27:08Z] Started GET "/" for 127.0.0.1 at 2025-08-25 13:27:08 +0200
# [bb6cae30-6031-4b3e-8384-8352e0cab32e] [2025-08-25T11:27:08Z] here is a test

Custom formatter

Let’s configure a different formatter:

class OnlylogsFormatter < ActiveSupport::Logger::SimpleFormatter
  def call(severity, time, progname, msg)
    "onlylogs --> #{super} <--\n"
  end
end

config.log_formatter = OnlylogsFormatter.new
config.log_tags = [:request_id]

and invoke this in a controller action:

Rails.logger.info("here is a test")

Let’s make a game. What will the output be between these two?

[4113f0bd-222a-4917-8dbb-4665ed4df378] onlylogs --> here is a test <--
onlylogs --> [4113f0bd-222a-4917-8dbb-4665ed4df378] here is a test <--
Reveal answer

The second one. The tags are, in fact, applied to the message before is passed to the formatter.

Custom logger

Let’s now configure a different logger:

class OnlylogsLogger < ActiveSupport::Logger
  def initialize(*args)
    super
    self.formatter = OnlylogsFormatter.new
  end
end

config.logger = OnlylogsLogger.new
config.log_tags = [:request_id]

What will the output be now?

[4113f0bd-222a-4917-8dbb-4665ed4df378] onlylogs --> here is a test <--
onlylogs --> [4113f0bd-222a-4917-8dbb-4665ed4df378] here is a test <--
onlylogs --> here is a test <--
Reveal answer

The last one. The option log_tags is completely ignored when configuring a custom logger.

This is true also for the other options: they only apply when a custom logger is not defined.

Stop reading if you haven’t answered first :)

Below the relevant code:

# bootstrap.rb
Rails.logger ||= config.logger || begin
  logger = if config.log_file_size
    ActiveSupport::Logger.new(config.default_log_file, 1, config.log_file_size)
  else
    ActiveSupport::Logger.new(config.default_log_file)
  end
  logger.formatter = config.log_formatter
  logger = ActiveSupport::TaggedLogging.new(logger)
  logger
end

In order to get the log_tags working we need to support tagged logging. For example with:

config.logger = ActiveSupport::TaggedLogging.new(OnlylogsLogger.new)

What happens when you install lograge?

What is important to understand here, is that lograge does change the logger or the formatter, but changes how logs are written, because it needs to basically collect multiple log messages and merge them into one. Still, it uses your defined configuration


class OnlylogsFormatter < ActiveSupport::Logger::SimpleFormatter
  def call(severity, time, progname, msg)
    "onlylogs --> #{super} <--\n"
  end
end

config.logger.formatter = OnlylogsFormatter.new
config.lograge.enabled = true

#=> onlylogs --> [f5326485-4c2b-4e03-8e12-273d714faf39] [2025-08-25T12:11:25Z] method=GET path=/ format=html controller=Devise::SessionsController action=new status=200 allocations=876879 duration=501.01 view=287.27 db=15.03 <--

How does this apply to onlylogs?

Onlylogs is designed to digest the logs no matter what. It treats your logs for what they are: text files, and allows you to search through them as you would normally do in a text file. Onlylogs is still work in progress at the current time. I’ll write more about it when we have something more concrete to show.