Rails error monitoring playbook.
Rails gives you a lot out of the box, and that is part of the problem. The framework handles so many concerns automatically — middleware stacks, job scheduling, mailer delivery, WebSocket channels — that errors can appear anywhere in the lifecycle. This playbook covers where Rails errors come from, how sentry-rails captures them, where it falls short, and how to point the whole setup at urgentry with one line.
20 seconds. Add sentry-ruby and sentry-rails to your Gemfile and run bundle install. Create config/initializers/sentry.rb with your DSN, environment, release, and breadcrumbs_logger. sentry-rails inserts itself into the Rack middleware stack and captures controller exceptions, ActiveJob failures, and ActionCable errors automatically. Swap the DSN to a urgentry DSN and nothing else changes.
60 seconds. The gaps are in background jobs and mailers. ActiveJob retry semantics mean sentry-rails only reports a job as an error when it exhausts its retries and dies — not on each attempt. Sidekiq adds its own retry layer on top of ActiveJob’s, which complicates when an exception becomes an event. Action Mailer async delivery via ActionMailer::DeliveryJob runs as a background job, so SMTP failures surface through the job pipeline, not the mailer itself. Know those three surfaces before you decide your coverage is complete.
Context isolation is the non-obvious requirement. A Sidekiq worker process can run thousands of jobs over its lifetime. Tags and user context set inside one job’s perform method persist on the Sentry hub and attach to subsequent events unless you use Sentry.with_scope. This is not a sentry-rails bug — it is the hub model — but it will produce confusing issues in production if you ignore it.
Where Rails errors come from
A Rails application is a stack of middleware, a router, controllers, models, background jobs, mailers, and (in modern apps) Hotwire components. Errors can originate in any layer, and each layer has different propagation behavior.
Controllers. Unhandled exceptions in controller actions propagate up through the middleware stack. ActionDispatch catches them and renders an error page in development. In production, the exception keeps propagating until either your application rescues it or it reaches the Rack server. sentry-rails places its middleware high in the stack so it sees exceptions before anything else swallows them.
ActiveRecord. Database errors — constraint violations, connection timeouts, missing records — raise subclasses of ActiveRecord::ActiveRecordError. Most surface in controllers when a model operation fails. A few, like ActiveRecord::ConnectionNotEstablished, can raise during application boot or during a request when the connection pool is exhausted.
ActiveJob. Jobs run outside the request cycle. A job that raises an exception triggers ActiveJob’s retry mechanism. The exception is not sent to your error tracker until the job exceeds its retry limit. Intermediate failures are lost unless you add a rescue_from handler or call Sentry.capture_exception explicitly.
Action Mailer. Synchronous delivery (deliver_now) raises in the calling thread — usually a controller or job. Asynchronous delivery (deliver_later) enqueues an ActionMailer::MailDeliveryJob, which means SMTP failures surface as job failures, not mailer errors. The distinction matters for where you instrument.
View rendering. Template errors raise ActionView::Template::Error, which wraps the underlying cause. sentry-rails captures the wrapped exception and extracts the original. The template name appears in the event context.
Middleware chain. Third-party middleware, Warden authentication hooks, and Rack::Attack can all raise before a request reaches a controller. Exceptions at this layer have no controller context, so the event will show a URL and HTTP method but no params or action name.
Install sentry-ruby and sentry-rails
The Sentry Ruby SDK ships as two gems. sentry-ruby is the core library for Ruby applications. sentry-rails adds Rails-specific integrations: the Rack middleware, ActiveJob hooks, ActionCable integration, and breadcrumb logging from Active Support notifications.
# Gemfile
gem "sentry-ruby"
gem "sentry-rails"
bundle install
Version compatibility: sentry-rails 5.x supports Rails 6.0 through 8.x. As of May 2026, the current release is 5.22.x. Rails 7.x and 8.x both work without additional configuration. Rails 6.1 users should pin to sentry-rails ~> 5.x and confirm that the activesupport version matches — Rails 6.0 had a different notification API that sentry-rails 5.x handles but does not optimize for.
If you use Sidekiq, add the integration gem:
# Gemfile
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq"
sentry-sidekiq adds a Sidekiq server middleware that clones the Sentry hub per job, captures exceptions from the Sidekiq retry pipeline (not just the ActiveJob layer), and handles context cleanup between jobs. Without it, Sidekiq errors go through the ActiveJob adapter and may miss Sidekiq-specific retry metadata.
Configure the initializer
Create config/initializers/sentry.rb. Rails loads this file on boot, before the application accepts requests.
# config/initializers/sentry.rb
Sentry.init do |config|
config.dsn = ENV["SENTRY_DSN"]
# Breadcrumb loggers: :active_support_logger captures Rails instrumentation
# events (SQL queries, cache reads, controller actions). :http_logger captures
# outbound HTTP calls made via Net::HTTP.
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
# "production", "staging", "review-app-pr-123"
config.environment = ENV.fetch("RAILS_ENV", "development")
# Tag events with the deployed version. Use a git SHA or a semver string.
# Populate via an environment variable set in your deploy pipeline.
config.release = ENV["APP_VERSION"]
# Capture 10% of performance transactions in production.
# Set to 1.0 during initial setup so you can confirm traces are arriving.
config.traces_sample_rate = ENV.fetch("RAILS_ENV", "development") == "production" ? 0.1 : 1.0
# Send personally identifiable information in the request context.
# Set to false if your compliance requirements prohibit it.
config.send_default_pii = false
# Filter sensitive parameters from request payloads before sending.
# Rails already filters these in logs; mirror the list here.
config.before_send = lambda do |event, hint|
event
end
end
A few notes on these options. breadcrumbs_logger: [:active_support_logger] subscribes to Active Support notifications and converts them to breadcrumbs. Every SQL query, every cache read, every controller action renders as a breadcrumb on the event. This is high-value context that most error trackers do not have. The :http_logger option requires Ruby’s Net::HTTP; if you use Faraday or HTTParty, those wrap Net::HTTP and are also covered.
Set config.release from an environment variable. Rails does not expose a built-in version string, so the most common patterns are a git SHA (git rev-parse --short HEAD run at deploy time and exported as APP_VERSION) or a semantic version from a VERSION file. The value appears in the urgentry UI and enables release-based filtering.
send_default_pii controls whether the SDK includes the full request body, cookies, and user IP in events. The default is false. If you enable it, add config.request_bodies filtering to exclude fields that should not leave your infrastructure.
What sentry-rails captures by default
After installing the initializer, sentry-rails captures the following without additional code.
Controller exceptions. Any exception that propagates out of a controller action triggers the Rack middleware. The event includes the request URL, HTTP method, headers (filtered), parameters (filtered), the current user if you configure Sentry.set_user in an around_action, and the full exception backtrace.
ActiveJob failures. sentry-rails installs an ActiveJob callback that fires when a job exceeds its retry limit. The event includes the job class name, queue name, and the arguments passed to perform. Note that large argument objects may be truncated; serialize only what you need for debugging.
ActionCable. Exceptions in channel receive and subscribed methods are captured. The WebSocket connection identifier is included as context if you set it.
The Rack middleware insertion. sentry-rails inserts Sentry::Rails::CaptureExceptions into the middleware stack automatically. You can verify this with bin/rails middleware. The middleware appears near the top of the stack, before ActionDispatch::ShowExceptions, so it captures exceptions before Rails replaces them with an error response.
Deprecation warnings. Rails emits deprecation warnings via Active Support notifications. sentry-rails can convert these to events by adding :deprecation_warning to the breadcrumbs_logger list. By default, deprecations appear as breadcrumbs, not issues. If you want them as issues — useful when you are tracking Rails upgrade completeness — add a deprecation handler in the initializer:
ActiveSupport::Deprecation.behavior = [:raise, :log]
This causes deprecations to raise exceptions, which sentry-rails then captures as events. Use this only in staging or CI; raising on every deprecation in production will generate a high event volume during any Rails upgrade.
ActiveJob and Sidekiq specifics
The retry semantics are the part that most teams get wrong the first time. Understanding them prevents both undercapturing (missing errors) and overcapturing (duplicate events per retry).
How retries interact with error capture
ActiveJob retries work as follows: when perform raises, ActiveJob catches the exception, checks the retry count against retry_on configuration, and either re-enqueues the job or marks it dead. sentry-rails captures an event only at the dead stage — when the job will not be retried again.
This is the correct default behavior. Capturing on every retry would flood your error tracker with duplicate events for transient failures (a database connection blip, a rate-limited API call). The final death event is the signal that a human needs to act.
If you want to observe retry attempts — for example, to alert on a job that is retrying many times before dying — add a rescue_from handler that captures with additional context:
class ApplicationJob < ActiveJob::Base
rescue_from(StandardError) do |exception|
Sentry.with_scope do |scope|
scope.set_tags(
job_class: self.class.name,
executions: executions,
queue: queue_name
)
scope.set_extra(:arguments, arguments.inspect)
Sentry.capture_exception(exception)
end
raise exception # re-raise so ActiveJob still handles retries
end
end
The raise exception at the end is required. If you rescue without re-raising, ActiveJob considers the job successful and will not retry it.
Sidekiq integration
With sentry-sidekiq installed, Sidekiq server middleware clones the Sentry hub for each job and cleans up after the job completes. This is the correct solution to context leaking. The middleware also adds Sidekiq-specific context to events: the job’s JID (Job ID), the retry count from Sidekiq’s own tracking, and the queue name.
One important distinction: Sidekiq has its own retry mechanism independent of ActiveJob. When you use Sidekiq as an ActiveJob adapter, you get both retry layers. A Sidekiq job configured with sidekiq_options retry: 5 will retry up to 5 times via Sidekiq before ActiveJob sees it as dead. Sentry captures only at final death from Sidekiq’s perspective — which means the event arrives after Sidekiq’s retries are exhausted, not after ActiveJob’s.
Unique fingerprinting for background jobs
Jobs that run in parallel can produce the same exception from different job instances simultaneously. sentry-rails generates a fingerprint from the exception class and the backtrace. If two job instances fail with the same exception at the same stack location, they generate the same fingerprint and group into one issue. This is the correct behavior for most cases.
If your jobs process different account IDs and you want separate issues per account, override the fingerprint with a scope before capturing:
Sentry.with_scope do |scope|
scope.set_fingerprint(["", account_id.to_s])
Sentry.capture_exception(exception)
end
The token preserves the automatic fingerprint from the exception class and backtrace and appends the account ID. This creates one issue per account per error type rather than collapsing all accounts into one issue.
Capturing handled exceptions and adding context
Not every error is unhandled. For exceptions you rescue explicitly, call Sentry.capture_exception to send them to your tracker.
class OrdersController < ApplicationController
def create
order = Order.new(order_params)
if order.save
redirect_to order, notice: "Order created."
else
render :new, status: :unprocessable_entity
end
rescue PaymentGateway::ChargeError => e
Sentry.with_scope do |scope|
scope.set_user(id: current_user.id, email: current_user.email)
scope.set_tags(payment_provider: order.payment_method)
scope.set_extra(:order_params, order_params.to_h)
Sentry.capture_exception(e)
end
flash[:error] = "Payment failed. Please try again."
render :new, status: :unprocessable_entity
end
end
Sentry.with_scope creates an isolated scope for the block. Tags and extra context set inside the block apply only to the captures within that block and are discarded when the block exits. This prevents the payment provider tag from appearing on unrelated errors captured later in the same request.
Setting user context
Set user context once per request in an around_action on your ApplicationController:
class ApplicationController < ActionController::Base
around_action :set_sentry_context
private
def set_sentry_context
Sentry.with_scope do |scope|
scope.set_user(
id: current_user&.id,
email: current_user&.email,
username: current_user&.username
) if current_user
yield
end
end
end
The around_action with with_scope keeps the user context scoped to a single request. Any unhandled exception captured during the request will include the user. The scope is discarded when the request completes, so the next request starts clean.
Setting tags
Tags are indexed fields in urgentry. Use them for values you will filter by: tenant IDs, feature flag states, request regions.
Sentry.with_scope do |scope|
scope.set_tags(
tenant_id: current_account.id,
plan: current_account.plan_name,
region: ENV["DEPLOY_REGION"]
)
Sentry.capture_exception(e)
end
Action Mailer failures
Synchronous delivery with deliver_now raises directly in the calling thread. If a controller calls UserMailer.welcome(user).deliver_now and the SMTP server is unreachable, the exception propagates out of the controller action and sentry-rails captures it with full request context. No extra instrumentation required.
Asynchronous delivery with deliver_later enqueues an ActionMailer::MailDeliveryJob. SMTP failures happen when the job runs, not when the controller calls deliver_later. The exception reaches sentry-rails through the ActiveJob error handler described above — only after retries are exhausted.
If you want to capture SMTP failures on every attempt (not just after exhausting retries), add a retry handler in your mailer base class:
class ApplicationMailer < ActionMailer::Base
rescue_from Net::SMTPError do |exception|
Sentry.with_scope do |scope|
scope.set_tags(mailer_class: self.class.name)
scope.set_extra(:mailer_action, action_name)
Sentry.capture_exception(exception)
end
raise exception
end
end
This pattern captures each SMTP failure as it occurs while still re-raising so ActiveJob can retry the job. Set scope.set_tags(mailer_class: ...) so you can filter by mailer in urgentry without scanning event payloads.
One edge case: if you use a third-party mailer gem like Postmark or SendGrid that wraps Net::HTTP rather than SMTP, failures raise as HTTP errors (Faraday::Error, Net::HTTP::Persistent::Error) rather than Net::SMTPError. Adjust the rescue_from class to match what your delivery backend raises.
Hotwire / Turbo Streams and Stimulus error context
Hotwire is server-driven HTML delivered over WebSocket channels or HTTP streams. The browser-visible behavior comes from server-rendered partials. When the server raises an exception while rendering a Turbo Stream response, the error originates on the Rails side and surfaces in the same places as any other controller or channel exception.
A Turbo Stream action rendered by a controller action will capture the exception through the standard Rack middleware. An ActionCable broadcast that fails while rendering a partial raises in the channel, which sentry-rails captures through the ActionCable integration.
The gap is on the client side. Stimulus controllers can throw JavaScript exceptions. These are client-side events that sentry-rails never sees. To track them, add @sentry/browser to your JavaScript bundle and initialize it with the same DSN:
// app/javascript/application.js
import * as Sentry from "@sentry/browser";
Sentry.init({
dsn: document.querySelector("meta[name='sentry-dsn']")?.content,
environment: document.querySelector("meta[name='app-env']")?.content,
});
// Capture Stimulus controller errors
application.handleError = (error, message, detail) => {
Sentry.captureException(error, {
extra: { message, detail },
});
};
Inject the DSN into the page through a meta tag set in your layout, not hardcoded in JavaScript. Keep the server-side DSN (used by sentry-rails) and the client-side DSN in the same urgentry project so server and browser errors appear together.
<%# app/views/layouts/application.html.erb %>
<meta name="sentry-dsn" content="<%= ENV['SENTRY_DSN'] %>">
<meta name="app-env" content="<%= Rails.env %>">
For Turbo Drive navigation errors — where a server response returns an error status during a Turbo visit — add an event listener on turbo:fetch-request-error:
document.addEventListener("turbo:fetch-request-error", (event) => {
Sentry.captureMessage(`Turbo fetch error: ${event.detail.response?.status}`, {
level: "warning",
extra: {
url: event.detail.url?.toString(),
status: event.detail.response?.status,
},
});
});
Point the DSN at urgentry
This is a one-line change. In urgentry, create a project and copy its DSN. It will have the format:
https://<public_key>@your.urgentry.host/<project_id>
Set the environment variable:
SENTRY_DSN=https://<public_key>@your.urgentry.host/<project_id>
The initializer reads ENV["SENTRY_DSN"], so nothing in your application code changes. The sentry-ruby SDK sends the same envelope payload it always sent. urgentry implements the Sentry envelope ingest protocol across all 218 documented operations, so every feature of sentry-rails — breadcrumbs, performance transactions, releases, user context, custom tags — works against urgentry exactly as it would against Sentry.
What stays the same: the SDK configuration, all breadcrumb logging, all context-setting code, the Rack middleware integration, job and mailer instrumentation, the DSN format, the envelope wire format.
What changes operationally: events land on your infrastructure instead of Sentry’s. urgentry runs as a single Go binary at around 52 MB resident memory at 400 events per second. SQLite is the default store; PostgreSQL is available for higher write throughput. A Rails application generating tens of thousands of events per day runs without issue on a small VM. You own the data, the retention policy, and the access control.
If you run urgentry behind a reverse proxy, confirm that the proxy passes the /api/<project_id>/envelope/ path through without rewriting. The sentry-ruby SDK sends all events to that endpoint. The legacy /api/<project_id>/store/ endpoint is also supported.
Three Rails gotchas
1. ActionDispatch::Response::RackBody and reading bodies twice
The sentry-rails Rack middleware reads request bodies to attach them to error events when send_default_pii is true and the request content type is JSON or form data. Rails’ Rack body is an ActionDispatch::Response::RackBody object that wraps an IO stream. Once you read a stream, subsequent reads return empty.
sentry-rails handles this by calling request.body.rewind after reading. If you have custom Rack middleware that also reads the request body without rewinding, or if you use a gem that does so, you may find that the request body arrives empty at the controller. The fix is to always call rewind after reading in any middleware that touches the body. If the body is a Rack::RewindableInput, rewind works. If it is a raw IO that does not support rewind (uncommon in Rails but possible with certain proxy setups), you need to buffer the body yourself before reading.
2. better_errors swallows exceptions in development
The better_errors gem replaces Rails’ default error page with an interactive debugging page. It does this by inserting itself into the middleware stack and rescuing exceptions before they propagate to sentry-rails. In development, this is correct behavior — better_errors is more useful than an error event in your tracker.
The risk is in staging environments that load development gems. If your staging Gemfile includes gem "better_errors", group: :development but your staging RAILS_ENV is set to "staging" rather than "development", better_errors will not load. But if staging uses RAILS_ENV=development (a common shortcut), better_errors intercepts exceptions and sentry-rails never sees them.
Use RAILS_ENV=staging or a custom environment for staging. Confirm that bin/rails middleware in staging shows Sentry::Rails::CaptureExceptions and does not show BetterErrors::Middleware.
3. Background job context leaking across jobs in the same worker
Sidekiq workers are long-lived processes. A single worker thread can run hundreds of thousands of jobs over its lifetime. The Sentry hub persists across jobs in the same thread unless sentry-sidekiq resets it between jobs.
sentry-sidekiq installs server middleware that calls Sentry.get_current_hub.new_from_top before each job runs and cleans up after. This should prevent leaking. But if you call Sentry.set_user, Sentry.set_tags, or Sentry.configure_scope outside of a with_scope block, you modify the hub’s persistent scope rather than a temporary one. That modification survives the sentry-sidekiq cleanup.
The rule: inside a job’s perform method, always use Sentry.with_scope for any context you set. Never call Sentry.set_user or Sentry.set_tags at the top level inside a job. Use Sentry.configure_scope only for process-level facts that should apply to all events (your release version, your deploy region) and set those in the initializer, not in job code.
FAQ
Does sentry-rails capture exceptions from controllers automatically?
Yes. sentry-rails inserts a Rack middleware into the stack that catches any exception propagating out of a controller action. It also hooks into ActionDispatch to attach the request context — params, headers, URL, environment — to the event before sending. You do not need to call Sentry.capture_exception inside each action for unhandled exceptions.
How does sentry-rails handle ActiveJob retries vs final failures?
sentry-rails captures an exception event only when a job fails beyond its retry limit and ActiveJob marks it as dead. Intermediate retries are breadcrumbs, not events. If you use sentry-sidekiq, the same rule applies through Sidekiq’s retry layer. You can override this behavior by calling Sentry.capture_exception inside a rescue_from handler in your job base class, which captures on every attempt.
Does urgentry accept the sentry-ruby SDK without code changes?
The only change is the DSN. Set SENTRY_DSN (or the dsn option in the initializer) to your urgentry project DSN. The SDK sends the same envelope payload; urgentry receives and stores it. No monkey-patching, no custom transport, no adapter needed.
Does better_errors affect production error capture?
No, as long as better_errors is not loaded in production. It is a development-only gem that rescues exceptions before they propagate to Rack. In production, exceptions propagate normally and sentry-rails captures them. The risk is staging environments that accidentally load development gems — always verify with bin/rails middleware in your staging environment.
How do I prevent ActiveJob context from leaking between jobs in a shared worker process?
Use Sentry.with_scope for all context you set inside a job’s perform method. with_scope creates an isolated scope that is discarded after the block exits. If you call Sentry.set_user or Sentry.set_tags at the top level of perform without with_scope, those values persist on the hub for the lifetime of the worker thread and attach to subsequent jobs.
Sources
- getsentry/sentry-ruby — the core Ruby SDK. Source for
Sentry.init,with_scope,capture_exception, and the hub API. - getsentry/sentry-ruby (sentry-rails) — the Rails integration. Source for Rack middleware insertion, ActiveJob callbacks, and ActionCable instrumentation.
- getsentry/sentry-ruby (sentry-sidekiq) — Sidekiq server middleware, hub isolation per job, and retry metadata.
- Rails Guides: Active Job Basics — retry semantics,
retry_on,discard_on, and the job lifecycle that determines when sentry-rails fires its callbacks. - Sidekiq Wiki — Sidekiq retry behavior, dead job queues, and middleware API used by
sentry-sidekiq. - FSL-1.1-Apache-2.0 license — urgentry’s source-available license terms.
- urgentry compatibility documentation — the 218 documented Sentry API operations confirmed compatible with sentry-ruby and sentry-rails.
Ready to wire up your Rails app?
urgentry runs as a single Go binary, accepts sentry-ruby and sentry-rails out of the box, and takes under ten minutes to start receiving events. Add two gems, write the initializer, set the DSN. That’s the full migration.