Dev Bench for Ruby and Rails
Everything Sentry's Rails SDK captures by default, plus who was affected, the server log lines of a failing request, the browser sensor, trace propagation and the handled-failure header — so an app can turn Sentry off. Zero runtime dependencies; loads in plain Ruby, hooks itself into Rails when Rails is there.
Install (Rails)
bundle add devbench
bin/rails generate devbenchThen set one value in the app's environment — the DSN adt dsn create
printed for this environment:
DEVBENCH_DSN=https://<public>:<secret>@adt-ingest.onrender.comThat is the whole install: no browser key, no sidecar, no service names, no initializer, no middleware line. Check it:
bin/rails devbench:test
# Dev Bench: sending a test exception to https://adt-ingest.onrender.com (service "acme_shop")
# HTTP 200: accepted (fingerprint 3f9a2b0c4d5e)It prints the HTTP result, or says plainly what is wrong (DEVBENCH_DSN is not set, HTTP 401: the key in DEVBENCH_DSN was rejected, could not reach …) and exits non-zero. Outside Rake: Devbench.test!.
What the generator does, and nothing else (run it again and nothing changes):
- puts
<%= devbench_script_tag %>inapp/views/layouts/application.html.erbjust before</head>(.haml/.slim:= devbench_script_tagas the last line ofhead). Without that layout it prints what to add where. - if
config/initializers/content_security_policy.rbdefines a policy, addshttps://unpkg.comtoscript_src, and the ingest host andhttps://*.storage.supabase.co(evidence uploads) toconnect_src.
The browser tag
devbench_script_tag renders the browser sensor, version-pinned to this
gem (upgrading the gem upgrades the sensor), with only the public part
of DEVBENCH_DSN:
<script src="https://unpkg.com/devbench@0.6.0/dist/devbench.min.js"
data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>The secret never reaches a page. Without a DSN (or with a 0.5 single-key
DSN, or DEVBENCH_ENABLED=false) it renders nothing at all, so a
development or test environment without a DSN serves no tag. If the app
uses CSP nonces (content_security_policy_nonce_generator), the tag
carries the request's nonce.
CSP: if your policy is defined somewhere other than the standard initializer, add these yourself:
policy.script_src ..., "https://unpkg.com"
policy.connect_src ..., "https://adt-ingest.onrender.com", "https://*.storage.supabase.co"Configuration
All optional except the DSN.
| Variable | Default | |
|---|---|---|
DEVBENCH_DSN |
— (falls back to ADT_DSN) |
https://<public>:<secret>@<host>[:port] from adt dsn create. The server authenticates with the secret; the page gets the public part. A 0.5 https://<key>@<host> still works (no browser tag, see below). Plain http:// is accepted only for localhost. |
DEVBENCH_SERVICE |
your app's module, underscored (AcmeShop → acme_shop); app outside Rails; <that>-sidekiq in a Sidekiq process |
Groups this app's issues. Set it and it wins everywhere. |
DEVBENCH_RELEASE |
the first found of: a REVISION file in Rails.root (Capistrano writes one), HEROKU_SLUG_COMMIT, KAMAL_VERSION, GITHUB_SHA, GIT_SHA, SOURCE_VERSION, RENDER_GIT_COMMIT; else empty |
Which deploy an occurrence came from. c.release in code wins over all of these. |
DEVBENCH_ENABLED |
on | false turns everything off: no middleware, no hooks, no tag, nothing sent. |
Or from code, e.g. config/initializers/devbench.rb:
Devbench.configure do |c|
c.dsn = Rails.application.credentials.devbench_dsn
c.service = 'billing-api'
endA DSN that does not parse logs one [devbench] warning and reports
nothing; it never raises into the app. Neither key is ever printed.
What is captured automatically
| What | How | context |
|---|---|---|
| An exception escaping the app | Devbench::Middleware (inserted first in the stack by the Railtie) reports it, then re-raises the same object, backtrace intact |
request |
| An exception Rails rendered as a 500 page itself | Devbench::Middleware (env['action_dispatch.exception']), or Rails.error from Rails 7.1's executor |
request |
Rails.error.report / Rails.error.handle (Rails ≥ 7.0) |
the Railtie subscribes; handled is passed through |
rails_error |
| A failed ActiveJob | the Railtie subscribes to perform.active_job |
job |
A failed Sidekiq job (Sidekiq::Job / Sidekiq::Worker) |
Sidekiq server middleware; reports, then re-raises the same object, so retries are unchanged | job |
| A Sidekiq error outside a job (Redis/fetch errors, a death handler that raised) | Sidekiq error_handlers |
job |
Each report carries the class, the message (≤ 2,000 characters), where it
surfaced (DealsController#update, InvoiceJob#perform), up to 50 stack
frames with paths relative to Rails.root, the trace, and the user.
Once per exception. An error that passes several hooks (a job failing inside a request reaches three) is reported once.
Status codes are not failures. Not reported, as in Sentry:
ActionController::RoutingError,ActiveRecord::RecordNotFound,ActionController::InvalidAuthenticityToken,ActionController::UnknownFormat,ActionDispatch::Http::MimeNegotiation::InvalidType, and subclasses. Add your own by name:Devbench.ignore_exceptions << 'Pundit::NotAuthorizedError'Never in the way. No hook raises into the app; a failure while reporting is dropped, and the application's own exception always comes out unchanged.
What is sent, and when
Like the browser sensor, the gem does not send one request per error:
- Each report is fingerprinted and counted in the process (class, templated message and in-app frames; the same algorithm as every other Dev Bench sensor). Repeats cost a counter increment.
- Once a minute a background thread sends the counts — fingerprint, how many, first/last seen, and up to 20 affected users each — in one small request. Nothing is sent for a quiet minute (except a small poll while the process holds log lines, below), and what is left is sent when the process exits (waiting at most 2 seconds).
- Detail only on request. When Dev Bench sees a fingerprint for the first time it asks for evidence, and the gem uploads the stack and one example message — templated and scrubbed first (emails, numbers, quoted values, tokens, card numbers, credentials and two-word names are removed). Who was affected never goes into evidence.
- Never harms the app. No network I/O on the request thread; 5-second timeouts on the background thread, one retry, then the data is dropped; memory is bounded (512 distinct problems per minute, the rest only counted). Safe under forking servers (Puma, Unicorn, Sidekiq): each worker reports on its own.
Server log lines
Triage reads the server's log lines for the user action that failed. With
DEVBENCH_DSN set, the gem keeps them itself — no sidecar:
- What: every line written to
Rails.loggerandSidekiq.loggerwhile a request or job carries a trace (x-adt-tracefrom the browser sensor; a job inherits the trace of the request that enqueued it). Untraced lines are not kept. Rails 7.1+: a capturing logger joinsRails.logger's broadcast (it follows the app's level andsilence, and writes nothing); older Rails andSidekiq.logger: a tee after the logger's own write. What your logger writes is unchanged. - Bounded: per process, the newest 10,000 lines or 4 MiB, nothing older than 15 minutes, 4 KiB per line. A logging call is never blocked on I/O and never raises because of this.
- Sent only when asked: when Dev Bench asks for a trace, the flush thread sends that trace's lines from this process — the newest ≤ 200 lines / 64 KiB — redacted first with the same strict rules as evidence (quoted values, numbers, emails, cards, tokens, credentials, IPs and two-word names removed), plus any redaction rules Dev Bench has learned for your app. A process holding lines it was not asked about sends nothing but a small poll once a minute while it holds them.
- Forking servers: each Puma worker and Sidekiq process keeps and answers for its own lines; Dev Bench assembles them.
A Rack app without Rails: Devbench.capture_logs(logger).
Who was affected
Set it once per request, typically in ApplicationController:
before_action do
Devbench.set_user(email: current_user&.email, account: current_account&.id)
endemailis trimmed and lowercased;accountis your own tenant/customer id, sent as a string. Both optional, each truncated to 255 characters.- It is sent in its own field with the counts, never inside a message or evidence, and triage sees counts of affected users and accounts.
- The middleware clears it when the request ends (Sidekiq: when the job ends), so the next request on the same thread never inherits it.
Reporting what you caught
An exception you rescued but want seen, like Sentry's capture_exception:
rescue Faraday::Error => e
Devbench.capture_exception(e, symbol: 'Billing::Sync#run')
retry_later
endA failure you handled and turned into a normal response — the case this product exists for:
rescue ActiveRecord::RecordInvalid => e
Devbench.report_handled(e, symbol: 'CustomersController#update', reason: 'validation')
render json: { ok: true } # <- the user is about to be told this worked
endreport_handled also marks the response with x-adt-handled: <count>. The
browser already knows whether the user was shown an error; this is the one
fact it cannot know on its own. The header carries a count and nothing else,
and it is added even when reporting is down.
Sidekiq
Under Rails there is nothing to add: the Railtie installs the hooks at boot when Sidekiq is loaded. A Sidekiq process without Rails installs them itself, wherever it configures Sidekiq:
require 'sidekiq'
require 'devbench'
Devbench::SidekiqHooks.installThat adds a server middleware (first in the chain; reports, then re-raises,
so retries and the dead set are unchanged), identity scoped to each job, the
request's trace carried into jobs it enqueues ("adt_trace" in the job
payload), and an error handler for failures outside a job. Sidekiq's own
control flow (Sidekiq::Shutdown, JobRetry::Handled/Skip,
Job::Interrupted) is never reported. Tested against Sidekiq 7.3; not a
dependency of this gem.
Without Rails
require 'devbench'
use Devbench::Middleware # RackEverything else (set_user, capture_exception, report_handled, the
DSN) works the same.
Trace propagation
The correlation id the browser sent (x-adt-trace) is available as
Devbench::Current.trace for the duration of the request. Forward it on
outbound calls to other instrumented services:
Net::HTTP.post(uri, body, Devbench::HTTP.headers)Cross-origin
If your JavaScript is served from a different origin than this API, the
browser cannot read x-adt-handled unless the server lists it in
Access-Control-Expose-Headers. The middleware adds it and appends rather
than overwrites. A CORS layer that replaces the response header set will
strip it and detection will silently never fire; check the ordering.
Optional: the sidecar
The Dev Bench sidecar is a separate process that reads your app's log stream on the host and answers triage's requests for the log lines of one user action. It is optional: with a DSN the gem keeps those lines itself (above). It remains for setups that cannot load the gem.
Without a DSN, the gem reports to the sidecar's unix socket instead
($ADT_SIDECAR_SOCKET, default /tmp/adt-sidecar.sock), exactly as 0.4
did, and the sidecar counts and uploads on its behalf. It never uses
both: with a DSN set, a sidecar on the same host still reads logs, but the
gem does not also write to its socket, so nothing is counted twice.
Fingerprints are identical in both modes, so moving between them keeps
every issue.
Upgrading from 0.5
Nothing is required: a 0.5 single-key DEVBENCH_DSN keeps reporting
exactly as before. To get server log lines and the browser tag from the
same value, mint a pair and replace the DSN:
adt dsn create --tenant <tenant> --environment production
# https://<public>:<secret>@adt-ingest.onrender.comthen run bin/rails generate devbench for the tag and CSP. A page that
loaded the browser sensor with its own DSN can drop that and use the tag.
One default changed: a Sidekiq process without DEVBENCH_SERVICE now
reports as <app>-sidekiq (it was <app>), so job failures are grouped
apart from web failures — and an existing job issue reappears once under
the new name. Set DEVBENCH_SERVICE in the Sidekiq process to keep the
old name.
Upgrading from adt (0.4)
- Change the Gemfile line to
gem 'devbench'.require 'adt'and everyADT.set_user,ADT.report_handled,ADT.capture_exception,ADT::MiddlewareandADT::SidekiqHookskeep working unchanged:ADTisDevbench. config.middleware.insert_before 0, ADT::Middlewarecan stay or go: the Railtie sees it and does not insert a second one.- Running the sidecar and want to keep it that way? Change nothing else.
To report directly instead, set
DEVBENCH_DSN(fromadt dsn create); see "Upgrading from 0.5".
Generated from sdk/server/ruby/README.md, the README the install test follows.