Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 065 / 100

Logging and monitoring Rails for Ruby on Rails developers

Know what happens in production.

Expona Admin8 min read

Introduction

You deploy your Rails app at 2 AM on a Friday, everything looks green, and then your phone buzzes at 3:47 AM with reports of slowdowns. Without proper logging and monitoring, you're flying blind—patching in the dark and hoping your fixes work. Logging and monitoring Rails for Ruby on Rails developers isn't optional anymore; it's how we stay in control of production systems and sleep through the night.

This guide covers the essentials: structured logs that tell the story of what happened, error tracking that catches exceptions before your customers do, APM tools that expose performance bottlenecks, and health checks that let you know the basics are working. Together, these four layers transform production from a black box into a transparent, observable system.

Structured Logs — JSON Logs with Request IDs

By default, Rails logs are human-readable but hard for machines to parse and correlate. When you have hundreds of requests flowing through simultaneously, a plain text log line about a failed database query tells you what happened but not which user or which downstream service was affected.

Structured logging—emitting JSON instead of text—solves this. Each log entry becomes an object with consistent fields: timestamp, level, message, request ID, user ID, and whatever custom context matters to your app.

Rails 7.1+ includes the SemanticLogger gem built-in, but you can also add it explicitly. Here's a typical setup:

ruby
# Gemfile
gem 'semantic_logger', '~> 4.13'

# config/initializers/logging.rb
require 'semantic_logger'

# Use JSON format for structured logs
SemanticLogger.configure do |config|
  config.log_level = :info
  config.add_appender(
    appender: :json_file,
    file_name: Rails.root.join('log', "#{Rails.env}.log"),
    formatter: SemanticLogger::Formatters::Json.new
  )
end

Rails.logger = SemanticLogger['Rails']

Now Rails logs come out as JSON. But the real power emerges when you include a request ID that ties all logs for a single HTTP request together:

ruby
# config/application.rb
config.log_tags = [
  :request_id,
  :user_id,
  ->(request) { request.remote_ip }
]

With request IDs in every log line, your observability tool (or your manual grep) can trace the entire lifecycle of a request—from controller entry through database queries, cache hits, external API calls, and the response sent back:

ruby
# Example log output (formatted)
{
  "timestamp": "2025-01-15T02:47:33.456Z",
  "level": "INFO",
  "message": "GET /api/orders",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_id": 12345,
  "remote_ip": "203.0.113.45",
  "status": 200,
  "duration_ms": 142
}
{
  "timestamp": "2025-01-15T02:47:33.521Z",
  "level": "DEBUG",
  "message": "SELECT * FROM orders WHERE user_id = ?",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "duration_ms": 8
}
{
  "timestamp": "2025-01-15T02:47:33.678Z",
  "level": "WARN",
  "message": "Redis cache miss",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "cache_key": "orders:12345"
}

When these logs land in a tool like CloudWatch, DataDog, or the ELK stack, you can search by request_id and reconstruct the entire request in seconds. This turns a vague complaint ("the API was slow yesterday") into actionable data.

Error Tracking — Sentry, Honeybadger, or AppSignal

Even with solid logging, you need a dedicated error tracking service. It captures exceptions, groups similar errors, and alerts you before a bug becomes a customer-visible outage. The three industry standard choices are Sentry (open source or hosted), Honeybadger (Rails-focused, paid), and AppSignal (APM + errors, paid).

Let's set up Sentry, the most widely adopted:

ruby
# Gemfile
gem 'sentry-rails'
gem 'sentry-sidekiq'  # if you use background jobs

# Bundle and generate config
bundle install
bundle exec rails generate sentry

# config/initializers/sentry.rb
# After bundle exec rails generate sentry, you'll have an initializer like:
Sentry.init do |config|
  config.dsn = ENV['SENTRY_DSN']  # provided by Sentry
  config.traces_sample_rate = 0.1  # sample 10% of requests for performance
  config.environment = Rails.env
  
  # Ignore noisy errors
  config.ignored_exceptions += ['ActionController::RoutingError']
end

With this config, any unhandled exception automatically sends a report to Sentry. You get a stack trace, the request context (params, headers, user), and the server state at the moment of crash. Sentry also groups similar errors so you see "this exact bug occurred 847 times this week" rather than 847 separate incidents.

But error tracking isn't just for uncaught exceptions. You can manually log errors for conditions that don't throw:

ruby
# In a job or controller
if order.total > credit_limit
  Sentry.capture_message(
    "Order exceeds credit limit",
    level: 'warning',
    extra: { order_id: order.id, user_id: order.user_id }
  )
end

# Or capture exceptions you do catch
begin
  payment_service.charge(order)
rescue PaymentGatewayError => e
  Sentry.capture_exception(e, extra: { order_id: order.id })
  # Handle gracefully
end

This approach lets you notify the team of business-logic failures, not just crashes. An order that fails due to an external API timeout deserves the same visibility as a nil pointer exception.

APM — Response Times and Slow Queries

Structured logs and error tracking tell you what went wrong. APM (Application Performance Monitoring) tells you why it's slow. Tools like AppSignal, New Relic, and DataDog continuously measure request latency, database query duration, and external API call time.

AppSignal is Rails-native and integrates cleanly:

ruby
# Gemfile
gem 'appsignal'

# config/appsignal.yml
production:
  push_api_key: <%= ENV['APPSIGNAL_PUSH_API_KEY'] %>
  name: "my-rails-app"
  environment: "production"
  
# Initialization happens automatically in Rails

Once running, AppSignal collects performance metrics with almost no overhead. You get dashboards showing:

  • Response time percentiles — not just average, but p50, p95, p99 so you catch tail latency.
  • Slow query detection — which SQL queries are taking >100ms, how often they run, on which endpoints.
  • External API duration — time spent waiting for third-party services.
  • Background job duration — which Sidekiq jobs are bottlenecks.

A real scenario: your order creation endpoint starts timing out at 8 PM every weekday. The endpoint itself is fine, but AppSignal shows the database is taking 12 seconds per query. You check the slow query log and discover a missing index on orders.created_at used by a nightly analytics report that runs at the same time. Index added, problem solved—all found within 15 minutes.

You can also instrument custom code:

ruby
# Mark an important operation
Appsignal.instrument("payment.charge", { order_id: order.id }) do
  stripe_charge = Stripe::Charge.create(
    amount: order.total_cents,
    currency: 'usd',
    customer: order.user.stripe_id
  )
end

# Or track a gauge (memory, queue size, etc.)
Appsignal.set_gauge("orders.pending", Order.where(status: 'pending').count)

This custom instrumentation surfaces business metrics alongside technical ones, helping you correlate application behavior with real outcomes.

Health Checks — /up Endpoint Built In

A health check is a simple HTTP endpoint that returns 200 if your app is working, 500 if it's not. Load balancers, Kubernetes, and monitoring tools use this to detect when an instance is broken and route traffic elsewhere.

Rails 7.1+ includes a built-in /up endpoint that checks a few basics: Rails is booted, the database is reachable. You don't need to do anything; it's there by default.

For custom health logic, implement a health check endpoint:

ruby
# config/routes.rb
get '/health', to: 'health#check'

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def check
    status = {
      status: 'ok',
      timestamp: Time.current.iso8601
    }

    # Check database
    begin
      ActiveRecord::Base.connection.execute('SELECT 1')
      status[:database] = 'ok'
    rescue => e
      status[:database] = 'error'
      status[:database_error] = e.message
    end

    # Check cache
    begin
      Rails.cache.write('health_check', 'ok', expires_in: 10.seconds)
      Rails.cache.read('health_check')
      status[:cache] = 'ok'
    rescue => e
      status[:cache] = 'error'
      status[:cache_error] = e.message
    end

    # Check background jobs (if using Sidekiq)
    begin
      redis = Sidekiq.redis { |conn| conn.ping }
      status[:sidekiq] = 'ok'
    rescue => e
      status[:sidekiq] = 'error'
      status[:sidekiq_error] = e.message
    end

    # Respond with 200 if all critical services are up, 503 otherwise
    critical_down = status[:database] == 'error' || status[:cache] == 'error'
    render json: status, status: critical_down ? 503 : 200
  end
end

Your infrastructure can now poll GET /health every 10 seconds. If it gets a 503, the instance is cordoned off, and new requests route to a healthy replica. This catches bugs, database connection pool exhaustion, and hung processes automatically.

Keep health checks lightweight—they run often and from many sources. Never do expensive operations (full data integrity checks, heavy queries) in a health endpoint. The goal is to detect "is the app alive" not "is the app perfect."

Common Mistakes

  • Logging secrets — Never log passwords, API keys, or tokens, even in dev. Use filters: config.filter_parameters += [:password, :token, :api_key].
  • Ignoring logs until it's a crisis — Set up alerts on error rates and latency percentiles now, not after a production incident. Sentry and AppSignal both have alert rules built in.
  • Sampling too aggressively — APM tools let you sample (e.g., trace 10% of requests). This reduces cost but can miss rare errors. For critical flows, increase sampling.
  • Unstructured error messages — "Something went wrong" is useless. Include context: Rails.logger.error("Payment failed", charge_id: charge.id, user_id: user.id, error_code: e.code).
  • Health checks that are too strict — If your health check fails because a third-party API is slow, you'll cascade into a complete outage. Health checks should verify local health, not external dependencies.

Key Takeaways

  • Structured JSON logs with request IDs turn chaos into a traceable timeline—correlate logs by request_id to understand the full flow of a transaction.
  • Error tracking services (Sentry, Honeybadger, AppSignal) group errors, alert automatically, and provide stack traces plus request context—deploy one within your first week in production.
  • APM reveals performance bottlenecks: slow queries, external API lag, and code that's faster on your laptop than under load—use it to find the 1% of code causing 50% of latency.
  • A simple health check endpoint lets infrastructure know if your app is alive; pair it with a liveness probe in Kubernetes or a health check in your load balancer to enable self-healing.
  • Implement all four layers (logs, errors, APM, health checks) together—they answer different questions and solve different problems, but they work best as a cohesive observability stack.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles