Skip to content

Ruby Logging — Logger, Log Levels, Formatting and Best Practices Explained

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Ruby Logging. We cover key concepts, practical examples, and best practices to help you master this topic.

Ruby Logging uses the built-in Logger class for configurable application logging with severity levels, multiple output destinations (file, STDOUT, syslog), and automatic log rotation.

What You'll Learn

  • Setting up and configuring Logger
  • Using log levels effectively
  • Custom log formatting
  • Log rotation and management

Why It Matters

Proper logging is essential for debugging and monitoring. Durga Antivirus Pro logs every scan event, threat detection, and configuration change. Doda Browser logs page loads, errors, and performance metrics. Without structured logging, diagnosing production issues becomes guesswork.

Real-World Use

Application error tracking, audit trails, performance monitoring, security event logging, debugging production issues, Compliance logging.

flowchart LR
    A["Logging"] --> B["Logger"]
    B --> C["Levels"]
    C --> D["Formatting"]
    D --> E["Rotation"]
    E --> F["Best Practices"]
    A:::current --> B
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style F fill:#f1f5f9,stroke:#94a3b8,color:#64748b

Basic Logger Setup

require "logger"

# Log to STDOUT
logger = Logger.new(STDOUT)
logger.info("Application started")
logger.warn("Configuration file not found, using defaults")
logger.error("Failed to connect to database: connection refused")

Log to File

require "logger"

# Log to a file
logger = Logger.new("app.log")
logger.info("Writing to app.log")

# With log rotation — keep 5 files of 10 MB each
logger = Logger.new("app.log", 5, 10_485_760)

# Daily rotation
logger = Logger.new("app.log", "daily")

Log Levels

Logger has six severity levels:

logger = Logger.new(STDOUT)
logger.level = Logger::INFO  # Default

logger.debug("Debug message")     # Only if level <= DEBUG
logger.info("Info message")       # General information
logger.warn("Warning message")    # Potential issues
logger.error("Error message")     # Recoverable errors
logger.fatal("Fatal message")     # Unrecoverable errors
logger.unknown("Unknown level")   # Should not happen

Level Filtering

logger = Logger.new(STDOUT)

# Show only warnings and above
logger.level = Logger::WARN
logger.debug("hidden")    # Not shown
logger.info("hidden")     # Not shown
logger.warn("visible")    # Seen
logger.error("visible")   # Seen

# Unknown level — always shown
logger.unknown("always")  # Seen regardless of level

Log Formatting

Custom Formatter

require "logger"

logger = Logger.new(STDOUT)

logger.formatter = proc do |severity, datetime, progname, msg|
  "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
end

logger.info("Custom format")
# [2026-06-28 12:30:45] INFO: Custom format

# JSON format for structured logging
logger.formatter = proc do |severity, datetime, progname, msg|
  {
    timestamp: datetime.utc.iso8601,
    level: severity,
    message: msg,
    pid: Process.pid
  }.to_json + "\n"
end

logger.info("JSON log entry")
# {"timestamp":"2026-06-28T12:30:45Z","level":"INFO","message":"JSON log entry","pid":12345}

Multi-line Messages

logger = Logger.new(STDOUT)

# Logger handles multi-line messages
logger.error("First line\nSecond line\nThird line")
# E, [2026-06-28T12:30:45.123456 #12345] ERROR -- : First line
# Second line
# Third line

Advanced Logger Features

Tagged/Progname Logging

logger = Logger.new(STDOUT)

# Tag with program name
logger.info("UserService") { "User #{user_id} logged in" }
logger.error("AuthService") { "Login failed: invalid password" }

# Or set default progname
logger.progname = "MyApp"
logger.info("Default progname applied")

Multiple Output Destinations

require "logger"
require "syslog"

# Create a multi-destination logger
log_file = Logger.new("app.log")
log_console = Logger.new(STDOUT)

class MultiLogger
  def initialize(*loggers)
    @loggers = loggers
  end

  def method_missing(name, *args, &block)
    @loggers.each { |log| log.send(name, *args, &block) }
  end

  def respond_to_missing?(name, include_private = false)
    @loggers.any? { |log| log.respond_to?(name) } || super
  end
end

logger = MultiLogger.new(log_file, log_console)
logger.info("This goes to both file and console")

Logger with Syslog

require "logger"
require "syslog/logger"

syslog = Syslog::Logger.new("myapp")
syslog.info("Logged to syslog")
# /var/log/syslog: Jun 28 12:30:45 hostname myapp: Logged to syslog

Logging Best Practices

Structured Logging

class StructuredLogger
  def initialize(logger)
    @logger = logger
  end

  def info(event, metadata = {})
    @logger.info(format_event(event, metadata))
  end

  def error(event, exception = nil, metadata = {})
    data = metadata.dup
    if exception
      data[:exception] = exception.class.to_s
      data[:message] = exception.message
      data[:backtrace] = exception.backtrace&.first(5)
    end
    @logger.error(format_event(event, data))
  end

  private

  def format_event(event, metadata)
    {
      event: event,
      timestamp: Time.now.utc.iso8601,
      pid: Process.pid
    }.merge(metadata).to_json
  end
end

logger = StructuredLogger.new(Logger.new(STDOUT))
logger.info("user.login", user_id: 42, ip: "192.168.1.1")

begin
  1 / 0
rescue ZeroDivisionError => e
  logger.error("calculation.error", e, operation: "division")
end

Contextual Logging

class ContextLogger
  def initialize(logger, context = {})
    @logger = logger
    @context = context
  end

  def with_context(additional)
    self.class.new(@logger, @context.merge(additional))
  end

  def info(msg)
    @logger.info("#{context_prefix}#{msg}")
  end

  def error(msg)
    @logger.error("#{context_prefix}#{msg}")
  end

  private

  def context_prefix
    return "" if @context.empty?
    "[#{@context.map { |k, v| "#{k}=#{v}" }.join(" ")}] "
  end
end

logger = ContextLogger.new(Logger.new(STDOUT))
logger.info("Server started")

# With request context
request_logger = logger.with_context(request_id: "abc-123", user_id: 42)
request_logger.info("Processing order")
# [request_id=abc-123 user_id=42] Processing order

Log Level Management

require "logger"
require "yaml"

class ConfigurableLogger
  def initialize(app_name, config_file = "log_config.yml")
    @logger = Logger.new("#{app_name}.log")
    @logger.level = load_log_level(config_file)
    @logger.formatter = default_formatter
  end

  def load_log_level(config_file)
    if File.exist?(config_file)
      config = YAML.load_file(config_file)
      Logger.const_get(config["log_level"] || "INFO")
    else
      Logger::INFO
    end
  rescue NameError
    Logger::INFO
  end

  def default_formatter
    proc do |severity, datetime, progname, msg|
      "[#{datetime.utc.iso8601}] #{severity.ljust(5)}: #{msg}\n"
    end
  end

  def with_level(level)
    old_level = @logger.level
    @logger.level = level
    yield
  ensure
    @logger.level = old_level
  end
end

logger = ConfigurableLogger.new("myapp")

# Temporarily change level
logger.with_level(Logger::DEBUG) do
  logger.debug("Debug info within this block only")
end
logger.debug("This won't show")  # Back to configured level

Common Mistakes

1. Logging Sensitive Information

# Bad — logs passwords
logger.info("User #{email} logged in with password #{password}")

# Good — never log credentials
logger.info("User #{email} logged in")

2. Using puts Instead of Logger

# Bad — no level control, no formatting, no rotation
puts "User logged in"

# Good
logger.info("User logged in")

3. Not Setting Log Level in Production

# Bad — debug logs flood production
logger = Logger.new("app.log")  # Default level is DEBUG

# Good
logger.level = Logger::INFO  # Or ERROR/WARN in production

4. Logging Inside Hot Paths

# Bad — logging every iteration
items.each do |item|
  logger.debug("Processing item #{item.id}")
  process(item)
end

# Good — batch or sample logging
logger.info("Processing #{items.size} items")

5. Not Handling Log Rotation

# Bad — single file grows unbounded
logger = Logger.new("app.log")

# Good — rotate at 10MB, keep 5 files
logger = Logger.new("app.log", 5, 10_485_760)

Practice Questions

1. What are Ruby Logger's six severity levels?

DEBUG, INFO, WARN, ERROR, FATAL, UNKNOWN. Each level includes all higher levels. Setting level to WARN shows WARN, ERROR, FATAL, and UNKNOWN.

2. How do you configure log rotation?

Pass shift_age and shift_size to the constructor: Logger.new("app.log", 5, 10485760) keeps 5 files of 10 MB each, or use "daily" for daily rotation.

3. How do you customize the log format?

Set a custom logger.formatter proc that accepts severity, datetime, progname, and msg, returning the formatted string.

