solid_errors-frontend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +170 -0
- data/app/assets/javascripts/solid_errors_frontend.js +222 -0
- data/app/controllers/solid_errors/frontend/reports_controller.rb +52 -0
- data/app/helpers/solid_errors/frontend/tags_helper.rb +20 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +5 -0
- data/lib/solid_errors/frontend/backtrace_formatter.rb +41 -0
- data/lib/solid_errors/frontend/engine.rb +31 -0
- data/lib/solid_errors/frontend/message_normalizer.rb +33 -0
- data/lib/solid_errors/frontend/payload.rb +26 -0
- data/lib/solid_errors/frontend/report.rb +88 -0
- data/lib/solid_errors/frontend/reporter.rb +60 -0
- data/lib/solid_errors/frontend/source_mapper.rb +65 -0
- data/lib/solid_errors/frontend/stack_parser.rb +83 -0
- data/lib/solid_errors/frontend/version.rb +7 -0
- data/lib/solid_errors/frontend.rb +125 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b92029d68f265e946100c2e7b55f85f750a840ac9b94d50d4a1af9a17cbb31a1
|
|
4
|
+
data.tar.gz: 9887401d8ad11b40d3e29fd600fa3b2bd091bd483f3a35291b377e4952ee34d1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 031c3ab7bde338a19fb2682590e5bdf9194cd7b985a27a40e84306017046127ef1d1a52d3aba7a62ceb470bc197ca8cb988183d5540065a945c4d11b7d33683a
|
|
7
|
+
data.tar.gz: 7e277694e98ee9bb4182b1a2fb0afd2a59b9b9da4fc174dac1c14795d5c0e217c730bbf320a9bafe0b8d372b6b910cb415d01cd9814dec253b2fcb330ce72beb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Captures uncaught JavaScript exceptions, unhandled promise rejections, Stimulus
|
|
8
|
+
controller errors and Turbo failures in the browser and reports them through
|
|
9
|
+
`ActiveSupport::ErrorReporter`, so Solid Errors records them alongside
|
|
10
|
+
server-side errors.
|
|
11
|
+
- Resolves frames of your own assets back through the Propshaft digest and
|
|
12
|
+
re-emits them as Ruby backtrace lines, so Solid Errors marks them as
|
|
13
|
+
application code and renders the surrounding source.
|
|
14
|
+
- Normalises messages before reporting, so one recurring bug stays one row rather
|
|
15
|
+
than splitting across the ids and URLs in its text.
|
|
16
|
+
- Bounded on every axis, since the ingest endpoint is unauthenticated by design:
|
|
17
|
+
per-IP rate limit, body size, reports per request and per page, message length,
|
|
18
|
+
frame count, and the generated exception-class registry.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Armand Mégrot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# solid_errors-frontend
|
|
2
|
+
|
|
3
|
+
Browser errors in your [Solid Errors](https://github.com/fractaledmind/solid_errors)
|
|
4
|
+
dashboard.
|
|
5
|
+
|
|
6
|
+
Uncaught JavaScript exceptions, unhandled promise rejections, Stimulus controller
|
|
7
|
+
errors and Turbo failures are captured in the browser, posted to a mounted
|
|
8
|
+
engine, and handed to `ActiveSupport::ErrorReporter` — the same path Solid Errors
|
|
9
|
+
already receives server-side errors on. They arrive as ordinary rows, grouped and
|
|
10
|
+
deduplicated like everything else, with frames pointing at your own source files.
|
|
11
|
+
|
|
12
|
+

|
|
15
|
+
|
|
16
|
+
> A third-party gem. Not affiliated with, or endorsed by, the solid_errors
|
|
17
|
+
> project; it shares the name prefix because that's what it pairs with.
|
|
18
|
+
|
|
19
|
+
**Requires Rails 8.0+, Propshaft and importmap** — see [Supported
|
|
20
|
+
stack](#supported-stack).
|
|
21
|
+
|
|
22
|
+
## Why the backtraces are readable
|
|
23
|
+
|
|
24
|
+
With importmap + Propshaft there is no bundling and no minification, so a line
|
|
25
|
+
number in a served asset matches the source file exactly — no source maps
|
|
26
|
+
required. Frames are resolved back through the Propshaft digest
|
|
27
|
+
(`application-abc12345.js` → `application.js`) and re-emitted in Ruby's backtrace
|
|
28
|
+
shape:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
/rails/app/javascript/controllers/map_controller.js:42:in `connect'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`SolidErrors::BacktraceLine` parses those, treats paths under the project root as
|
|
35
|
+
application code, and renders the surrounding source — so a browser error opens
|
|
36
|
+
in the dashboard showing the JavaScript that raised it. Frames that can't be
|
|
37
|
+
resolved (CDN scripts, extensions, inline handlers) pass through verbatim and
|
|
38
|
+
degrade to unparsed text rather than pointing somewhere wrong.
|
|
39
|
+
|
|
40
|
+
Because the file part of that format can't contain a colon and a trailing
|
|
41
|
+
`:column` breaks the match, columns are dropped from the frame and reported in
|
|
42
|
+
the context instead.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "solid_errors-frontend"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Mount the engine and add the config tag to your layout:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# config/routes.rb
|
|
54
|
+
mount SolidErrors::Frontend::Engine, at: "/frontend_errors"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```erb
|
|
58
|
+
<%# app/views/layouts/application.html.erb, in <head> %>
|
|
59
|
+
<%= frontend_errors_tag %>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Start the reporter before your own code, so it's listening when that code runs:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// app/javascript/application.js
|
|
66
|
+
import { start } from "solid_errors_frontend"
|
|
67
|
+
start()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
And hook Stimulus, which otherwise swallows every controller error into
|
|
71
|
+
`console.error`:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
// app/javascript/controllers/application.js
|
|
75
|
+
import { installStimulusErrorHandler } from "solid_errors_frontend"
|
|
76
|
+
|
|
77
|
+
const application = Application.start()
|
|
78
|
+
installStimulusErrorHandler(application)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Supported stack
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
|---|---|
|
|
85
|
+
| **Rails 8.0+** | The ingest controller scopes its rate limit with `rate_limit(name:)`, which arrived in 8.0. |
|
|
86
|
+
| **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. |
|
|
87
|
+
| **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. |
|
|
88
|
+
|
|
89
|
+
Solid Errors itself is *not* a runtime dependency. Reports go through
|
|
90
|
+
`Rails.error`, so this works with any `ErrorReporter` subscriber — or none at
|
|
91
|
+
all, which is what makes it safe in development and test where Solid Errors is
|
|
92
|
+
often not installed. Everything below about grouping and fingerprints describes
|
|
93
|
+
Solid Errors specifically, because that is what it's built for.
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# config/initializers/solid_errors_frontend.rb
|
|
99
|
+
SolidErrors::Frontend.context = -> {
|
|
100
|
+
{ user_id: Current.user&.id }
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Option | Default | |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `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. |
|
|
107
|
+
| `context` | `-> { {} }` | Lambda `instance_exec`'d in the controller; returns extra flat context. Has access to `request`, `cookies`, … |
|
|
108
|
+
| `allowed_context_keys` | url, referrer, viewport, identifier, frame_id, method, status | Client-supplied context keys to keep. Strings or symbols. |
|
|
109
|
+
| `rate_limit_to` / `rate_limit_within` | `30` / `1.minute` | Per-IP limit on the endpoint. |
|
|
110
|
+
| `max_body_bytes` | `64.kilobytes` | Larger requests get a 413. |
|
|
111
|
+
| `max_reports_per_request` | `20` | |
|
|
112
|
+
| `max_reports_per_page` | `10` | Browser-side budget, reset on each Turbo visit. |
|
|
113
|
+
| `max_message_length` | `500` | |
|
|
114
|
+
| `max_frames` | `30` | |
|
|
115
|
+
| `message_filters` | uuids, urls, digests | Applied before reporting — see below. |
|
|
116
|
+
| `log_reports` | `nil` (on outside production) | Log every report, which is how you see the pipeline work in an environment with no subscriber. |
|
|
117
|
+
|
|
118
|
+
### Context
|
|
119
|
+
|
|
120
|
+
Two sources are merged. `SolidErrors::Frontend.context` is server-derived and
|
|
121
|
+
unrestricted — put identity there. Anything the browser sends is filtered through
|
|
122
|
+
`allowed_context_keys`, since the endpoint is unauthenticated; extend it with
|
|
123
|
+
whatever your own instrumentation reports:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
SolidErrors::Frontend.allowed_context_keys += %w[activity_id]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Values are coerced to short scalars whatever the list says, and **server-derived
|
|
130
|
+
context is merged last**, so a client can never overwrite a key the server
|
|
131
|
+
resolved for it — including one you add to the allowlist yourself.
|
|
132
|
+
|
|
133
|
+
### Message normalisation
|
|
134
|
+
|
|
135
|
+
Solid Errors fingerprints on `exception_class + message + severity + source`, so
|
|
136
|
+
an id or URL inside the message would split one recurring bug across an unbounded
|
|
137
|
+
number of rows. `message_filters` collapses those before reporting.
|
|
138
|
+
|
|
139
|
+
### What arrives in the dashboard
|
|
140
|
+
|
|
141
|
+
- **Exception class** — `JS::TypeError`, `JS::UnhandledRejection`, … built per
|
|
142
|
+
JavaScript error name so the grouping stays legible. Names are allowlisted and
|
|
143
|
+
the registry is capped, since the value comes from the client.
|
|
144
|
+
- **Source** — `javascript.window`, `javascript.promise`, `javascript.stimulus`,
|
|
145
|
+
`javascript.turbo`, so capture sites don't group together.
|
|
146
|
+
- **Severity** — `:error`, except Turbo failures which are `:warning`.
|
|
147
|
+
- **Context** — url, referrer, viewport, column, user agent, plus whatever
|
|
148
|
+
`SolidErrors::Frontend.context` adds and a small allowlist of per-source detail
|
|
149
|
+
(Stimulus identifier, Turbo frame id).
|
|
150
|
+
|
|
151
|
+
Which puts them in the same list as everything else, rather than a place you have
|
|
152
|
+
to remember to check:
|
|
153
|
+
|
|
154
|
+

|
|
156
|
+
|
|
157
|
+
## Endpoint exposure
|
|
158
|
+
|
|
159
|
+
`POST` to the mounted path is unauthenticated and CSRF-exempt: errors on the
|
|
160
|
+
sign-in page are worth having, and `navigator.sendBeacon` — the only transport
|
|
161
|
+
that survives a closing page — can't set a CSRF header. It is therefore bounded
|
|
162
|
+
on every axis: per-IP rate limit, body size, reports per request, message length,
|
|
163
|
+
and the generated exception-class registry.
|
|
164
|
+
|
|
165
|
+
One caveat on the rate limit: it counts through `Rails.cache#increment`, so it is
|
|
166
|
+
only as real as the configured store. Against a `:null_store` it counts nothing
|
|
167
|
+
and the limit never trips. The other bounds hold regardless.
|
|
168
|
+
|
|
169
|
+
If the noise ever outweighs the coverage, set `base_controller_class` to a
|
|
170
|
+
controller that requires a session and accept losing pre-login errors.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Browser-side error reporter.
|
|
2
|
+
//
|
|
3
|
+
// Captures uncaught exceptions, unhandled promise rejections, Stimulus
|
|
4
|
+
// controller errors and Turbo failures, and posts them to the mounted engine,
|
|
5
|
+
// which reports them through Rails.error.
|
|
6
|
+
//
|
|
7
|
+
// Configuration comes from <meta name="solid-errors-frontend"> (see TagsHelper).
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
endpoint: "/frontend_errors",
|
|
11
|
+
maxReports: 10,
|
|
12
|
+
maxMessageLength: 500,
|
|
13
|
+
maxFrames: 30,
|
|
14
|
+
flushDelay: 1000
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Messages that carry no actionable information. "Script error." is what
|
|
18
|
+
// browsers report for exceptions thrown by cross-origin scripts, and the
|
|
19
|
+
// ResizeObserver warnings are fired by layout timing rather than by a bug.
|
|
20
|
+
const IGNORED_MESSAGES = [
|
|
21
|
+
/^Script error\.?$/,
|
|
22
|
+
/^ResizeObserver loop/
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const EXTENSION_SCHEME = /^(chrome|moz|safari-web|safari)-extension:\/\//
|
|
26
|
+
|
|
27
|
+
const config = { ...DEFAULTS }
|
|
28
|
+
const queue = []
|
|
29
|
+
const seen = new Set()
|
|
30
|
+
|
|
31
|
+
let started = false
|
|
32
|
+
let reported = 0
|
|
33
|
+
let reporting = false
|
|
34
|
+
let flushTimer = null
|
|
35
|
+
|
|
36
|
+
function readConfig() {
|
|
37
|
+
const meta = document.querySelector("meta[name='solid-errors-frontend']")
|
|
38
|
+
if (!meta) return
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
Object.assign(config, JSON.parse(meta.content))
|
|
42
|
+
} catch {
|
|
43
|
+
// A malformed tag shouldn't disable reporting; the defaults still work.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function truncate(value, length) {
|
|
48
|
+
const text = String(value ?? "")
|
|
49
|
+
return text.length > length ? `${text.slice(0, length - 1)}…` : text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Cross-origin and extension frames tell us nothing about our own code, and
|
|
53
|
+
// extensions in particular generate a lot of noise on other people's pages.
|
|
54
|
+
function isNoise(message, stack) {
|
|
55
|
+
if (IGNORED_MESSAGES.some((pattern) => pattern.test(message))) return true
|
|
56
|
+
if (!stack) return false
|
|
57
|
+
|
|
58
|
+
const frames = stack.split("\n").filter((line) => /https?:|:\d+:\d+/.test(line))
|
|
59
|
+
return frames.length > 0 && frames.every((line) => EXTENSION_SCHEME.test(line.trim().replace(/^at\s+/, "")))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pageContext() {
|
|
63
|
+
return {
|
|
64
|
+
url: window.location.href,
|
|
65
|
+
referrer: document.referrer || undefined,
|
|
66
|
+
viewport: `${window.innerWidth}x${window.innerHeight}`
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function report({ name, message, stack, source, context = {} }) {
|
|
71
|
+
// A failure inside the reporter must never re-enter it.
|
|
72
|
+
if (reporting || !started) return
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
reporting = true
|
|
76
|
+
|
|
77
|
+
const text = truncate(message, config.maxMessageLength)
|
|
78
|
+
if (!text && !name) return
|
|
79
|
+
if (isNoise(text, stack)) return
|
|
80
|
+
|
|
81
|
+
const key = `${name}|${text}|${(stack || "").split("\n")[1] || ""}`
|
|
82
|
+
if (seen.has(key)) return
|
|
83
|
+
if (reported >= config.maxReports) return
|
|
84
|
+
|
|
85
|
+
seen.add(key)
|
|
86
|
+
reported += 1
|
|
87
|
+
|
|
88
|
+
queue.push({
|
|
89
|
+
name: name || "Error",
|
|
90
|
+
message: text,
|
|
91
|
+
stack: stack ? stack.split("\n").slice(0, config.maxFrames + 1).join("\n") : undefined,
|
|
92
|
+
source,
|
|
93
|
+
context: { ...pageContext(), ...context }
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
scheduleFlush()
|
|
97
|
+
} catch {
|
|
98
|
+
// Reporting is best-effort.
|
|
99
|
+
} finally {
|
|
100
|
+
reporting = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function scheduleFlush() {
|
|
105
|
+
if (flushTimer) return
|
|
106
|
+
flushTimer = setTimeout(() => flush(), config.flushDelay)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function flush({ beacon = false } = {}) {
|
|
110
|
+
if (flushTimer) {
|
|
111
|
+
clearTimeout(flushTimer)
|
|
112
|
+
flushTimer = null
|
|
113
|
+
}
|
|
114
|
+
if (queue.length === 0) return
|
|
115
|
+
|
|
116
|
+
const body = JSON.stringify({ reports: queue.splice(0, queue.length) })
|
|
117
|
+
|
|
118
|
+
// On pagehide the document may be torn down before fetch resolves, so use
|
|
119
|
+
// sendBeacon there; it can't set headers, which is why the endpoint is
|
|
120
|
+
// CSRF-exempt.
|
|
121
|
+
if (beacon && navigator.sendBeacon) {
|
|
122
|
+
navigator.sendBeacon(config.endpoint, new Blob([body], { type: "application/json" }))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fetch(config.endpoint, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body,
|
|
130
|
+
keepalive: true,
|
|
131
|
+
credentials: "same-origin"
|
|
132
|
+
}).catch(() => {
|
|
133
|
+
// Dropping a report is preferable to an unhandled rejection here, which
|
|
134
|
+
// would come straight back through our own rejection handler.
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function errorFrom(value) {
|
|
139
|
+
if (value instanceof Error) {
|
|
140
|
+
return { name: value.name, message: value.message, stack: value.stack }
|
|
141
|
+
}
|
|
142
|
+
return { name: "Error", message: String(value) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onError(event) {
|
|
146
|
+
if (event.error) {
|
|
147
|
+
report({ ...errorFrom(event.error), source: "window" })
|
|
148
|
+
} else if (event.message) {
|
|
149
|
+
// Cross-origin scripts give an ErrorEvent with no Error object. The
|
|
150
|
+
// location is still worth keeping, formatted so the server can resolve it.
|
|
151
|
+
report({
|
|
152
|
+
name: "Error",
|
|
153
|
+
message: event.message,
|
|
154
|
+
stack: event.filename ? ` at ${event.filename}:${event.lineno || 0}:${event.colno || 0}` : undefined,
|
|
155
|
+
source: "window"
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onUnhandledRejection(event) {
|
|
161
|
+
const { name, message, stack } = errorFrom(event.reason)
|
|
162
|
+
report({ name: name === "Error" ? "UnhandledRejection" : name, message, stack, source: "promise" })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onTurboFetchError(event) {
|
|
166
|
+
const request = event.detail?.request
|
|
167
|
+
report({
|
|
168
|
+
name: "TurboFetchRequestError",
|
|
169
|
+
message: `${request?.method || "GET"} ${request?.url || "request"} failed`,
|
|
170
|
+
source: "turbo",
|
|
171
|
+
context: { method: request?.method, url: request?.url?.toString() }
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function onTurboFrameMissing(event) {
|
|
176
|
+
report({
|
|
177
|
+
name: "TurboFrameMissing",
|
|
178
|
+
message: `Response missing turbo-frame #${event.target?.id || "unknown"}`,
|
|
179
|
+
source: "turbo",
|
|
180
|
+
context: { frame_id: event.target?.id, status: event.detail?.response?.status }
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Turbo Drive keeps the document alive across navigations, so a per-page-load
|
|
185
|
+
// budget has to be reset on visits rather than on load.
|
|
186
|
+
function onTurboLoad() {
|
|
187
|
+
reported = 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function start() {
|
|
191
|
+
if (started) return
|
|
192
|
+
started = true
|
|
193
|
+
|
|
194
|
+
readConfig()
|
|
195
|
+
|
|
196
|
+
window.addEventListener("error", onError)
|
|
197
|
+
window.addEventListener("unhandledrejection", onUnhandledRejection)
|
|
198
|
+
document.addEventListener("turbo:fetch-request-error", onTurboFetchError)
|
|
199
|
+
document.addEventListener("turbo:frame-missing", onTurboFrameMissing)
|
|
200
|
+
document.addEventListener("turbo:load", onTurboLoad)
|
|
201
|
+
|
|
202
|
+
window.addEventListener("pagehide", () => flush({ beacon: true }))
|
|
203
|
+
document.addEventListener("visibilitychange", () => {
|
|
204
|
+
if (document.visibilityState === "hidden") flush({ beacon: true })
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Stimulus routes every controller lifecycle and action error through
|
|
209
|
+
// handleError, which only logs by default — so without this the majority of
|
|
210
|
+
// application errors never surface.
|
|
211
|
+
export function installStimulusErrorHandler(application) {
|
|
212
|
+
const original = application.handleError.bind(application)
|
|
213
|
+
|
|
214
|
+
application.handleError = (error, message, detail) => {
|
|
215
|
+
report({
|
|
216
|
+
...errorFrom(error),
|
|
217
|
+
source: "stimulus",
|
|
218
|
+
context: { identifier: detail?.identifier }
|
|
219
|
+
})
|
|
220
|
+
original(error, message, detail)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
class ReportsController < SolidErrors::Frontend.base_controller_class.constantize
|
|
5
|
+
# navigator.sendBeacon can't set an X-CSRF-Token header, and the beacon path is
|
|
6
|
+
# the one that survives a page being closed — which is exactly when we most
|
|
7
|
+
# want the queued errors. See the class comment below for what guards this
|
|
8
|
+
# instead.
|
|
9
|
+
skip_forgery_protection
|
|
10
|
+
|
|
11
|
+
# The endpoint is deliberately reachable without a session, so that errors on
|
|
12
|
+
# the sign-in and sign-up pages are captured too. That makes it publicly
|
|
13
|
+
# writable, so it's bounded on every axis: requests per IP here, body size and
|
|
14
|
+
# report count in #create, message length in MessageNormalizer, and the
|
|
15
|
+
# generated exception-class registry in SolidErrors::Frontend.error_class_for.
|
|
16
|
+
#
|
|
17
|
+
# The per-IP limit leans on Rails.cache#increment, so it is only as real as the
|
|
18
|
+
# configured store — with a :null_store it counts nothing. The other bounds
|
|
19
|
+
# hold regardless.
|
|
20
|
+
rate_limit to: SolidErrors::Frontend.rate_limit_to,
|
|
21
|
+
within: SolidErrors::Frontend.rate_limit_within,
|
|
22
|
+
name: "solid_errors_frontend"
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
return head(:content_too_large) if request.content_length.to_i > SolidErrors::Frontend.max_body_bytes
|
|
26
|
+
|
|
27
|
+
source_mapper = SourceMapper.new(host: request.host)
|
|
28
|
+
|
|
29
|
+
Payload.parse(request.body.read).each do |report|
|
|
30
|
+
Reporter.new(report, context: report_context, source_mapper: source_mapper).call
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
head :no_content
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
# No `url` here: the client reports its own `location.href`, which is both
|
|
38
|
+
# more accurate than a Referer taken at flush time and unaffected by
|
|
39
|
+
# Referrer-Policy. Deriving one here would only fight it, since server
|
|
40
|
+
# context takes precedence.
|
|
41
|
+
def report_context
|
|
42
|
+
{ user_agent: request.user_agent }.merge(host_context).compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Host-supplied identity/context. Evaluated in the controller so it can read
|
|
46
|
+
# cookies, headers and whatever else the app needs to resolve a user.
|
|
47
|
+
def host_context
|
|
48
|
+
context = instance_exec(&SolidErrors::Frontend.context)
|
|
49
|
+
context.is_a?(Hash) ? context : {}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
module TagsHelper
|
|
5
|
+
# Configuration for the browser-side reporter, read by
|
|
6
|
+
# solid_errors_frontend.js. A meta tag rather than an inline script so the
|
|
7
|
+
# module stays a plain, cacheable asset and nothing has to be exempted from a
|
|
8
|
+
# CSP.
|
|
9
|
+
def frontend_errors_tag(**overrides)
|
|
10
|
+
config = {
|
|
11
|
+
endpoint: solid_errors_frontend.reports_path,
|
|
12
|
+
maxReports: SolidErrors::Frontend.max_reports_per_page,
|
|
13
|
+
maxMessageLength: SolidErrors::Frontend.max_message_length,
|
|
14
|
+
maxFrames: SolidErrors::Frontend.max_frames
|
|
15
|
+
}.merge(overrides)
|
|
16
|
+
|
|
17
|
+
tag.meta(name: "solid-errors-frontend", content: config.to_json)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
# Renders browser frames as Ruby backtrace lines.
|
|
5
|
+
#
|
|
6
|
+
# Error backends parse backtraces with a Ruby-shaped regex — roughly
|
|
7
|
+
#
|
|
8
|
+
# /^((?:[a-zA-Z]:)?[^:]+):(\d+)(?::in [`']([^']+)')?$/
|
|
9
|
+
#
|
|
10
|
+
# so a frame written as "/app/javascript/foo.js:42:in `connect'" is understood,
|
|
11
|
+
# linked to its source and (when the path is under the project root) treated as
|
|
12
|
+
# application code. Two constraints follow from that regex: the file part can't
|
|
13
|
+
# contain a colon, and a trailing ":column" makes the whole line fail to match —
|
|
14
|
+
# hence columns are dropped here and carried in the report context instead.
|
|
15
|
+
#
|
|
16
|
+
# Frames we can't resolve to a source file are emitted verbatim; backends fall
|
|
17
|
+
# back to showing the unparsed line, so nothing is lost.
|
|
18
|
+
class BacktraceFormatter
|
|
19
|
+
DEFAULT_METHOD = "<anonymous>"
|
|
20
|
+
|
|
21
|
+
def initialize(source_mapper)
|
|
22
|
+
@source_mapper = source_mapper
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param frames [Array<StackParser::Frame>]
|
|
26
|
+
# @return [Array<String>]
|
|
27
|
+
def call(frames)
|
|
28
|
+
frames.filter_map { |frame| format_frame(frame) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def format_frame(frame)
|
|
33
|
+
return frame.raw if frame.url.nil? || frame.line.nil?
|
|
34
|
+
|
|
35
|
+
path = @source_mapper.resolve(frame.url)
|
|
36
|
+
return frame.raw unless path
|
|
37
|
+
|
|
38
|
+
"#{path}:#{frame.line}:in `#{frame.function || DEFAULT_METHOD}'"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace SolidErrors::Frontend
|
|
6
|
+
|
|
7
|
+
# Serve app/assets/javascripts/solid_errors_frontend.js through the host's
|
|
8
|
+
# asset pipeline. Propshaft and Sprockets both serve it; only Propshaft can
|
|
9
|
+
# resolve the served URL back to a source file (see SourceMapper).
|
|
10
|
+
initializer "solid_errors_frontend.assets" do |app|
|
|
11
|
+
app.config.assets.paths << root.join("app/assets/javascripts") if app.config.respond_to?(:assets)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Importmap::Engine's own "importmap" initializer draws every path in
|
|
15
|
+
# config.importmap.paths, so ours has to be appended before it runs.
|
|
16
|
+
initializer "solid_errors_frontend.importmap", before: "importmap" do |app|
|
|
17
|
+
next unless app.config.respond_to?(:importmap)
|
|
18
|
+
|
|
19
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
20
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# isolate_namespace keeps engine helpers out of the host's views, but
|
|
24
|
+
# frontend_errors_tag has to be callable from the host layout.
|
|
25
|
+
initializer "solid_errors_frontend.helpers" do
|
|
26
|
+
ActiveSupport.on_load(:action_view) do
|
|
27
|
+
include SolidErrors::Frontend::TagsHelper
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
# Collapses the parts of a message that vary between occurrences of the same bug.
|
|
5
|
+
#
|
|
6
|
+
# Error backends typically fingerprint on the message (Solid Errors hashes
|
|
7
|
+
# exception class + message + severity + source), so an id, a URL or a digest
|
|
8
|
+
# inside the text would split one recurring bug into an unbounded number of
|
|
9
|
+
# groups. Normalising first keeps one bug to one row.
|
|
10
|
+
class MessageNormalizer
|
|
11
|
+
def self.call(message, filters: SolidErrors::Frontend.message_filters, limit: SolidErrors::Frontend.max_message_length)
|
|
12
|
+
new(filters: filters, limit: limit).call(message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(filters: SolidErrors::Frontend.message_filters, limit: SolidErrors::Frontend.max_message_length)
|
|
16
|
+
@filters = filters
|
|
17
|
+
@limit = limit
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(message)
|
|
21
|
+
text = message.to_s.strip.gsub(/\s+/, " ")
|
|
22
|
+
text = @filters.reduce(text) { |result, (pattern, replacement)| result.gsub(pattern, replacement) }
|
|
23
|
+
truncate(text)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
def truncate(text)
|
|
28
|
+
return text if text.length <= @limit
|
|
29
|
+
|
|
30
|
+
"#{text[0, @limit - 1]}…"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidErrors::Frontend
|
|
6
|
+
# Parses the request body into Reports.
|
|
7
|
+
#
|
|
8
|
+
# This is the trust boundary: the endpoint is unauthenticated by design (errors
|
|
9
|
+
# on the sign-in page are worth having), so malformed or hostile input has to
|
|
10
|
+
# produce an empty list rather than an exception.
|
|
11
|
+
module Payload
|
|
12
|
+
def self.parse(body, limit: SolidErrors::Frontend.max_reports_per_request)
|
|
13
|
+
parsed = JSON.parse(body.to_s)
|
|
14
|
+
return [] unless parsed.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
reports = parsed["reports"]
|
|
17
|
+
return [] unless reports.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
reports.first(limit).filter_map do |attributes|
|
|
20
|
+
Report.from(attributes) if attributes.is_a?(Hash)
|
|
21
|
+
end
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
# One browser error, normalised into everything needed to report it.
|
|
5
|
+
#
|
|
6
|
+
# Every field here arrives from the client, so nothing is passed through
|
|
7
|
+
# untouched: the capture site picks the reported source and severity (rather
|
|
8
|
+
# than the payload naming them), extra context is allowlisted, and the message
|
|
9
|
+
# is normalised and truncated.
|
|
10
|
+
class Report
|
|
11
|
+
# Capture site => [reported source, severity, handled]. The source ends up in
|
|
12
|
+
# the fingerprint, so this both labels the report and keeps, say, a Turbo
|
|
13
|
+
# failure from grouping with an identically-worded uncaught exception.
|
|
14
|
+
CAPTURE_SITES = {
|
|
15
|
+
"window" => [ "javascript.window", :error, false ],
|
|
16
|
+
"promise" => [ "javascript.promise", :error, false ],
|
|
17
|
+
"stimulus" => [ "javascript.stimulus", :error, false ],
|
|
18
|
+
"turbo" => [ "javascript.turbo", :warning, true ]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
DEFAULT_CAPTURE_SITE = [ "javascript", :error, false ].freeze
|
|
22
|
+
|
|
23
|
+
# Values are kept flat and short whatever SolidErrors::Frontend.allowed_context_keys
|
|
24
|
+
# permits: backends render context as a plain key/value list, and the payload
|
|
25
|
+
# is unauthenticated.
|
|
26
|
+
MAX_CONTEXT_VALUE_LENGTH = 200
|
|
27
|
+
|
|
28
|
+
attr_reader :name, :message, :stack, :source, :severity, :context
|
|
29
|
+
|
|
30
|
+
def self.from(attributes)
|
|
31
|
+
attributes = attributes.to_h { |key, value| [ key.to_s, value ] }
|
|
32
|
+
|
|
33
|
+
# A blank message would be dropped (or worse, raise) by backends that
|
|
34
|
+
# require one, so fall back to the error name before giving up.
|
|
35
|
+
message = MessageNormalizer.call(attributes["message"])
|
|
36
|
+
message = MessageNormalizer.call(attributes["name"]) if message.empty?
|
|
37
|
+
return nil if message.empty?
|
|
38
|
+
|
|
39
|
+
new(
|
|
40
|
+
name: attributes["name"],
|
|
41
|
+
message: message,
|
|
42
|
+
stack: attributes["stack"],
|
|
43
|
+
capture_site: attributes["source"],
|
|
44
|
+
context: attributes["context"]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(name:, message:, stack:, capture_site:, context: nil)
|
|
49
|
+
@name = name
|
|
50
|
+
@message = message
|
|
51
|
+
@stack = stack
|
|
52
|
+
@source, @severity, @handled = CAPTURE_SITES.fetch(capture_site.to_s, DEFAULT_CAPTURE_SITE)
|
|
53
|
+
@context = sanitize_context(context)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handled? = @handled
|
|
57
|
+
|
|
58
|
+
def error_class = SolidErrors::Frontend.error_class_for(name)
|
|
59
|
+
|
|
60
|
+
def frames
|
|
61
|
+
@frames ||= StackParser.parse(stack)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
def sanitize_context(context)
|
|
66
|
+
return {} unless context.respond_to?(:each_pair)
|
|
67
|
+
|
|
68
|
+
# Hosts configure the allowlist with whichever of strings or symbols come
|
|
69
|
+
# naturally; compare on strings.
|
|
70
|
+
allowed = SolidErrors::Frontend.allowed_context_keys.map(&:to_s)
|
|
71
|
+
|
|
72
|
+
context.each_pair.with_object({}) do |(key, value), result|
|
|
73
|
+
key = key.to_s
|
|
74
|
+
next unless allowed.include?(key)
|
|
75
|
+
next unless scalar?(value)
|
|
76
|
+
|
|
77
|
+
result[key.to_sym] = value.is_a?(String) ? value.strip[0, MAX_CONTEXT_VALUE_LENGTH] : value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def scalar?(value)
|
|
82
|
+
case value
|
|
83
|
+
when String, Numeric, TrueClass, FalseClass then true
|
|
84
|
+
else false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
# Hands a browser error to Rails' error reporter.
|
|
5
|
+
#
|
|
6
|
+
# SolidErrors::Subscriber is a plain ErrorSubscriber, so this is the way in:
|
|
7
|
+
# going to SolidErrors::Error directly would bypass its sanitizer, fingerprint
|
|
8
|
+
# dedup and occurrence creation. It also means solid_errors need not be loaded
|
|
9
|
+
# at all — in an environment with no subscriber the report simply goes nowhere,
|
|
10
|
+
# which is why the gem works in development and test.
|
|
11
|
+
class Reporter
|
|
12
|
+
def initialize(report, context: {}, source_mapper: SourceMapper.new)
|
|
13
|
+
@report = report
|
|
14
|
+
@context = context
|
|
15
|
+
@source_mapper = source_mapper
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
error = build_error
|
|
20
|
+
log(error) if SolidErrors::Frontend.log_reports?
|
|
21
|
+
|
|
22
|
+
Rails.error.report(
|
|
23
|
+
error,
|
|
24
|
+
handled: @report.handled?,
|
|
25
|
+
severity: @report.severity,
|
|
26
|
+
source: @report.source,
|
|
27
|
+
context: context
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def build_error
|
|
33
|
+
error = @report.error_class.new(@report.message)
|
|
34
|
+
error.set_backtrace(backtrace)
|
|
35
|
+
error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def backtrace
|
|
39
|
+
BacktraceFormatter.new(@source_mapper).call(@report.frames)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Server-derived context is merged last, so it always wins: whatever a host
|
|
43
|
+
# adds to `allowed_context_keys`, a client must never be able to overwrite
|
|
44
|
+
# the identity we resolved for it.
|
|
45
|
+
def context
|
|
46
|
+
top_frame = @report.frames.first
|
|
47
|
+
@report.context
|
|
48
|
+
.merge(@context)
|
|
49
|
+
.merge(column: top_frame&.column)
|
|
50
|
+
.compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log(error)
|
|
54
|
+
Rails.logger.warn do
|
|
55
|
+
"[solid_errors_frontend] #{error.class.name}: #{error.message} " \
|
|
56
|
+
"(#{@report.source}) #{error.backtrace&.first}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SolidErrors::Frontend
|
|
6
|
+
# Resolves the URL of a served asset back to the source file it was built from.
|
|
7
|
+
#
|
|
8
|
+
# No source maps are involved and none are needed: with importmap + Propshaft
|
|
9
|
+
# there is no bundling or minification, so line numbers in the served asset
|
|
10
|
+
# match the source file exactly. All that has to be undone is the digest
|
|
11
|
+
# Propshaft splices into the filename (application-abc12345.js).
|
|
12
|
+
#
|
|
13
|
+
# Returning an absolute path under Rails.root is what lets an error backend
|
|
14
|
+
# recognise the frame as application code and show the surrounding source.
|
|
15
|
+
class SourceMapper
|
|
16
|
+
MAX_CACHE_SIZE = 500
|
|
17
|
+
|
|
18
|
+
def initialize(host: nil)
|
|
19
|
+
@host = host
|
|
20
|
+
@cache = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] absolute path of the source file, if it can be found
|
|
24
|
+
def resolve(url)
|
|
25
|
+
return nil if url.nil?
|
|
26
|
+
|
|
27
|
+
@cache.fetch(url) do
|
|
28
|
+
path = lookup(url)
|
|
29
|
+
@cache.clear if @cache.size >= MAX_CACHE_SIZE
|
|
30
|
+
@cache[url] = path
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
def lookup(url)
|
|
36
|
+
path = asset_path(url)
|
|
37
|
+
return nil unless path
|
|
38
|
+
|
|
39
|
+
logical_path, _digest = Propshaft::Asset.extract_path_and_digest(path)
|
|
40
|
+
asset = Rails.application.assets.load_path.find(logical_path)
|
|
41
|
+
asset&.path&.to_s
|
|
42
|
+
rescue StandardError
|
|
43
|
+
# Never let stack rewriting take down the report itself.
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The path under the asset prefix, or nil when the URL isn't one of our
|
|
48
|
+
# assets (a CDN, an inline script, a browser extension, another origin).
|
|
49
|
+
def asset_path(url)
|
|
50
|
+
return nil unless defined?(Propshaft) && defined?(Rails) && Rails.respond_to?(:application)
|
|
51
|
+
|
|
52
|
+
uri = URI.parse(url)
|
|
53
|
+
return nil if uri.host && @host && uri.host != @host
|
|
54
|
+
return nil unless uri.path&.start_with?(assets_prefix)
|
|
55
|
+
|
|
56
|
+
uri.path.delete_prefix(assets_prefix)
|
|
57
|
+
rescue URI::InvalidURIError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assets_prefix
|
|
62
|
+
@assets_prefix ||= Rails.application.assets.prefix
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidErrors::Frontend
|
|
4
|
+
# Turns a browser stack trace string into structured frames.
|
|
5
|
+
#
|
|
6
|
+
# Two dialects have to be handled. V8 (Chrome, Edge, node) prefixes every frame
|
|
7
|
+
# with "at" and puts the location in parentheses when a function name is known:
|
|
8
|
+
#
|
|
9
|
+
# Error: boom
|
|
10
|
+
# at Object.connect (https://host/assets/x-abc123.js:42:15)
|
|
11
|
+
# at https://host/assets/x-abc123.js:1:1
|
|
12
|
+
# at new Foo (https://host/assets/x-abc123.js:7:9)
|
|
13
|
+
# at async load (https://host/assets/x-abc123.js:3:1)
|
|
14
|
+
#
|
|
15
|
+
# SpiderMonkey/JavaScriptCore (Firefox, Safari) use "function@location" with no
|
|
16
|
+
# prefix, and an empty function name for top-level frames:
|
|
17
|
+
#
|
|
18
|
+
# connect@https://host/assets/x-abc123.js:42:15
|
|
19
|
+
# @https://host/assets/x-abc123.js:1:1
|
|
20
|
+
class StackParser
|
|
21
|
+
Frame = Struct.new(:function, :url, :line, :column, :raw, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# "at fn (url:line:col)" / "at url:line:col". The location group is greedy up
|
|
24
|
+
# to the final two ":n" pairs so that URLs containing colons still split right.
|
|
25
|
+
V8 = /\A\s*at\s+(?:(?<function>.+?)\s+\()?(?<url>.+?):(?<line>\d+):(?<column>\d+)\)?\s*\z/
|
|
26
|
+
|
|
27
|
+
# "fn@url:line:col"
|
|
28
|
+
MOZ = /\A\s*(?<function>[^@]*)@(?<url>.+?):(?<line>\d+):(?<column>\d+)\s*\z/
|
|
29
|
+
|
|
30
|
+
def self.parse(stack, limit: SolidErrors::Frontend.max_frames)
|
|
31
|
+
new(stack).parse(limit: limit)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(stack)
|
|
35
|
+
@stack = stack.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse(limit: SolidErrors::Frontend.max_frames)
|
|
39
|
+
frames = []
|
|
40
|
+
|
|
41
|
+
@stack.each_line do |raw_line|
|
|
42
|
+
line = raw_line.strip
|
|
43
|
+
next if line.empty?
|
|
44
|
+
|
|
45
|
+
if (frame = frame_from(line))
|
|
46
|
+
frames << frame
|
|
47
|
+
elsif frames.any?
|
|
48
|
+
# Something we can't decompose but that sits inside the trace, e.g.
|
|
49
|
+
# Safari's "[native code]". Worth keeping verbatim.
|
|
50
|
+
frames << Frame.new(raw: line)
|
|
51
|
+
end
|
|
52
|
+
# Anything unparseable *before* the first frame is the "Name: message"
|
|
53
|
+
# header V8 prepends, which we already have as the message.
|
|
54
|
+
|
|
55
|
+
break if frames.size >= limit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
frames
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
def frame_from(line)
|
|
63
|
+
match = V8.match(line) || MOZ.match(line)
|
|
64
|
+
return unless match
|
|
65
|
+
|
|
66
|
+
Frame.new(
|
|
67
|
+
function: normalize_function(match[:function]),
|
|
68
|
+
url: match[:url],
|
|
69
|
+
line: match[:line].to_i,
|
|
70
|
+
column: match[:column].to_i,
|
|
71
|
+
raw: line.strip
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# "new Foo" and "async load" carry a V8 modifier rather than a name we can
|
|
76
|
+
# show as-is; keep the name and drop the noise.
|
|
77
|
+
def normalize_function(function)
|
|
78
|
+
name = function.to_s.strip
|
|
79
|
+
name = name.delete_prefix("async ").delete_prefix("new ")
|
|
80
|
+
name.empty? ? nil : name
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
5
|
+
require "active_support/core_ext/numeric/bytes"
|
|
6
|
+
require "active_support/core_ext/numeric/time"
|
|
7
|
+
|
|
8
|
+
require "solid_errors/frontend/version"
|
|
9
|
+
|
|
10
|
+
# Defined here rather than assumed, because solid_errors itself is not a runtime
|
|
11
|
+
# dependency: reports go through ActiveSupport::ErrorReporter, so an app with no
|
|
12
|
+
# subscriber at all still works. When solid_errors *is* loaded it simply reopens
|
|
13
|
+
# this module.
|
|
14
|
+
module SolidErrors
|
|
15
|
+
module Frontend
|
|
16
|
+
# Base class for every error reported from the browser. Concrete classes are
|
|
17
|
+
# built per JavaScript error name by .error_class_for.
|
|
18
|
+
class BrowserError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# JavaScript error names we're willing to turn into a Ruby class name. Anything
|
|
21
|
+
# else (including anything an attacker could POST) collapses to JS::Error.
|
|
22
|
+
NAME_PATTERN = /\A[A-Z][A-Za-z0-9_]{0,63}\z/
|
|
23
|
+
|
|
24
|
+
# Upper bound on the generated class registry, so a hostile client can't grow it
|
|
25
|
+
# without limit.
|
|
26
|
+
MAX_ERROR_CLASSES = 100
|
|
27
|
+
|
|
28
|
+
# Controller the ingest endpoint inherits from. Deliberately not the host's
|
|
29
|
+
# ApplicationController by default: the endpoint must stay reachable without a
|
|
30
|
+
# session (errors on the sign-in page matter too), and it shouldn't pick up
|
|
31
|
+
# whatever before_actions the host installs. Identity comes from `context`.
|
|
32
|
+
mattr_accessor :base_controller_class, default: "::ActionController::Base"
|
|
33
|
+
|
|
34
|
+
# Lambda instance_exec'd in the controller, returning a flat hash of extra
|
|
35
|
+
# context (user id, native flag, ...). It has access to `request`, `cookies`
|
|
36
|
+
# and anything else the controller exposes. Not subject to
|
|
37
|
+
# `allowed_context_keys` — that governs client input, this is server-derived.
|
|
38
|
+
mattr_accessor :context, default: -> { {} }
|
|
39
|
+
|
|
40
|
+
# Client-supplied context keys we're willing to store. An allowlist rather than
|
|
41
|
+
# a denylist because the payload is unauthenticated: add the keys your own
|
|
42
|
+
# instrumentation sends, and nothing else gets through. Values are still
|
|
43
|
+
# constrained to short scalars regardless of what's listed here.
|
|
44
|
+
mattr_accessor :allowed_context_keys, default: %w[url referrer viewport identifier frame_id method status]
|
|
45
|
+
|
|
46
|
+
# Per-IP rate limit on the ingest endpoint. Note this relies on Rails.cache
|
|
47
|
+
# supporting #increment — with a :null_store it silently does nothing.
|
|
48
|
+
mattr_accessor :rate_limit_to, default: 30
|
|
49
|
+
mattr_accessor :rate_limit_within, default: 1.minute
|
|
50
|
+
|
|
51
|
+
# Hard caps. Everything below is attacker-controlled, and `message` in particular
|
|
52
|
+
# feeds the error fingerprint Solid Errors computes for the report.
|
|
53
|
+
mattr_accessor :max_body_bytes, default: 64.kilobytes
|
|
54
|
+
mattr_accessor :max_reports_per_request, default: 20
|
|
55
|
+
mattr_accessor :max_message_length, default: 500
|
|
56
|
+
mattr_accessor :max_frames, default: 30
|
|
57
|
+
|
|
58
|
+
# Budget the browser gives itself per page view, so one error in a loop can't
|
|
59
|
+
# turn into a flood of requests. Reset on each Turbo visit.
|
|
60
|
+
mattr_accessor :max_reports_per_page, default: 10
|
|
61
|
+
|
|
62
|
+
# Substitutions applied to messages before reporting. Solid Errors fingerprints
|
|
63
|
+
# on the message, so anything that varies per occurrence (ids, urls, digests)
|
|
64
|
+
# would otherwise split one bug across many groups.
|
|
65
|
+
mattr_accessor :message_filters, default: [
|
|
66
|
+
[ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<uuid>" ],
|
|
67
|
+
[ /\bblob:\S+/i, "<blob-url>" ],
|
|
68
|
+
[ %r{\bhttps?://\S+}i, "<url>" ],
|
|
69
|
+
[ /\b[0-9a-f]{16,}\b/i, "<hash>" ]
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Whether to log every report. nil means "decide from the environment": on
|
|
73
|
+
# outside production, where solid_errors is often not installed and the log is
|
|
74
|
+
# the only way to see the pipeline work.
|
|
75
|
+
mattr_accessor :log_reports, default: nil
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
def log_reports?
|
|
79
|
+
return log_reports unless log_reports.nil?
|
|
80
|
+
|
|
81
|
+
defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Builds (and memoizes) an exception class whose #name reads "JS::TypeError".
|
|
85
|
+
# Solid Errors keys its grouping off the exception class name, so this is what
|
|
86
|
+
# makes browser errors legible next to server-side ones.
|
|
87
|
+
def error_class_for(js_name)
|
|
88
|
+
name = NAME_PATTERN.match?(js_name.to_s) ? js_name.to_s : "Error"
|
|
89
|
+
|
|
90
|
+
error_classes_lock.synchronize do
|
|
91
|
+
error_classes[name] ||=
|
|
92
|
+
if error_classes.size >= MAX_ERROR_CLASSES
|
|
93
|
+
BrowserError
|
|
94
|
+
else
|
|
95
|
+
Class.new(BrowserError) do
|
|
96
|
+
define_singleton_method(:name) { "JS::#{name}" }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reset_error_classes! # :nodoc: for tests
|
|
103
|
+
error_classes_lock.synchronize { @error_classes = {} }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
def error_classes
|
|
108
|
+
@error_classes ||= {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def error_classes_lock
|
|
112
|
+
@error_classes_lock ||= Mutex.new
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
require "solid_errors/frontend/message_normalizer"
|
|
119
|
+
require "solid_errors/frontend/stack_parser"
|
|
120
|
+
require "solid_errors/frontend/source_mapper"
|
|
121
|
+
require "solid_errors/frontend/backtrace_formatter"
|
|
122
|
+
require "solid_errors/frontend/report"
|
|
123
|
+
require "solid_errors/frontend/payload"
|
|
124
|
+
require "solid_errors/frontend/reporter"
|
|
125
|
+
require "solid_errors/frontend/engine" if defined?(Rails::Engine)
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: solid_errors-frontend
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Armand Mégrot
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: railties
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionpack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.0'
|
|
40
|
+
description: Captures uncaught JavaScript exceptions, unhandled promise rejections,
|
|
41
|
+
Stimulus controller errors and Turbo failures in the browser and reports them through
|
|
42
|
+
ActiveSupport::ErrorReporter, so Solid Errors records them alongside server-side
|
|
43
|
+
errors — with frames resolved back to your source files. A third-party companion
|
|
44
|
+
gem, not affiliated with solid_errors.
|
|
45
|
+
email:
|
|
46
|
+
- armand.megrot@gmail.com
|
|
47
|
+
executables: []
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- CHANGELOG.md
|
|
52
|
+
- LICENSE
|
|
53
|
+
- README.md
|
|
54
|
+
- app/assets/javascripts/solid_errors_frontend.js
|
|
55
|
+
- app/controllers/solid_errors/frontend/reports_controller.rb
|
|
56
|
+
- app/helpers/solid_errors/frontend/tags_helper.rb
|
|
57
|
+
- config/importmap.rb
|
|
58
|
+
- config/routes.rb
|
|
59
|
+
- lib/solid_errors/frontend.rb
|
|
60
|
+
- lib/solid_errors/frontend/backtrace_formatter.rb
|
|
61
|
+
- lib/solid_errors/frontend/engine.rb
|
|
62
|
+
- lib/solid_errors/frontend/message_normalizer.rb
|
|
63
|
+
- lib/solid_errors/frontend/payload.rb
|
|
64
|
+
- lib/solid_errors/frontend/report.rb
|
|
65
|
+
- lib/solid_errors/frontend/reporter.rb
|
|
66
|
+
- lib/solid_errors/frontend/source_mapper.rb
|
|
67
|
+
- lib/solid_errors/frontend/stack_parser.rb
|
|
68
|
+
- lib/solid_errors/frontend/version.rb
|
|
69
|
+
homepage: https://github.com/armandmgt/solid_errors-frontend
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
homepage_uri: https://github.com/armandmgt/solid_errors-frontend
|
|
74
|
+
changelog_uri: https://github.com/armandmgt/solid_errors-frontend/blob/main/CHANGELOG.md
|
|
75
|
+
bug_tracker_uri: https://github.com/armandmgt/solid_errors-frontend/issues
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: 3.2.0
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.6.9
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Browser errors in your Solid Errors dashboard
|
|
94
|
+
test_files: []
|