Logging in depth

Logger and levels

Logger has a level and drops everything below it. The level is the switch that keeps a development log loud and a production log quiet without editing a call:

require "logger" logger = Logger.new($stderr) logger.level = Logger::INFO logger.debug("hidden") logger.info("started")

The order is DEBUG, INFO, WARN, ERROR, FATAL. A line at DEBUG is free to be verbose because the level above keeps it out of production, and the one call site does not change between environments.

A formatter

The default line is a timestamp, a severity, the process id and the message. A formatter is a proc that returns the whole line, which is where a project decides what its logs look like:

logger.formatter = proc do |severity, time, _progname, msg| "#{time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")} #{severity} #{msg}\n" end

The trailing newline is required: the formatter returns the complete line. UTC timestamps make two servers in different zones comparable, and a fixed format is what a log search can parse.

One JSON object per line

A structured event is a hash, encoded as one JSON line. A log collector reads the fields without a regex, and a new field does not break the old query:

require "json" logger.info(JSON.generate(event: "request", path: "/", status: 200, ms: 12)) logger.warn("slow request")

ruby app.rb 2> app.log

cat app.log

2026-11-05T00:50:06Z INFO started 2026-11-05T00:50:06Z INFO {"event":"request","path":"/","status":200,"ms":12} 2026-11-05T00:50:06Z WARN slow request

The debug line is absent because the level is INFO. Mixing a sentence and a JSON object in one file is honest for a small script; a service that a collector reads logs JSON only, so every line has the same shape.

Rotation

A log that grows forever fills the disk. Logger.new takes a path, a number of old files and a size in bytes, and rotates when the current file passes it:

logger = Logger.new("app.log", 2, 1024 * 1024)

That keeps app.log plus two old files of a megabyte each. In a container the usual answer is the opposite: log to stdout, let the platform collect it, and rotate nothing inside the process.

What to watch for

A log line that contains a password, a token or a full request body is a leak that outlives the bug. Log the id and the shape, not the secret; lesson 116 has the warning side of the same idea.

puts in a library writes to the caller's stdout and bypasses the level. A gem logs through a Logger it was given or Logger.new($stderr), never through puts.

Next: Testing with Minitest

Article author: Arthur Isaev