OopsieExceptions
Lightweight exception capture and webhook delivery for Rails. Like Sentry/Rollbar, but self-hosted and webhook-driven.
Captures unhandled exceptions from web requests and background jobs, enriches them with request/user/server context, and POSTs structured JSON payloads to one or more webhook endpoints.
Installation
Add to your Gemfile:
gem "oopsie_exceptions"
Then run the install generator:
bin/rails generate oopsie_exceptions:install
That creates a single file: config/initializers/oopsie_exceptions.rb. No code lands in app/ — the Rack middleware, Rails error subscriber, ActiveJob hook, and webhook delivery job all live inside the gem.
Configuration
The most common pattern is to configure a different webhook per environment, with the URL and auth token coming from environment variables. Here's a real-world initializer:
# config/initializers/oopsie_exceptions.rb
OopsieExceptions.configure do |config|
if Rails.env.development?
config.add_webhook(
"http://localhost:3099/api/v1/exceptions",
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_DEV_TOKEN']}" },
name: "oopsie-local"
)
config.async_delivery = false
end
if Rails.env.production?
config.add_webhook(
"https://oopsie.example.com/api/v1/exceptions",
headers: { "Authorization" => "Bearer #{ENV['OOPSIE_PROD_TOKEN']}" },
name: "oopsie-prod"
)
config.async_delivery = true
end
config.app_name = "MyApp"
config.environment = Rails.env
config.filter_parameters = %w[password password_confirmation secret token api_key]
config.enabled = Rails.env.development? || Rails.env.production?
# Attach the current user and controller#action to every exception.
# `env` is the Rack env — runs once per request.
config.context_builder = ->(env) {
warden = env["warden"]
user = warden&.user
params = env["action_dispatch.request.path_parameters"]
{
user: user ? { id: user.id, email: user.email } : nil,
action: params ? "#{params[:controller]}##{params[:action]}" : nil
}.compact
}
end
Configuration options
| Option | Default | Description |
|---|---|---|
add_webhook(url, headers:, name:) |
— | Register a webhook. Call multiple times for fan-out. |
app_name |
Rails app module name | Identifier sent in every payload. |
environment |
Rails.env |
Environment label sent in every payload. |
enabled |
true |
Master kill switch. Set false in test/dev. |
async_delivery |
true |
Deliver via ActiveJob. Set false to POST inline. |
filter_parameters |
password, secret, token, api_key, ... |
Param names to redact in payloads. |
filter_headers |
Authorization, Cookie, Set-Cookie |
Headers to strip from payloads. |
capture_request_body |
false |
Include first 10KB of JSON request bodies. |
ignored_exceptions |
404s, routing errors, etc. | Exception class names to silently drop. |
ignore_exception(*names) |
— | Append to ignored_exceptions. |
context_builder |
nil |
->(env) { Hash } — extra context per request. |
before_notify |
nil |
->(payload) { payload } — mutate or drop payloads. |
timeout / open_timeout |
10 / 5 |
HTTP timeouts (seconds). |
What gets captured automatically
- Web requests — A Rack middleware inserted after
ActionDispatch::DebugExceptionscatches unhandled exceptions and 5xx responses. - Background jobs —
ActiveJob::Base.executeis wrapped at the class level (the same approach Appsignal uses), so every queue adapter (Solid Queue, Sidekiq, Async, etc.) is covered. CatchesDeserializationErrorand missing job classes too. Rails.errorreports — Subscribed viaRails.error.subscribe, so anything reported through the framework error reporter (Rails.error.report/Rails.error.handle) flows through.
Each captured exception is enriched with request URL/method/IP/params/headers, the current user (via context_builder or set_context), server hostname/PID/Ruby version, and a UTC timestamp.
Request context capture is best-effort. If Rack rejects a malformed or truncated request body while OopsieExceptions is collecting params, the exception report is still allowed to continue with request.params omitted. Payloads include omission metadata such as params_omitted / params_error_class or body_omitted / body_error_class when request enrichment fails.
Adding context per-request
If you don't want to use context_builder, you can set context from a controller:
class ApplicationController < ActionController::Base
before_action :set_oopsie_context
private
def set_oopsie_context
OopsieExceptions.set_context(
user: current_user ? { id: current_user.id, email: current_user.email } : nil,
action: "#{self.class.name}##{action_name}"
)
end
end
context_builder is preferred — it runs in the middleware before the request hits any controller, so it captures context even on errors raised before before_action runs.
Manual reporting
begin
risky_operation
rescue => e
OopsieExceptions.report(e, context: { order_id: 123 }, handled: true)
end
Or scope context to a block:
OopsieExceptions.with_context(tenant_id: 42) do
do_work
end
Multiple webhooks
config.add_webhook "https://your-api.com/exceptions",
headers: { "Authorization" => "Bearer #{ENV['PRIMARY_TOKEN']}" }
config.add_webhook "https://hooks.slack.com/services/..."
config.add_webhook "https://discord.com/api/webhooks/..."
Each endpoint receives every exception. Failures on one webhook don't affect the others.
Payload format
Each webhook receives a JSON POST with:
- exception — class, message, backtrace, cause chain
- request — URL, method, IP, params, headers, user agent
- context —
user,action,job, plus anything you set viacontext_builder/set_context - server — hostname, PID, Ruby/Rails versions
- app —
app_name,environment - timestamp — UTC ISO8601
- handled —
trueforOopsieExceptions.report(..., handled: true),falsefor unhandled
Filtering noise
By default these are dropped:
ActionController::RoutingError
ActionController::UnknownFormat
ActionController::BadRequest
ActionDispatch::Http::MimeNegotiation::InvalidType
AbstractController::ActionNotFound
ActiveRecord::RecordNotFound
ActionController::UnknownHttpMethod
Add your own:
config.ignore_exception "MyApp::IgnorableError", "ThirdParty::Timeout"
Sensitive params (password, token, secret, api_key) and headers (Authorization, Cookie, Set-Cookie) are redacted from payloads automatically.
Mutating or dropping payloads
config.before_notify = ->(payload) {
payload[:context][:deploy_sha] = ENV["GIT_SHA"]
return nil if payload[:exception][:message].include?("known noise")
payload
}
Return nil to drop the notification entirely.
Upgrading from earlier versions
Malformed request body handling
Apps that added a local guard around OopsieExceptions request context collection for malformed multipart or truncated request bodies can remove that workaround after upgrading to a gem release that includes best-effort request params/body capture. Until the fixed gem version is deployed in the app, keep the app-local guard in place.
This gem change only prevents OopsieExceptions from turning context enrichment into a new request failure. Production exception groups for the affected app should still be resolved from that app's deploy verification, not from the gem release alone.
Legacy delivery job cleanup
If you're coming from an older version of the gem that generated app/jobs/oopsie_exceptions/delivery_job.rb in your app, delete that file. The gem now ships its own OopsieExceptions::WebhookJob and the host-app file is obsolete.
rm app/jobs/oopsie_exceptions/delivery_job.rb
rmdir app/jobs/oopsie_exceptions # if empty
Or just re-run the install generator and it will detect and remove the legacy file for you:
bin/rails generate oopsie_exceptions:install
No initializer changes are required.
License
MIT — see LICENSE.txt.