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