Ruby logging with Logger: JSON output, rotation, and exceptions
Configure Ruby Logger levels and formatters, preserve structured fields and exceptions, and check rotation and collection before sending logs to Guance.
Ruby's Logger library covers the basics: severity levels, formatting, output destinations, and file rotation. For many applications, a clear formatter and an explicit output policy are enough to make those logs useful in production.
This guide starts with Logger, adds JSON and exception fields, and explains rotation and multiple destinations. It finishes with the field checks needed to collect the output in Guance. The examples use logger and standard Ruby facilities; declare the appropriate gem dependency in a Bundler-managed application if your Ruby distribution or bundle requires it.
Create a logger and set its level
require "logger"
logger = Logger.new($stdout)
logger.level = Logger::INFO
logger.debug("Debug details") # Suppressed at the INFO threshold.
logger.info("Service started port=3000")
logger.warn("Configuration missing; using default")
logger.error("Database connection failed")
logger.fatal("Unrecoverable application state")
The default formatter includes severity, a timestamp, and the process ID. Representative output looks like this:
I, [2026-08-25T14:40:11.552301 #1294858] INFO -- : Service started port=3000
W, [2026-08-25T14:40:11.553120 #1294858] WARN -- : Configuration missing; using default
The available severities are DEBUG, INFO, WARN, ERROR, FATAL, and UNKNOWN, in increasing numeric order. The configured level is the minimum accepted severity. Calling fatal records a message; it does not terminate the process. Application control flow must handle an unrecoverable condition separately.
Predicates such as logger.info? tell you whether a level is enabled. A block defers message construction until it is needed:
logger.debug { "Diagnostic details: #{expensive_compute}" }
Here, expensive_compute is an application method. It is not called when debug logging is disabled.
Customize the formatter
A formatter receives severity, time, program name, and the message object:
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%dT%H:%M:%S%z')}] #{severity} #{msg}\n"
end
For example:
[2026-08-25T14:40:11+0800] INFO Service started port=3000
Set logger.progname to identify a subsystem:
logger.progname = "OrderService"
Include progname in the formatter if it should appear in the output. Assigning it does not force a custom formatter to serialize it.
Text output can work well when people read it directly or a stable parser already exists. JSON is useful when several context fields need to remain independently queryable.
Emit JSON with explicit context fields
This formatter handles strings, structured hashes, and exceptions. Application context is placed under context so that callers cannot accidentally overwrite the timestamp or severity:
require "logger"
require "json"
require "time"
logger = Logger.new($stdout)
logger.progname = "OrderService"
logger.formatter = proc do |severity, datetime, progname, msg|
payload = {
timestamp: datetime.iso8601(3),
level: severity,
progname: progname
}
case msg
when Exception
payload[:message] = msg.message
payload[:error] = {
type: msg.class.name,
message: msg.message,
backtrace: Array(msg.backtrace)
}
when Hash
payload[:message] = msg.fetch(:message, "Application event")
payload[:context] = msg.reject { |key, _| key == :message }
else
payload[:message] = msg.to_s
end
"#{JSON.generate(payload)}\n"
end
logger.info("User signed in")
logger.info(message: "Order created", order_id: "ORD-123", user_id: 123)
A structured event resembles:
{"timestamp":"2026-08-25T14:41:05.123+08:00","level":"INFO","progname":"OrderService","message":"Order created","context":{"order_id":"ORD-123","user_id":123}}
This example uses symbol keys for structured messages. Keep the convention consistent or normalize keys explicitly. Ensure values can be serialized and decide which identifiers are appropriate to retain. A formatter is not a reason to include credentials or full request bodies.
The collector still needs to parse the output, map severity, and interpret the timestamp. In particular, decide whether downstream queries expect an identifier in context.order_id or at the top level. The schema should be intentional on both sides.
Configure rotation and retention deliberately
Logger supports period-based and size-based rotation. Create the directory before opening a file:
require "fileutils"
FileUtils.mkdir_p("logs")
# Rotate by period: daily, weekly, or monthly.
daily_logger = Logger.new("logs/daily.log", "daily")
# Size-based rotation, with a rotation count of 7 and a 100 MiB threshold.
size_logger = Logger.new("logs/size.log", 7, 100 * 1024 * 1024)
The positional arguments are the destination, shift_age, and shift_size. A numeric shift_age controls size-based rotation. In the standard implementation, a count of seven corresponds to the active file plus up to six rotated files; do not budget it as seven historical files in addition to the active one. Verify behavior for the Logger version in use.
The threshold is checked around writes, so one large event can take a file past the nominal limit. Period-based rotation also needs a retention plan: choosing "daily" establishes when rotation happens, not a universal promise that only a fixed number of daily files will remain. Monitor disk use and test the actual files created over several rotations.
Only one layer should own a file's rotation strategy. An external tool and an in-process logger rotating the same file independently can interfere with collection and open file descriptors.
Preserve exception details
If code turns an exception into a short string, any omitted traceback is gone from that log record. With the JSON formatter above, pass the exception itself:
begin
raise ArgumentError, "Payment amount must be positive"
rescue => e
logger.error(e)
end
For operation-specific context, supply explicit fields:
begin
process_payment(order)
rescue => e
logger.error(
message: "Payment processing failed",
order_id: order.id,
error_type: e.class.name,
error_message: e.message,
backtrace: Array(e.backtrace).first(10)
)
end
The second block is an application fragment: order and process_payment come from the surrounding code. Truncating to ten frames is a deliberate size trade-off, not a requirement; keep enough information for the failure modes you need to investigate. These examples log a rescued exception without re-raising it. Choose propagation or recovery behavior separately rather than silently swallowing a production failure.
The default Logger formatter can format an Exception object with its class, message, and backtrace. A custom formatter replaces that behavior. Test it directly instead of assuming either that all Logger calls capture stacks automatically or that Logger can never display one.
Send events to multiple destinations
A standard Logger instance writes to one destination. A small wrapper can forward calls to several instances:
class MultiLogger
def initialize(*loggers)
@loggers = loggers
end
%i[debug info warn error fatal unknown].each do |severity|
define_method(severity) do |*args, &block|
@loggers.each do |logger|
logger.public_send(severity, *args, &block)
end
end
end
end
This is forwarding, not a complete logging framework. Each underlying logger has its own level and formatter, a message block can run more than once, and an error from one destination can interrupt forwarding to the next. Do not put side effects in a lazy message block. Define the required failure behavior before relying on this wrapper for critical events.
A library such as Semantic Logger may be more suitable when multiple appenders, asynchronous output, and structured payload handling are recurring requirements. For containers, writing one stream to standard output and distributing it in the collection layer often simplifies the application. Verify the runtime's buffering and rotation policy as part of that choice.
Check the events collected in Guance
For a host deployment, configure DataKit to read the actual file paths. For container output, check runtime paths and permissions. The DataKit logging guide provides the collection setup.
Emit an ordinary JSON event and one with an exception. Verify that timestamp becomes event time, level maps to the expected severity, context fields retain their types, and the full exception remains one event. Use the formatter's actual output rather than assuming that every Ruby library uses the same names.
Only then build an error query and a log detection rule, with an explicit evaluation window and notification recipients. Parsing failures should be visible separately; missing parsed errors do not establish that the application is healthy. If requests already have trace identifiers, preserve the actual identifier and validate its relationship to the stored trace.
Common questions
Does every Ruby application need the standard Logger?
No. It is a useful starting point with a familiar API. Semantic Logger can address richer appender and structured-event requirements. Rails applications may use Lograge to reshape request logging. Choose the capabilities required by the application and check compatibility with the surrounding framework.
Can multiple threads share a logger?
Logger synchronizes writes within its standard log device, which supports sharing an instance across threads. That is narrower than a guarantee about every custom output object, formatter, or separate process writing to the same destination. Avoid changing shared configuration during requests, and test multiprocess output and rotation separately.
How should timestamps be standardized?
Choose a timezone policy and include an offset. Use require "time" before calling datetime.iso8601(3), or format the timestamp explicitly with strftime. UTC is convenient across regions, but a documented local offset can also be unambiguous. Do not drop the offset when moving between hosts or parsing at the backend.
What should happen to existing puts calls?
puts writes to its output stream without a severity or logging schema. A container collector may already capture that output; the problem is missing structure and control, not necessarily missing data. Migrate operational messages to logger calls gradually. Temporary output capture can preserve legacy messages, but it cannot infer their level, service context, or original timestamp reliably.
Related guides
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan