
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Debugging a live incident without structured data is like searching for a needle in a haystack while blindfolded. Effective production logging for Ruby applications transforms chaotic text streams into queryable, actionable intelligence that reduces mean time to resolution (MTTR). This guide covers the exact configuration patterns I use to ship secure, compliant, and observable logs from Rails, Puma, and Sidekiq environments.
How do you configure structured production logging for Ruby applications?
The default Rails logger outputs human-readable text that is nearly impossible to parse reliably at scale. For production logging for Ruby applications, you must switch to a structured JSON formatter. This allows log aggregators to index fields like request_id, user_id, and duration_ms without expensive regex parsing. As outlined in our structured logging best practices, consistency in field naming is critical for building reliable dashboards and alerts.
Configure the Oj or JSON formatter
Add the oj gem to your Gemfile for high-performance JSON serialization. Then configure the logger in config/environments/production.rb:
# config/environments/production.rb
require 'active_support/logger'
require 'oj'
Oj.default_options = { mode: :compat }
config.log_formatter = proc do |severity, datetime, progname, msg|
payload = {
timestamp: datetime.iso8601(3),
level: severity,
message: msg,
pid: Process.pid,
hostname: Socket.gethostname,
service: ENV.fetch('SERVICE_NAME', 'ruby-app')
}
# Merge thread-local context (request_id, user_id)
payload.merge!(Thread.current[:log_context] || {})
Oj.dump(payload) + "\n"
end
# Ensure STDOUT is used for containerized environments
config.logger = ActiveSupport::Logger.new(STDOUT)
config.logger.level = Logger::INFO Inject request context automatically
Logs without correlation IDs are useless during incidents. Use middleware to inject trace and request identifiers into every log line emitted during that request cycle:
# app/middleware/log_context_middleware.rb
class LogContextMiddleware
def initialize(app)
@app = app
end
def call(env)
Thread.current[:log_context] = {
request_id: env['action_dispatch.request_id'],
trace_id: env['HTTP_X_TRACE_ID'],
method: env['REQUEST_METHOD'],
path: env['PATH_INFO']
}
status, headers, body = @app.call(env)
[status, headers, body]
ensure
Thread.current[:log_context] = nil
end
end
# config/application.rb
config.middleware.use LogContextMiddleware How do you filter sensitive data in Ruby production logs?
A common mistake in production logging for Ruby applications is accidentally persisting PII, tokens, or passwords. Under ISO 27001 and SOC 2 frameworks, this constitutes a control failure. You must implement filtering at the formatter level, not just rely on Rails parameter filtering which only applies to controller params.
Implement a recursive sanitizer
Create a dedicated sanitizer module that scrubs sensitive keys before serialization. This catches nested hashes and arrays that simple parameter filters miss:
# lib/log_sanitizer.rb
module LogSanitizer
SENSITIVE_KEYS = %w[password token secret api_key authorization credit_card ssn].freeze
REDACTED = '[REDACTED]'
def self.sanitize(data)
case data
when Hash
data.each_with_object({}) do |(key, value), result|
if SENSITIVE_KEYS.any? { |k| key.to_s.downcase.include?(k) }
result[key] = REDACTED
else
result[key] = sanitize(value)
end
end
when Array
data.map { |item| sanitize(item) }
when String
# Redact Bearer tokens and basic auth headers
data.gsub(/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/, "Bearer #{REDACTED}")
.gsub(/Basic\s+[A-Za-z0-9+\/=]+/, "Basic #{REDACTED}")
else
data
end
end
end Integrate this into your formatter's payload construction step. Never log raw exception backtraces without sanitization; they often contain environment variables or query strings with embedded secrets. For deeper guidance on protecting credentials across your stack, see our article on Kubernetes secrets management done right.
What are the correct log levels for Ruby production environments?
Setting the wrong log level either floods your storage with noise or hides critical failures. In practice, most teams over-log at INFO and under-log at WARN. Understanding the semantic meaning of each level prevents alert fatigue and ensures your SLOs remain meaningful, as discussed in defining meaningful SLIs and SLOs.
| Level | Semantic Meaning | Production Use Case | Retention |
|---|---|---|---|
| DEBUG | Verbose diagnostic information | Never enabled in prod; dev/staging only | N/A |
| INFO | Normal operational milestones | Request completion, job success, deploy events | 30 days |
| WARN | Recoverable issues requiring attention | Retry attempts, deprecated API usage, rate limits | 90 days |
| ERROR | Failures affecting individual requests/jobs | Exceptions, validation failures, external API errors | 1 year |
| FATAL | Process-crashing or system-wide failures | DB connection loss, OOM, unrecoverable state | Indefinite |
Avoid logging inside hot paths
Even with asynchronous shipping, excessive INFO logging in tight loops or high-RPS endpoints adds measurable latency. Profile your logging overhead. If an endpoint handles 5,000 RPS, five INFO logs per request means 25,000 log writes per second. Sample verbose logs or move them to DEBUG behind a feature flag.
How do you handle logging in Puma and Sidekiq workers?
Rails configuration alone does not cover your entire runtime. Puma and Sidekiq have their own logging pipelines that bypass config.log_formatter unless explicitly configured. Missing these creates blind spots exactly where failures occur most frequently.
Puma structured logging
In config/puma.rb, redirect Puma's internal logs through your Rails logger so they share the same JSON format and context:
# config/puma.rb
stdout_redirect '/dev/stdout', '/dev/stderr', true
on_worker_boot do
ActiveRecord::Base.establish_connection
# Re-initialize logger after fork to avoid shared file descriptors
Rails.logger.reopen(STDOUT)
end
# Custom log formatter for Puma-specific events
log_formatter do |str|
Oj.dump({
timestamp: Time.now.iso8601(3),
level: 'INFO',
source: 'puma',
message: str.strip,
pid: Process.pid
}) + "\n"
end Sidekiq job context propagation
Sidekiq jobs run outside the HTTP request cycle, so they lack request_id. Inject job metadata via server middleware:
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add SidekiqLogContextMiddleware
end
end
class SidekiqLogContextMiddleware
def call(worker, job, queue)
Thread.current[:log_context] = {
job_id: job['jid'],
job_class: worker.class.name,
queue: queue,
args_hash: LogSanitizer.sanitize(job['args'])
}
yield
ensure
Thread.current[:log_context] = nil
end
end How do you ship Ruby logs without blocking application threads?
Writing logs synchronously to disk or network introduces tail latency. In high-throughput Ruby applications, this can add 5–20ms per request. The solution is decoupling: write to stdout or a local buffer, then let a dedicated shipper handle transport. This pattern also provides resilience against network partitions and backend outages.
Choose the right shipper
- Fluent Bit: Lightweight C-based agent, ideal for Kubernetes sidecars and resource-constrained VPS deployments. Low memory footprint (~10MB).
- Vector: Rust-based, excellent for complex transformations and multi-sink routing. Better performance than Fluentd for Ruby JSON logs.
- Fluentd: Ruby-based, rich plugin ecosystem but higher memory usage. Good choice if your team already maintains custom Ruby plugins.
For detailed comparisons of these tools, read our breakdown of Fluentd vs Fluent Bit for log shipping. Regardless of choice, configure local buffering with disk fallback to prevent log loss during network blips.
Container and systemd integration
In Docker/Kubernetes, always log to stdout/stderr. Let the container runtime capture output and forward it to the node-level shipper via journald or direct file tailing. Never mount volumes for application logs in production; this couples pod lifecycle to storage availability and complicates scaling. For systemd-managed bare-metal deployments, configure StandardOutput=journal and have your shipper read from the journal API rather than tailing files.
Implementing Audit-Ready Production Logging for Ruby Applications
Effective production logging for Ruby applications is not just about debugging—it is an audit artifact. Ensure immutable storage, tamper-evident hashing, and access controls on your log backend. Define retention tiers aligned with your compliance obligations before you need them during an audit. Start by converting one service to structured JSON this week, validate the output in your aggregator, then roll out the pattern across your fleet. If you need help designing a compliant observability stack or auditing your current logging posture, reach out to discuss your infrastructure.