Rails Sentry: Production Error Tracking with Source Maps, Performance Monitoring, and PII Scrubbing
Rails Sentry catches production errors before customers complain. Configure sourcemaps, sample rates, performance traces, and PII scrubbing safely today.
A fintech founder called me on a Friday evening to ask why a customer had emailed about a checkout error that his team had never seen. The error had been live for eleven days. Sentry was technically installed, the gem was in the Gemfile, the DSN was set, and the dashboard was open in someone’s tab — but every exception from the storefront controller was being swallowed by a rescue_from StandardError, with: :render_500 that returned a polite 500 page and then quietly logged the exception to STDOUT. Sentry never saw it. Rails Sentry is one of those tools that looks installed when it is not actually catching anything, and the gap between “we have Sentry” and “Sentry is doing its job” is bigger than most teams realise.
After nineteen years of Rails I have configured Sentry on hundreds of apps, and I have walked into ten times that many where the configuration was wrong in some quietly expensive way. This post is the production setup I now ship by default — the gem, the initializer, source maps for JavaScript, performance traces that do not bankrupt you, and PII scrubbing that survives a security audit.
What Rails Sentry Actually Does
Rails Sentry is two products in one bundle. The first is exception capture: when your app raises an unhandled error, the sentry-ruby and sentry-rails gems serialize the exception, the request, the user, and a stack trace, and ship the payload to sentry.io (or your self-hosted instance) over HTTPS. The second is performance monitoring: a configurable percentage of requests get a distributed trace with timings for controller, view, ActiveRecord queries, and outgoing HTTP, all hung off the same incident view as the exceptions.
The trap is that “we installed Sentry” usually means the first product is half-on and the second is invisible. Most teams hit one of three failure modes. They rescue exceptions in a base controller and never re-raise to Sentry. They set traces_sample_rate to 1.0 and either burn through their quota in eight days or ignore the resulting noise. They leave default data scrubbing in place and ship customer emails, session cookies, and Stripe tokens to a US-hosted SaaS without ever thinking about it. Each of those is a five-minute fix once you know it exists.
Setting Up Rails Sentry The Right Way
Start with the gems and an initializer. Do not copy the README straight into your config/initializers directory — the default config is fine for a demo and wrong for production.
# Gemfile
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq" # only if you use Sidekiq
gem "stackprof", require: false # for profiling — optional
# config/initializers/sentry.rb
Sentry.init do |config|
config.dsn = ENV.fetch("SENTRY_DSN", nil)
config.breadcrumbs_logger = %i[active_support_logger http_logger]
config.enabled_environments = %w[production staging]
config.release = ENV.fetch("HEROKU_SLUG_COMMIT", `git rev-parse HEAD`.strip)
config.environment = Rails.env
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_RATE", "0.05").to_f
config.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_RATE", "0.05").to_f
config.send_default_pii = false
config.before_send = ->(event, _hint) { Sentry::Scrubber.call(event) }
config.excluded_exceptions += %w[
ActionController::RoutingError
ActiveRecord::RecordNotFound
ActionController::InvalidAuthenticityToken
]
end
A few non-obvious choices in there. The release value is what lets Sentry group errors by deploy and tell you “this regression appeared in abc1234”. Without it, every error looks like it has always existed. send_default_pii is false by default in modern sentry-ruby but I set it explicitly so it shows up in code review — and because some older configs override it. excluded_exceptions removes the bulk noise that is not actionable: a RecordNotFound from a bot scraping deleted URLs is not a bug, it is the internet.
The bit that catches teams out is enabled_environments. Sentry will silently ship nothing from development if this is misconfigured, which sounds good until you realise you have been “testing” Sentry in development for three weeks and have no idea whether the integration works in production. The fix is to deploy a deliberate test exception to staging, confirm it lands, then remove the test.
Rescue Patterns That Do Not Hide Errors
Rails Sentry hooks into ActionDispatch::DebugExceptions, which means anything you swallow before the request reaches that middleware is invisible. The most common offender is a defensive base controller.
# Wrong — Sentry never sees these.
class ApplicationController < ActionController::Base
rescue_from StandardError do |error|
Rails.logger.error(error)
render "errors/500", status: :internal_server_error
end
end
The fix is to keep your custom error page and also re-emit the exception explicitly.
# Right — Sentry gets the exception, user gets the friendly page.
class ApplicationController < ActionController::Base
rescue_from StandardError do |error|
Sentry.capture_exception(error)
render "errors/500", status: :internal_server_error
end
end
For exceptions you genuinely want to track but not show as red alerts — for example a third party API that flaps and you handle gracefully — use capture_exception with a tag so you can filter the noise out of the main inbox.
Sentry.with_scope do |scope|
scope.set_tag(:expected, "true")
scope.set_level(:warning)
Sentry.capture_exception(error)
end
A similar trap exists in background jobs. If you wrap a job body in a begin/rescue and only log, Sentry never sees the failure. We have a longer post on Rails Active Job retries with exponential backoff that covers when to re-raise versus when to swallow — the short version is, if you swallow, capture explicitly.
Source Maps for JavaScript Errors
This is the single most ignored part of a Rails Sentry setup, and the one that pays back the fastest. Without source maps, a JavaScript exception in your Sentry dashboard looks like TypeError at app-a1b2c3.js:1:84210, which is useless. With source maps, it looks like TypeError at Checkout#submit (checkout_controller.js:42), which is a ticket you can close.
For a Rails 8 app on importmaps the story is simple — you do not need source maps because your JS is not minified. For any app on esbuild, Bun, or Vite (most modern Rails apps with real frontend), upload source maps to Sentry as part of your build.
# .github/workflows/deploy.yml — relevant excerpt
- name: Build assets
run: bin/rails assets:precompile
- name: Upload source maps to Sentry
env:
SENTRY_AUTH_TOKEN: $
SENTRY_ORG: ttb-software
SENTRY_PROJECT: storefront-web
SENTRY_RELEASE: $
run: |
npx @sentry/cli releases new "$SENTRY_RELEASE"
npx @sentry/cli releases files "$SENTRY_RELEASE" \
upload-sourcemaps ./app/assets/builds \
--url-prefix "~/assets" --rewrite
npx @sentry/cli releases finalize "$SENTRY_RELEASE"
The --url-prefix "~/assets" is the Rails-specific bit. Sourcemaps are matched by URL, and Rails serves compiled JS from /assets/... with a digest. The tilde tells Sentry “any host, this path” so the matching works across staging and production. Get this wrong and your maps upload but never apply, which looks identical to not uploading them at all.
Critically, do not ship source maps to the public CDN. Set your asset pipeline to write .map files locally during the build, upload them to Sentry, and then rm them before rsync to S3. A leaked source map is not a vulnerability on its own, but it is free reconnaissance for anyone reverse engineering your frontend.
Performance Monitoring Without Bankrupting Yourself
traces_sample_rate = 1.0 is the line in the README that has cost more startups more money than any other. At 1.0, Sentry traces every request. On a modest app at 200 RPS, that is 17 million traces a day, which on the Team plan blows through your monthly quota in two days. Worse, the dashboard becomes useless because every endpoint blends into noise.
The sane setup is a low default rate with a dynamic sampler that boosts the rate for slow or error-prone endpoints.
config.traces_sampler = lambda do |sampling_context|
transaction_context = sampling_context[:transaction_context]
op = transaction_context[:op]
name = transaction_context[:name]
case
when op == "http.server" && name&.include?("/healthz")
0.0
when op == "http.server" && name&.include?("/checkout")
0.5
when op == "queue.sidekiq"
0.02
else
0.05
end
end
That gives you 5 percent baseline coverage, half-rate on the high-value checkout flow, almost nothing on background jobs (where the volume is huge and the value per trace is low), and zero on health checks. A startup at 200 RPS on this config sees roughly 800,000 useful traces a month instead of 500 million useless ones.
For finding slow queries inside those traces, pair Sentry performance with one of the in-process profilers — we cover that workflow in rack-mini-profiler for Rails performance profiling and OpenTelemetry for Rails 8 observability. Sentry tells you which endpoints are slow; the profilers tell you why.
PII Scrubbing That Survives An Audit
Default Sentry scrubbing covers obvious passwords and credit card numbers, and misses almost everything else specific to your app. Email addresses, IP addresses, customer names, internal IDs that map to a customer in your support tool — all of those end up in stack frames and request bodies and breadcrumbs by default. If you operate in the EU, that is a GDPR finding waiting to happen.
The right pattern is an explicit before_send hook that runs every event through your own scrubber.
# app/services/sentry/scrubber.rb
class Sentry::Scrubber
PII_FIELDS = %w[
email phone address ssn iban
api_key access_token refresh_token authorization
stripe_token card_number cvv
].freeze
def self.call(event)
return nil if drop?(event)
scrub_request(event)
scrub_extra(event)
scrub_breadcrumbs(event)
event
end
def self.drop?(event)
return true if event.tags&.dig(:expected) == "true" && Rails.env.staging?
false
end
def self.scrub_request(event)
return unless event.request
event.request.cookies = nil
event.request.headers&.transform_values! { |v| v.is_a?(String) ? scrub_string(v) : v }
event.request.data = scrub_hash(event.request.data) if event.request.data.is_a?(Hash)
end
def self.scrub_extra(event)
event.extra = scrub_hash(event.extra) if event.extra.is_a?(Hash)
end
def self.scrub_breadcrumbs(event)
event.breadcrumbs&.each do |b|
b.data = scrub_hash(b.data) if b.data.is_a?(Hash)
end
end
def self.scrub_hash(hash)
hash.transform_values.with_index do |value, _|
value
end.tap do
hash.each do |k, v|
hash[k] = "[FILTERED]" if PII_FIELDS.include?(k.to_s.downcase)
hash[k] = scrub_string(v) if v.is_a?(String)
end
end
end
def self.scrub_string(value)
value
.gsub(/[\w._%+-]+@[\w.-]+\.[A-Z]{2,}/i, "[email]")
.gsub(/\b\d{16}\b/, "[card]")
end
end
Returning nil from before_send drops the event entirely. That is the mechanism you use to suppress known-noisy expected exceptions on staging without disabling them in production. The scrubber runs on every event, so keep it cheap — string allocations here are paid once per error, but if you ship 200 errors per minute during an incident, that adds up.
A second layer worth adding is Rails’ own filter parameters. Anything in config.filter_parameters gets replaced with [FILTERED] before it reaches the request body, which means Sentry never sees it in the first place. This is your defence in depth against a misconfigured scrubber.
# config/application.rb
config.filter_parameters += %i[
password password_confirmation
ssn iban card_number cvv stripe_token
access_token refresh_token api_key
email phone
]
Connecting Sentry To User Context
The single highest-leverage Sentry config is the user scope. Without it, every error is anonymous and you cannot tell whether five errors came from five users or one user retrying five times. With it, the dashboard tells you “this error affected 312 users, here is one of them.”
# app/controllers/application_controller.rb
before_action :set_sentry_context
def set_sentry_context
return unless current_user
Sentry.set_user(
id: current_user.id,
ip_address: request.remote_ip,
segment: current_user.plan
)
Sentry.set_tag(:account_id, current_user.account_id)
end
I deliberately do not send current_user.email here. The user ID and the plan are enough to find them in your admin tool; the email is PII you do not need to ship to a third party. If you operate under HIPAA, BAA constraints, or strict EU data residency, even the IP address is a problem — switch ip_address to nil and rely on the user ID alone.
Verifying The Setup Actually Works
Every Sentry installation deserves a smoke test in CI or staging. The cost is twenty lines of test code and the payoff is catching a misconfigured DSN before an actual incident.
# test/integration/sentry_integration_test.rb
require "test_helper"
class SentryIntegrationTest < ActionDispatch::IntegrationTest
test "captures unhandled controller exceptions" do
captured = nil
Sentry.get_current_client.transport.stub :send_event, ->(event) { captured = event } do
get "/__sentry_test__"
assert captured
assert_equal "RuntimeError", captured.exception.values.first.type
end
end
end
Pair that with a temporary /__sentry_test__ route in staging that raises an exception, hit it after deploy, and confirm the event lands in the dashboard tagged with the current release SHA. That is the only way to know your full pipeline works — gem, initializer, DSN, network, sourcemaps, scrubber, all of it.
FAQ
How much does Rails Sentry cost in production?
It depends entirely on your traces sample rate and your error volume. A typical Rails SaaS at 100 RPS with the dynamic sampler in this post and a few thousand errors per month fits comfortably inside the Team plan. The cost-blowups I have seen all come from traces_sample_rate = 1.0, which can multiply your bill by twenty or more.
Should I use Sentry, Honeybadger, or Rollbar for Rails?
All three do the core job well. Sentry has the strongest performance monitoring and best frontend integration. Honeybadger has the cleanest Ruby-only experience and excellent uptime monitoring. Rollbar’s grouping algorithm is the most aggressive, which helps or hurts depending on whether your team chases unique fingerprints. For most modern Rails apps with a JavaScript frontend, Sentry is the default I reach for.
Does Rails Sentry capture Sidekiq job failures automatically?
Only if you install sentry-sidekiq and require it in your Gemfile. The base sentry-rails gem hooks into web requests; the Sidekiq integration is separate and wraps the middleware stack. If your jobs fail and Sentry is silent, this is the first place to check.
How do I avoid sending GDPR-regulated data to Sentry?
Three layers. Set send_default_pii = false. Add a before_send scrubber like the one in this post. Add the field names to config.filter_parameters so Rails strips them before Sentry ever sees them. Defence in depth beats relying on any single layer.
Need help wiring up production-grade observability on your Rails app — error tracking, performance monitoring, audit-ready PII scrubbing? TTB Software specializes in Rails infrastructure and observability for product teams who need it to actually work the first time. We’ve been doing this for nineteen years.
Related Articles
Rails ActiveSupport::Notifications: Custom Instrumentation for Metrics and Production Monitoring
Rails ActiveSupport::Notifications powers custom instrumentation, metrics, and APM integration. Subscribe to events, ...
Rails Error Reporter: Structured Error Handling Without rescue Spaghetti
Rails Error Reporter centralizes production error handling with handle, record, and report. Stop scattering rescue bl...
Rails StackProf: Flamegraph CPU Profiling for Ruby Production Applications
Rails StackProf flamegraph profiling reveals exactly where your Ruby burns CPU — sampling middleware, three profiling...