4. Why not use puts for logging?

puts offers no level control, no timestamp, no formatting, no rotation, no file output, and no structured logging. Logger provides all of these.

Challenge: Create a LeveledLogger class that supports runtime level changes, JSON structured logging, and automatic flushing on crash.

Solution
require "logger"
require "json"

class LeveledLogger
  LEVELS = %i[debug info warn error fatal unknown].freeze

  def initialize(output = STDOUT, level: :info)
    @logger = Logger.new(output)
    @logger.level = Logger.const_get(level.upcase)
    @logger.formatter = method(:json_formatter)
    at_exit { flush }
  end

  def level=(level)
    @logger.level = Logger.const_get(level.to_s.upcase)
  end

  LEVELS.each do |level|
    define_method(level) do |message, metadata = {}|
      @logger.send(level) { format_message(message, metadata) }
    end
  end

  private

  def json_formatter(severity, datetime, _progname, msg)
    { timestamp: datetime.utc.iso8601, level: severity, message: msg }.to_json + "\n"
  end

  def format_message(message, metadata)
    metadata.empty? ? message : { text: message, **metadata }
  end

  def flush
    @logger.close rescue nil
  end
end

log = LeveledLogger.new("app.log", level: :info)
log.info("User created", user_id: 42, source: "signup")
log.error("Database timeout", timeout: 30, retry: 3)

Expected output in log file:

{"timestamp":"2026-06-28T12:30:45Z","level":"INFO","message":{"text":"User created","user_id":42,"source":"signup"}}
{"timestamp":"2026-06-28T12:30:46Z","level":"ERROR","message":{"text":"Database timeout","timeout":30,"retry":3}}

FAQ

{{< faq question="Should I use Logger or a logging gem?" >}} Logger is sufficient for most applications. For advanced needs (structured logging, log aggregation, async logging), consider SemanticLogger, Lograge, or Fluentd. {{< /faq >}}

{{< faq question="What's the best log level for production?" >}} INFO or WARN. DEBUG is too verbose for production. ERROR catches issues needing attention. FATAL indicates unrecoverable failures. {{< /faq >}}

{{< faq question="How do I log to both file and STDOUT?" >}} Create a MultiLogger wrapping both file and STDOUT loggers, or use Logger's log_device parameter to send to multiple destinations. {{< /faq >}}

{{< faq question="Is Logger thread-safe?" >}} Yes. Logger uses a Mutex internally to ensure thread-safe writes. You can safely share a Logger instance across threads. {{< /faq >}}

{{< faq question="How do I anonymize sensitive data in logs?" >}} Create a wrapper that filters sensitive fields (passwords, tokens, PII) before passing to Logger. Use regex or key-based filtering. {{< /faq >}}

Try It Yourself

# logging_demo.rb

require "logger"
require "json"

# Create a JSON logger
logger = Logger.new(STDOUT)
logger.level = Logger::DEBUG

logger.formatter = proc do |severity, datetime, progname, msg|
  log_entry = {
    time: datetime.strftime("%H:%M:%S"),
    level: severity,
    msg: msg,
    pid: Process.pid
  }
  log_entry.to_json + "\n"
end

# Simulate application events
logger.debug("Starting debug diagnostics")
logger.info("Application initialized")
logger.warn("Memory usage at 85%")

begin
  raise "Simulated error"
rescue => e
  logger.error("Error: #{e.message}")
  logger.debug("Backtrace: #{e.backtrace.first(3).join(' | ')}")
end

logger.fatal("Unrecoverable: shutting down")

Expected output (formatted):

{"time":"12:30:45","level":"DEBUG","msg":"Starting debug diagnostics","pid":12345}
{"time":"12:30:45","level":"INFO","msg":"Application initialized","pid":12345}
{"time":"12:30:45","level":"WARN","msg":"Memory usage at 85%","pid":12345}
{"time":"12:30:45","level":"ERROR","msg":"Error: Simulated error","pid":12345}
{"time":"12:30:45","level":"FATAL","msg":"Unrecoverable: shutting down","pid":12345}

What's Next

Now that you understand logging, set up Ruby on Rails and build your first web application with the world's most productive web framework.

Topic Description Link
Ruby Rails Setup MVC, directory structure {{< ref "25-rails-setup" >}}
Ruby Exception Handling begin/rescue/ensure {{< ref "20-exception-handling" >}}
Python Logging Compare Python's logging module Python

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro