solid_errors-frontend
Browser errors in your Solid Errors dashboard.
Uncaught JavaScript exceptions, unhandled promise rejections, Stimulus controller
errors and Turbo failures are captured in the browser, posted to a mounted
engine, and handed to ActiveSupport::ErrorReporter — the same path Solid Errors
already receives server-side errors on. They arrive as ordinary rows, grouped and
deduplicated like everything else, with frames pointing at your own source files.

A third-party gem. Not affiliated with, or endorsed by, the solid_errors project; it shares the name prefix because that's what it pairs with.
Requires Rails 8.0+, Propshaft and importmap — see Supported stack.
Why the backtraces are readable
With importmap + Propshaft there is no bundling and no minification, so a line
number in a served asset matches the source file exactly — no source maps
required. Frames are resolved back through the Propshaft digest
(application-abc12345.js → application.js) and re-emitted in Ruby's backtrace
shape:
/rails/app/javascript/controllers/map_controller.js:42:in `connect'
SolidErrors::BacktraceLine parses those, treats paths under the project root as
application code, and renders the surrounding source — so a browser error opens
in the dashboard showing the JavaScript that raised it. Frames that can't be
resolved (CDN scripts, extensions, inline handlers) pass through verbatim and
degrade to unparsed text rather than pointing somewhere wrong.
Because the file part of that format can't contain a colon and a trailing
:column breaks the match, columns are dropped from the frame and reported in
the context instead.
Install
gem "solid_errors-frontend"
Mount the engine and add the config tag to your layout:
# config/routes.rb
mount SolidErrors::Frontend::Engine, at: "/frontend_errors"
<%# app/views/layouts/application.html.erb, in <head> %>
<%= frontend_errors_tag %>
Start the reporter before your own code, so it's listening when that code runs:
// app/javascript/application.js
import { start } from "solid_errors_frontend"
start()
And hook Stimulus, which otherwise swallows every controller error into
console.error:
// app/javascript/controllers/application.js
import { installStimulusErrorHandler } from "solid_errors_frontend"
const application = Application.start()
installStimulusErrorHandler(application)
Supported stack
| | |
|---|---|
| Rails 8.0+ | The ingest controller scopes its rate limit with rate_limit(name:), which arrived in 8.0. |
| Propshaft | Required to resolve frames back to source. Under Sprockets or a bundler (esbuild, vite) everything still works — errors are reported, grouped and deduplicated — but every frame stays verbatim, because a minified line number doesn't correspond to a source line. |
| importmap | The JavaScript ships through the asset pipeline; there is no npm package. A bundled app has to reference the file directly rather than importing the bare specifier. |
Solid Errors itself is not a runtime dependency. Reports go through
Rails.error, so this works with any ErrorReporter subscriber — or none at
all, which is what makes it safe in development and test where Solid Errors is
often not installed. Everything below about grouping and fingerprints describes
Solid Errors specifically, because that is what it's built for.
Configuration
# config/initializers/solid_errors_frontend.rb
SolidErrors::Frontend.context = -> {
{ user_id: Current.user&.id }
}
| Option | Default | |
|---|---|---|
base_controller_class |
"::ActionController::Base" |
Superclass of the ingest controller. Deliberately not the host's ApplicationController: the endpoint has to work without a session so errors on sign-in pages are captured. |
context |
-> { {} } |
Lambda instance_exec'd in the controller; returns extra flat context. Has access to request, cookies, … |
allowed_context_keys |
url, referrer, viewport, identifier, frame_id, method, status | Client-supplied context keys to keep. Strings or symbols. |
rate_limit_to / rate_limit_within |
30 / 1.minute |
Per-IP limit on the endpoint. |
max_body_bytes |
64.kilobytes |
Larger requests get a 413. |
max_reports_per_request |
20 |
|
max_reports_per_page |
10 |
Browser-side budget, reset on each Turbo visit. |
max_message_length |
500 |
|
max_frames |
30 |
|
message_filters |
uuids, urls, digests | Applied before reporting — see below. |
log_reports |
nil (on outside production) |
Log every report, which is how you see the pipeline work in an environment with no subscriber. |
Context
Two sources are merged. SolidErrors::Frontend.context is server-derived and
unrestricted — put identity there. Anything the browser sends is filtered through
allowed_context_keys, since the endpoint is unauthenticated; extend it with
whatever your own instrumentation reports:
SolidErrors::Frontend.allowed_context_keys += %w[activity_id]
Values are coerced to short scalars whatever the list says, and server-derived context is merged last, so a client can never overwrite a key the server resolved for it — including one you add to the allowlist yourself.
Message normalisation
Solid Errors fingerprints on exception_class + message + severity + source, so
an id or URL inside the message would split one recurring bug across an unbounded
number of rows. message_filters collapses those before reporting.
What arrives in the dashboard
- Exception class —
JS::TypeError,JS::UnhandledRejection, … built per JavaScript error name so the grouping stays legible. Names are allowlisted and the registry is capped, since the value comes from the client. - Source —
javascript.window,javascript.promise,javascript.stimulus,javascript.turbo, so capture sites don't group together. - Severity —
:error, except Turbo failures which are:warning. - Context — url, referrer, viewport, column, user agent, plus whatever
SolidErrors::Frontend.contextadds and a small allowlist of per-source detail (Stimulus identifier, Turbo frame id).
Which puts them in the same list as everything else, rather than a place you have to remember to check:

Endpoint exposure
POST to the mounted path is unauthenticated and CSRF-exempt: errors on the
sign-in page are worth having, and navigator.sendBeacon — the only transport
that survives a closing page — can't set a CSRF header. It is therefore bounded
on every axis: per-IP rate limit, body size, reports per request, message length,
and the generated exception-class registry.
One caveat on the rate limit: it counts through Rails.cache#increment, so it is
only as real as the configured store. Against a :null_store it counts nothing
and the limit never trips. The other bounds hold regardless.
If the noise ever outweighs the coverage, set base_controller_class to a
controller that requires a session and accept losing pre-login errors.