Back to Logging guides

Logging in Ruby: A Comparison of the Top 6 Libraries

Ayooluwa Isaiah
Updated on November 23, 2023

Although Ruby provides a Logger class in its standard library, it is missing several features that are necessary for a robust logging setup in production applications. Due to its limitations, a number of alternatives have sprung up in the community to offer more comprehensive logging capabilities for Ruby applications.

These alternatives provide an enhanced logging experience that aligns with the rigors of modern software development and the complexities of production environments. This article will discuss and compare a few of the logging solutions in Ruby, shedding light on their unique features, advantages, and use cases.

We'll begin with the standard Logger class, and then evaluate five of the best third-party options. Let's get started!

1. The Logger class

Screenshot from 2023-08-30 22-03-30.png

Ruby ships with a Logger class in its standard library, which provides a simple API to record event logs without using a third-party framework. It supports the following log levels:

 
require 'logger'

logger = Logger.new($stdout)

logger.debug('database query executed')
logger.info('user signed in')
logger.warn('disk space is 95% full')
logger.error('encountered an unexpected error while backing up database')
logger.fatal('application crashed')
Output
D, [2023-08-30T12:06:15.252379 #505263] DEBUG -- : database query executed
I, [2023-08-30T12:06:15.252419 #505263]  INFO -- : user signed in
W, [2023-08-30T12:06:15.252426 #505263]  WARN -- : disk space is 95% full
E, [2023-08-30T12:06:15.252430 #505263] ERROR -- : encountered an unexpected error while backing up database
F, [2023-08-30T12:06:15.252434 #505263] FATAL -- : application crashed

The default log level here is DEBUG, but it can be easily changed through the level method on a logger instance:

 
logger.level = Logger::ERROR
Output
E, [2023-08-30T15:55:38.870752 #632021] ERROR -- : encountered an unexpected error while backing up database
F, [2023-08-30T15:55:38.870783 #632021] FATAL -- : application crashed

Notably, the Logger class does not support the TRACE level. If you need it or other custom levels, you can create a custom logger class that inherits from Logger and add one or more custom levels as shown in our logging in Ruby article.

Formatting your log entries is done through the logger.formatter property. It accepts a proc object with the following four parameters: severity, datetime, progname, and msg. You can use these four parameters to construct your custom format and return a string representing the formatted entry. Note that you can also add other properties that you want to see in all entries, like the process ID example included below:

 
logger.formatter = proc do |severity, datetime, progname, msg|
  datefmt = datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N')
  "time=#{datefmt} level=#{severity.ljust(5)} pid=#{Process.pid} msg='#{msg}'\n"
end
Output
time=2023-08-30T12:14:01.133053 level=INFO pid=519895 msg='user signed in'

Logging in JSON is not directly supported, but you can implement a custom formatter using the json module as follows:

 
logger.formatter = proc do |severity, datetime, progname, msg|
  datefmt = datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N')
  {
    timestamp: datefmt,
    level: severity.ljust(5),
    pid: Process.pid,
    msg: msg
  }.to_json + "\n"
end
 
{"timestamp":"2023-08-30T12:15:24.673590","level":"INFO ","pid":521057,"msg":"user signed in"}

Logging to a file can be achieved by passing a filename to the new() method:

 
logger = Logger.new('app.log')

It also supports basic log rotation through the second and third arguments, which lets you configure how many files should be retained and the maximum size for individual files.

 
# retain a maximum of five 10-megabyte files
Logger.new('app.log', 5, 10 * 1024 * 1024)

The Logger class is a simple way to start logging in Ruby, but it's a little light on features. If you need more than what it offers, please see the other options on this list.

2. Semantic Logger

semantic_logger.png

Semantic logger is a framework that aims to replace existing Ruby and Rails loggers. It can produce both human and machine-readable logs, and it supports a wide variety of destinations through its built-in appenders. It also claims to be capable of producing thousands of logs per second without slowing down the application by using an in-memory queue in a separate thread.

Getting started with Semantic Logger is straightforward. Once you install the library, you can import it into your project and create an instance of the logger by supplying the name of the class/application. You also need to specify a destination for the logs using the add_appender() method:

 
require 'semantic_logger'
SemanticLogger.add_appender(io: $stdout)
logger = SemanticLogger['MyApp']

logger.trace('entered main function')
logger.debug('database query executed')
logger.info('server started on port 8080')
logger.warn('disk space is 95% full')
logger.error('encountered an unexpected error while backing up database')
logger.fatal('application crashed')
Output
2023-08-30 10:52:06.113367 I [291531:60] MyApp -- server started on port 8080
2023-08-30 12:02:37.277804 W [291531:60] MyApp -- disk space is 95% full
2023-08-30 10:52:06.113383 E [291531:60 main.rb:10] MyApp -- encountered an unexpected error while backing up database
2023-08-30 10:52:06.113398 F [291531:60 main.rb:11] MyApp -- application crashed

Semantic logger defaults to the INFO level, but you can customize this through the default_level property as follows:

 
require 'semantic_logger'
SemanticLogger.default_level = :trace
SemanticLogger.add_appender(io: $stdout) . . .

You should observe the TRACE and DEBUG messages in the program's output afterward:

Output
2023-08-30 10:55:45.703627 T [303347:60] MyApp -- entered main function
2023-08-30 10:55:45.703640 D [303347:60] MyApp -- database query executed
2023-08-30 10:55:45.703645 I [303347:60] MyApp -- server started on port 8080
2023-08-30 12:02:37.277804 W [303347:60] MyApp -- disk space is 95% full
2023-08-30 10:55:45.703648 E [303347:60 main.rb:11] MyApp -- encountered an unexpected error while backing up database
2023-08-30 10:55:45.703658 F [303347:60 main.rb:12] MyApp -- application crashed

Formatting the logs is done through the formatter option on the add_appender() method. For example, here's how to colorize the records to make them more easily distinguishable in development environments:

 
SemanticLogger.add_appender(io: $stdout, formatter: :color)

Screenshot from 2023-08-30 10-58-54.png

In production, you likely want to output your logs as structured JSON so that they can be easily parsed and monitored by log management tools. Semantic Logger provides the :json formatter for this purpose:

 
SemanticLogger.add_appender(io: $stdout, formatter: :json)
Output
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087391Z","level":"trace","level_index":0,"pid":336086,"thread":"60","name":"MyApp","message":"entered main function"}
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087409Z","level":"debug","level_index":1,"pid":336086,"thread":"60","name":"MyApp","message":"database query executed"}
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087414Z","level":"info","level_index":2,"pid":336086,"thread":"60","name":"MyApp","message":"server started on port 8080"}
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087415Z","level":"warn","level_index":3,"pid":336086,"thread":"60","name":"MyApp","message":"disk space is 95% full"}
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087416Z","level":"error","level_index":4,"pid":336086,"thread":"60","file":"main.rb","line":11,"name":"MyApp","message":"encountered an unexpected error while backing up database"}
{"host":"fedora","application":"Semantic Logger","timestamp":"2023-08-30T09:05:11.087428Z","level":"fatal","level_index":5,"pid":336086,"thread":"60","file":"main.rb","line":12,"name":"MyApp","message":"application crashed"}

You can log to multiple destinations simultaneously using the same or different formats by using multiple appenders like this:

 
SemanticLogger.add_appender(io: $stdout, formatter: :color)
SemanticLogger.add_appender(file_name: 'app.log', formatter: :json)

This configuration produces a coloured output to the console, and a JSON-formatted output to a file called app.log so that you get the best of both worlds.

Semantic Logger also allows you to add arbitrary contextual values to a log entry as follows:

 
logger.info('user signed in', user_id: 123_456, provider: 'facebook')
Output
2023-08-30 11:22:31.164988 I [394737:60] MyApp -- user signed in -- { :user_id => 123456, :provider => "facebook" }

You can also add one or more properties to multiple log entries through the tagged block as follows:

 
SemanticLogger.tagged(username: 'John', user_id: 123_456) do
  # All log entries in this block will include the above named tags
  logger.debug('user signed in')
  logger.debug('user opened document', doc_id: 'xyz')
  logger.debug('user signed out')
end
Output
2023-08-30 11:30:07.355499 D [414838:60] {username: John, user_id: 12345} MyApp -- user signed in
2023-08-30 11:30:07.355525 D [414838:60] {username: John, user_id: 12345} MyApp -- user opened document -- { :doc_id => "xyz" }
2023-08-30 11:30:07.355535 D [414838:60] {username: John, user_id: 12345} MyApp -- user signed out

Another useful feature is its ability to measure various operations in the program through the level method prefixed with measure:

 
logger.measure_debug 'request a random quote' do
  response = HTTP.get('https://api.quotable.io/quotes/random?tags=history%7Ccivil-rights')
  p response.parse
end

The duration of the operation will be present in the logs:

 
2023-08-30 11:50:04.462807 D [469071:540] (1.930s) MyApp -- request a random quote

There's a lot more that Semantic Logger offers, so do check out its documentation to learn more.

3. Ougai

ougai.png

Ougai is a logging framework that focuses mainly on outputting structured data, though it also supports human-readable and colorized logs. It was made to extend the standard Logger class in Ruby by adding a few quality-of-life improvements. For example, it supports the TRACE level by default and formats its output as JSON:

 
require 'ougai'

logger = Ougai::Logger.new($stdout)
logger.level = Ougai::Logger::TRACE

logger.trace('entered main function')
logger.debug('database query executed')
logger.info('user signed in')
logger.warn('disk space is 95% full')
logger.error('encountered an unexpected error while backing up database')
logger.fatal('application crashed')
Output
{"name":"main","hostname":"fedora","pid":684730,"level":10,"time":"2023-08-30T16:25:53.470+02:00","v":0,"msg":"entered main function"}
{"name":"main","hostname":"fedora","pid":684730,"level":20,"time":"2023-08-30T16:25:53.471+02:00","v":0,"msg":"database query executed"}
{"name":"main","hostname":"fedora","pid":684730,"level":30,"time":"2023-08-30T16:25:53.471+02:00","v":0,"msg":"user signed in"}
{"name":"main","hostname":"fedora","pid":684730,"level":40,"time":"2023-08-30T16:25:53.471+02:00","v":0,"msg":"disk space is 95% full"}
{"name":"main","hostname":"fedora","pid":684730,"level":50,"time":"2023-08-30T16:25:53.471+02:00","v":0,"msg":"encountered an unexpected error while backing up database"}
{"name":"main","hostname":"fedora","pid":684730,"level":60,"time":"2023-08-30T16:25:53.471+02:00","v":0,"msg":"application crashed"}

You can add contextual properties to individual entries as follows:

 
logger.info('user signed in', user_id: 123_456, username: 'johndoe')
Output
{"name":"main","hostname":"fedora","pid":688088,"level":30,"time":"2023-08-30T16:27:38.283+02:00","v":0,"msg":"user signed in","user_id":123456,"username":"johndoe"}

You can also add properties to all logs through the with_fields property on a logger:

 
logger.with_fields = { app_version: 'v1.2.3' }
Output
{"name":"main","hostname":"fedora","pid":694621,"level":30,"time":"2023-08-30T16:30:55.699+02:00","v":0,"msg":"user signed in","user_id":123456,"username":"johndoe","app_version":"v1.2.3"}
{"name":"main","hostname":"fedora","pid":694621,"level":40,"time":"2023-08-30T16:30:55.699+02:00","v":0,"msg":"disk space is 95% full","app_version":"v1.2.3"}

Another helpful feature is its ability to add contextual attributes to a set of events to avoid repetition at log point:

 
require 'ougai'

logger = Ougai::Logger.new($stdout)
logger.level = Ougai::Logger::TRACE

logger.with_fields = { app_version: 'v1.2.3' }

child_logger = logger.child({ user_id: 123_456, username: 'johndoe' })

child_logger.debug('user signed in')
child_logger.debug('user opened document', doc_id: 'xyz')
child_logger.debug('user signed out')
Output
{"name":"main","hostname":"fedora","pid":702804,"level":20,"time":"2023-08-30T16:35:49.567+02:00","v":0,"msg":"user signed in","user_id":123456,"username":"johndoe","app_version":"v1.2.3"}
{"name":"main","hostname":"fedora","pid":702804,"level":20,"time":"2023-08-30T16:35:49.567+02:00","v":0,"msg":"user opened document","doc_id":"xyz","user_id":123456,"username":"johndoe","app_version":"v1.2.3"}
{"name":"main","hostname":"fedora","pid":702804,"level":20,"time":"2023-08-30T16:35:49.567+02:00","v":0,"msg":"user signed out","user_id":123456,"username":"johndoe","app_version":"v1.2.3"}

Since Ougai extends the built-in Logger class, it supports logging to files and auto-rotating logs in the same manner discussed earlier in the Logger section above. It also supports pretty printing logs through the Amazing Print package. Once installed, you may be use it as follows:

 
require 'ougai'

logger = Ougai::Logger.new($stdout)
logger.formatter = Ougai::Formatters::Readable.new
. . .

ougai_amazing_print.png

To learn more about Ougai and see the other things it can do, please check out its GitHub repo.

4. Logging

twp_logging.png

The Logging library is flexible logging library for Ruby programs heavily inspired by Java's Log4j module. It features a hierarchical logging system, multiple output destinations, custom formatting, and much more. By default, it is configured to produce the same output as the standard Logger class:

 
require 'logging'

logger = Logging.logger($stdout)

logger.debug('database query executed')
logger.info('user signed in')
logger.warn('disk space is 95% full')
logger.error('encountered an unexpected error while backing up database')
logger.fatal('application crashed')
Output
D, [2023-08-30T16:52:24.614290 #759283] DEBUG : database query executed
I, [2023-08-30T16:52:24.614328 #759283]  INFO : user signed in
W, [2023-08-30T16:52:24.614338 #759283]  WARN : disk space is 95% full
E, [2023-08-30T16:52:24.614345 #759283] ERROR : encountered an unexpected error while backing up database
F, [2023-08-30T16:52:24.614351 #759283] FATAL : application crashed

You can also name your logger and configure the destination of the logs using the provided add_appenders() method:

 
require 'logging'

logger = Logging.logger['example']
logger.add_appenders(Logging.appenders.stdout)
. . .
Output
DEBUG  example : database query executed
 INFO  example : user signed in
 WARN  example : disk space is 95% full
ERROR  example : encountered an unexpected error while backing up database
FATAL  example : application crashed

The default level can be customized in two ways. Either through the level property on the logger:

 
logger = Logging.logger['example']
logger.level = :warn

Or through the level property on the appender:

 
logger.add_appenders(Logging.appenders.stdout(level: :warn))

The result is the same either way:

Output
WARN  example : disk space is 95% full
ERROR  example : encountered an unexpected error while backing up database
FATAL  example : application crashed

To format your logs, a few predefined layouts are available: basic (the default), json, yaml, and pattern (for custom formats):

 
logger.add_appenders(Logging.appenders.stdout(level: :info, layout: Logging.layouts.json))
Output
{"timestamp":"2023-08-30T17:28:54.939779+02:00","level":"INFO","logger":"example","message":"user signed in"}
{"timestamp":"2023-08-30T17:28:54.956600+02:00","level":"WARN","logger":"example","message":"disk space is 95% full"}
{"timestamp":"2023-08-30T17:28:54.956620+02:00","level":"ERROR","logger":"example","message":"encountered an unexpected error while backing up database"}
{"timestamp":"2023-08-30T17:28:54.956631+02:00","level":"FATAL","logger":"example","message":"application crashed"}

Multiple appenders with different formats can be configured like this:

 
require 'logging'

Logging.color_scheme('bright',
                     levels: {
                       info: :green,
                       warn: :yellow,
                       error: :red,
                       fatal: %i[white on_red]
                     },
                     date: :blue,
                     logger: :cyan,
                     message: :magenta)

logger = Logging.logger['example']

logger.add_appenders(
  Logging.appenders.stdout(
    layout: Logging.layouts.pattern(
      pattern: '[%d] %-5l %c: %m\n',
      color_scheme: 'bright'
    )
  ),
  Logging.appenders.rolling_file(
    'app.log',
    age: 'daily',
    layout: Logging.layouts.json
  )
)
. . .

The colorized entries will be printed to the console, while the JSON-formatted entries will be placed in the app.log file.

Screenshot from 2023-08-30 17-45-28.png

To learn about Logging's other features, see the examples folder in its GitHub repository.

5. Yell

yell.png

Yell is a logging library designed to be a drop-in replacement for the standard Logger class in Ruby. It supports the same log levels you can see below:

 
require 'yell'

logger = Yell.new($stdout)

logger.debug('database query executed')
logger.info('user signed in')
logger.warn('disk space is 95% full')
logger.error('encountered an unexpected error while backing up database')
logger.fatal('application crashed')
Output
2023-08-30T17:53:16+02:00 [DEBUG] 964757 : database query executed
2023-08-30T17:53:16+02:00 [ INFO] 964757 : user signed in
2023-08-30T17:53:16+02:00 [ WARN] 964757 : disk space is 95% full
2023-08-30T17:53:16+02:00 [ERROR] 964757 : encountered an unexpected error while backing up database
2023-08-30T17:53:16+02:00 [FATAL] 964757 : application crashed

Changing the default log level is done directly through the new() method:

 
logger = Yell.new($stdout, level: :error)
Output
2023-08-30T20:01:15+02:00 [ERROR] 1318438 : encountered an unexpected error while backing up database
2023-08-30T20:01:15+02:00 [FATAL] 1318438 : application crashed

You can prepare a more elaborate configuration using adapters. In this case, DEBUG, INFO, and WARN logs are sent to the standard output, while ERROR and FATAL entries are sent to the standard error:

 
logger = Yell.new do |l|
  l.adapter($stdout, level: %i[debug info warn])
  l.adapter($stderr, level: %i[error fatal])
end

Logging to a file can be done using the :file or :datefile adapter. The latter adds a timestamp to the provided filename, while the former does not:

 
logger = Yell.new do |l|
  l.adapter(:file, 'app.log', level: %i[debug info warn]) # produces app.log
  l.adapter(:datefile, 'error.log', level: 'gte.error') # produces error.20230830.log
end

With Yell, you can format log messages using the provided placeholders. Although JSON isn't supported by default, you can do something like this to produce JSON-formatted output:

 
logger = Yell.new do |l|
  l.adapter($stdout, level: %i[debug info warn], format: '{"time": "%d", "msg": "%m", "level": "%L", "pid": %p}')
end
Output
{"time": "2023-08-30T20:19:48+02:00", "msg": "database query executed", "level": "DEBUG", "pid": 1390809}
{"time": "2023-08-30T20:19:48+02:00", "msg": "user signed in", "level": "INFO", "pid": 1390809}
{"time": "2023-08-30T20:19:48+02:00", "msg": "disk space is 95% full", "level": "WARN", "pid": 1390809}

Note that Yell doesn't appear to be actively maintained at the time of writing given its last commit in 2021.

6. MrLogaLoga

mr-loga-loga.png

MrLogaLoga is a relatively new logging library that focuses on making it easy to add contextual information to log entries. By default, it produces the same output as the standard Logger class:

Output
D, [2023-08-30T20:30:45.674216 #1430267] DEBUG -- : database query executed
I, [2023-08-30T20:30:45.674286 #1430267]  INFO -- : user signed in
W, [2023-08-30T20:30:45.674296 #1430267]  WARN -- : disk space is 95% full
E, [2023-08-30T20:30:45.674304 #1430267] ERROR -- : encountered an unexpected error while backing up database
F, [2023-08-30T20:30:45.674310 #1430267] FATAL -- : application crashed

However, unlike the standard logger, it's possible to attach contextual data at log point. There are a few ways to do this:

 
# Dynamic context method chaining
logger.query('SELECT 1;').duration('200ms').debug('database query executed')
# Using contextual arguments directly on the level method
logger.info('user signed in', username: 'johndoe', user_id: 123_456)
# Passing an explicit context
logger.context(space_used: '1234278MB', space_remaining: '100MB').warn('disk space is 95% full')
Output
D, [2023-08-30T20:35:15.275648 #1450800] DEBUG -- : database query executed query=SELECT 1; duration=200ms
I, [2023-08-30T20:35:15.275688 #1450800]  INFO -- : user signed in username=johndoe user_id=123456
W, [2023-08-30T20:35:15.275701 #1450800]  WARN -- : disk space is 95% full space_used=1234278MB space_remaining=100MB

Notice that the contextual properties use the key=value format by default. If you need a fully structured output, you can use the Json formatter as follows:

 
logger = MrLogaLoga::Logger.new($stdout, formatter: MrLogaLoga::Formatters::Json.new)
Output
{"severity":"DEBUG","datetime":"2023-08-30T20:47:30.478459","pid":1493435,"message":"database query executed","query":"SELECT 1;","duration":"200ms"}
{"severity":"INFO","datetime":"2023-08-30T20:47:30.478507","pid":1493435,"message":"user signed in","username":"johndoe","user_id":123456}
{"severity":"WARN","datetime":"2023-08-30T20:47:30.478521","pid":1493435,"message":"disk space is 95% full","space_used":"1234278MB","space_remaining":"100MB"}

While MrLogaLoga is a little light on features, it conveniently enables contextual logging in Ruby programs for those who don't require the extensive capabilities of comprehensive frameworks like Semantic Logger.

Final thoughts

The landscape of third-party logging frameworks in Ruby is not as robust as in other languages such as Go, Node.js, or Python, but there are still a few good options. We generally recommend using the Semantic Logger for its comprehensive features or Ougai if you need only some quality-of-life improvements over the standard Logger class.

Thanks for reading, and happy logging!

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is the Head of Content at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he’s not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
How To Start Logging With .NET
Learn how to start logging with .NET and go from basics to best practices in no time.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github