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 JavaScript TypeError in the Solid Errors dashboard, its backtrace resolved to
the Stimulus controller that raised it, with the failing line
highlighted

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.jsapplication.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 classJS::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.
  • Sourcejavascript.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.context adds 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:

The Solid Errors index listing a server-side NoMethodError, a
JS::TurboFrameMissing and a JS::TypeError together

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.