Introduction

dry-logger is a standalone, dependency-free logging solution for Ruby applications.

Features

  • Structured logging - First-class support for key-value payloads
  • Multiple destinations - Log to stdout, files, or multiple backends simultaneously
  • Flexible formatting - String, JSON, and Rack formatters included
  • Data filtering - Redact sensitive information from logs
  • Exception handling - Automatic formatting of exceptions with backtraces
  • Customizable templates - Control log format with colorization support
  • Extensible - Add custom backends and formatters

Installation

Add dry-logger to your Gemfile:

gem "dry-logger"

Getting started

Create a logger and start logging:

require "dry/logger"

logger = Dry.Logger(:my_app)

logger.info("Application started")
# Application started

logger.warn("Low memory warning")
# Low memory warning

logger.error("Failed to connect to database")
# Failed to connect to database

Log levels

Set the minimum severity level to control which messages are logged:

logger = Dry.Logger(:my_app, level: :warn)

logger.debug("Debug message")  # Won't be logged
logger.info("Info message")    # Won't be logged
logger.warn("Warning message") # Will be logged
logger.error("Error message")  # Will be logged

Available levels (lowest to highest): :debug, :info (default), :warn, :error, :fatal, :unknown

Structured logging

Attach key-value data to log entries:

logger = Dry.Logger(:my_app)

# Log with structured data
logger.info("User logged in", user_id: 42, ip: "192.168.1.1")
# User logged in user_id=42 ip="192.168.1.1"

# Log only structured data (no message)
logger.info(action: "signup", user_id: 123, plan: "premium")
# action="signup" user_id=123 plan="premium"

Logging exceptions

Exceptions are automatically formatted with their message, class, and backtrace:

begin
  1 / 0
rescue => e
  logger.error(e)
  # ZeroDivisionError: divided by 0
  #   /path/to/file.rb:10:in `/'
  #   /path/to/file.rb:10:in `<main>'
end

Block-based logging

For expensive operations, use blocks to avoid computing messages unless they'll actually be logged:

logger = Dry.Logger(:my_app, level: :info)

# Block is NOT evaluated (debug < info)
logger.debug { expensive_debug_info }

# Block IS evaluated
logger.info { "User count: #{User.count}" }
# User count: 42

Blocks can also return structured data:

logger.info { {action: "cache_miss", key: "user:123"} }
# action="cache_miss" key="user:123"

Customizing progname per entry

Override the logger's default progname for specific log entries:

logger = Dry.Logger(:my_app)

# Use progname keyword
logger.info("Request received", progname: "http_server", path: "/api/users")
# Logs with progname "http_server" instead of "my_app"

# Or pass as first argument with block-based logging
logger.info("worker") { "Job completed" }
# Logs with progname "worker"

Output streams

Standard output (default)

logger = Dry.Logger(:my_app)  # Logs to $stdout

Files

logger = Dry.Logger(:my_app, stream: "logs/application.log")  # Relative path
logger = Dry.Logger(:my_app, stream: "/var/log/app.log")      # Absolute path

StringIO (testing)

require "stringio"

output = StringIO.new
logger = Dry.Logger(:my_app, stream: output)

logger.info("Test message")

puts output.string
# Test message

Multiple destinations

Using add_backend

Add backends to the default logger:

logger = Dry.Logger(:test, template: :details)
  .add_backend(stream: "logs/test.log")

logger.info "Hello World"
# Logs to both $stdout and logs/test.log
# [test] [INFO] [2022-11-17 11:46:12 +0100] Hello World

Block-based configuration

For more control, configure all backends in a block:

logger = Dry.Logger(:test) do |setup|
  setup.add_backend(stream: "logs/test.log", template: :details)
  setup.add_backend(stream: "logs/errors.log", log_if: :error?)
end

logger.info "Hello World"
# Only logs to the files you configured (no stdout)

When you use a block, dry-logger skips creating the default stdout backend, giving you complete control.

Conditional logging

Route logs to specific backends based on conditions using log_if:

logger = Dry.Logger(:test, template: :details)
  .add_backend(stream: "logs/requests.log", log_if: -> entry { entry.key?(:request) })

logger.info "Hello World"
# Only to $stdout: [test] [INFO] [2022-11-17 11:50:12 +0100] Hello World

logger.info "GET /posts", request: true
# To both $stdout and logs/requests.log
# [test] [INFO] [2022-11-17 11:51:50 +0100] GET /posts request=true

Templates and formatting

Custom templates

Customize the log format using sprintf-style templates:

logger = Dry.Logger(:test, template: "[%<severity>s] %<message>s")

logger.info "Hello World"
# [INFO] Hello World

The following tokens are supported:

  • %<progname>s - Logger identifier
  • %<severity>s - Log level (DEBUG, INFO, WARN, ERROR, FATAL)
  • %<time>s - Timestamp
  • %<message>s - Log message
  • %<payload>s - Structured data as key=value pairs

You can also use payload keys directly in templates:

logger = Dry.Logger(:test, template: "[%<severity>s] %<verb>s %<path>s")

logger.info verb: "GET", path: "/users"
# [INFO] GET /users

Colorized output

Use color tags in templates for better readability:

logger = Dry.Logger(:test, template: "[%<severity>s] <blue>%<verb>s</blue> <green>%<path>s</green>")

logger.info verb: "GET", path: "/users"
# [INFO] GET /users (with blue verb and green path)

Available colors: black, red, green, yellow, blue, magenta, cyan, gray

Formatters

dry-logger includes three formatters:

  • :string - Human-readable key=value format with color support (development)
  • :json - Structured JSON with UTC timestamps (production)
  • :rack - Optimized for HTTP request logging

Use the formatter option:

logger = Dry.Logger(:test, formatter: :rack)

logger.info verb: "GET", path: "/users", elapsed: "12ms", ip: "127.0.0.1", status: 200, length: 312, params: {}
# [test] [INFO] [2022-11-17 12:04:30 +0100] GET 200 12ms 127.0.0.1 /users 312

Log rotation

Rotate logs by size or time period:

Size-based rotation:

# Keep 5 files, max 10MB each
logger = Dry.Logger(:test,
  stream: "logs/test.log",
  shift_age: 5,
  shift_size: 10_485_760
)

Time-based rotation:

# Rotate daily, weekly, or monthly
logger = Dry.Logger(:test, stream: "logs/test.log", shift_age: "daily")

See Ruby's Logger documentation for details.

Next steps

Now that you understand the basics, explore more features:

  • Backends - Configure multiple logging destinations
  • Formatters - Control output format (string, JSON, Rack)
  • Templates - Customize log message format
  • Filtering - Filter sensitive data from logs
  • Context - Add request-scoped data to log entries
  • Tagged logging - Mark and filter log entries with tags
  • Crash handling - Customize behavior when logging itself crashes
  • Testing - Test your application's logging
  • Examples - Complete, realistic configuration examples

octocatEdit on GitHub