rails-informant 0.6.1 → 0.7.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 +4 -4
- data/README.md +93 -3
- data/app/assets/javascripts/rails_informant.js +238 -0
- data/app/controllers/concerns/rails_informant/security_headers.rb +18 -0
- data/app/controllers/rails_informant/api/base_controller.rb +8 -8
- data/app/controllers/rails_informant/api/errors_controller.rb +2 -0
- data/app/controllers/rails_informant/api/occurrences_controller.rb +2 -0
- data/app/controllers/rails_informant/api/status_controller.rb +11 -1
- data/app/controllers/rails_informant/browser_reports_controller.rb +82 -0
- data/app/helpers/rails_informant/browser_helper.rb +34 -0
- data/app/models/rails_informant/error_group.rb +17 -0
- data/app/models/rails_informant/occurrence.rb +2 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260227000000_create_informant_tables.rb +4 -0
- data/lib/generators/rails_informant/browser_generator.rb +103 -0
- data/lib/generators/rails_informant/install_generator.rb +3 -0
- data/lib/generators/rails_informant/templates/add_browser_capture_to_informant_tables.rb.erb +33 -0
- data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +4 -0
- data/lib/generators/rails_informant/templates/initializer.rb.erb +6 -0
- data/lib/rails_informant/browser_context.rb +48 -0
- data/lib/rails_informant/browser_error.rb +14 -0
- data/lib/rails_informant/browser_fingerprint.rb +21 -0
- data/lib/rails_informant/browser_report.rb +102 -0
- data/lib/rails_informant/configuration.rb +5 -1
- data/lib/rails_informant/context_builder.rb +30 -3
- data/lib/rails_informant/current.rb +1 -1
- data/lib/rails_informant/engine.rb +13 -1
- data/lib/rails_informant/error_recorder.rb +77 -13
- data/lib/rails_informant/error_subscriber.rb +3 -1
- data/lib/rails_informant/event.rb +4 -3
- data/lib/rails_informant/fingerprint.rb +2 -0
- data/lib/rails_informant/javascript_stack.rb +93 -0
- data/lib/rails_informant/job_execution_context.rb +11 -0
- data/lib/rails_informant/mcp/client.rb +4 -4
- data/lib/rails_informant/mcp/server.rb +20 -0
- data/lib/rails_informant/mcp/tools/get_informant_status.rb +1 -1
- data/lib/rails_informant/mcp/tools/list_errors.rb +1 -0
- data/lib/rails_informant/mcp/tools/list_occurrences.rb +1 -0
- data/lib/rails_informant/notifiers/slack.rb +22 -8
- data/lib/rails_informant/notifiers/webhook.rb +1 -0
- data/lib/rails_informant.rb +34 -5
- data/skills/informant/SKILL.md +12 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85905d22093388e09e56afd089dcd870c3d33d066c51c8d0b4bb27beb1567a00
|
|
4
|
+
data.tar.gz: d2053f3a5655d02bf059d4876d268985017b675d49a7284aba7fb8f1f23aa2fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f961c33f024b36c00bd48f9c5356c311a81a08ebd6c1131f2e7a2a9f25d7810b29eae1d305530a95c87e131cdeb428c42f859f0170d88041274dc9443b9be41f
|
|
7
|
+
data.tar.gz: e3cba73cd5fde5af8368888a20668175f7ef8d47bca2755eab2837ceefaca1ec198c57e6ff06107dad7045e119ea00c0c42943d48773b110344dd8168635313e
|
data/README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="#quick-start">Quick Start</a>
|
|
15
15
|
◆ <a href="#configuration">Configuration</a>
|
|
16
16
|
◆ <a href="#noise-suppression">Noise Suppression</a>
|
|
17
|
+
◆ <a href="#browser-errors">Browser Errors</a>
|
|
17
18
|
◆ <a href="#mcp-server">MCP Server</a>
|
|
18
19
|
◆ <a href="#data-and-privacy">Data and Privacy</a>
|
|
19
20
|
</p>
|
|
@@ -65,6 +66,13 @@ bin/rails generate rails_informant:skill
|
|
|
65
66
|
|
|
66
67
|
With [Rails Hyperdrive](https://github.com/rails-hyperdrive/rails-hyperdrive), the `/informant` skill arrives through `bin/rails hyperdrive:sync` instead, and the generator installs the rest (see [With Rails Hyperdrive](#with-rails-hyperdrive)).
|
|
67
68
|
|
|
69
|
+
Optionally, capture JavaScript errors from your pages too (see [Browser Errors](#browser-errors)):
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
bin/rails generate rails_informant:browser
|
|
73
|
+
bin/rails db:migrate
|
|
74
|
+
```
|
|
75
|
+
|
|
68
76
|
Errors are captured automatically. To capture manually:
|
|
69
77
|
|
|
70
78
|
```ruby
|
|
@@ -88,12 +96,14 @@ Every option can be set via an environment variable. The initializer takes prece
|
|
|
88
96
|
| Option | Env var | Default | Description |
|
|
89
97
|
|--------|---------|---------|-------------|
|
|
90
98
|
| `api_token` | `INFORMANT_API_TOKEN` | `nil` | Authentication token for API/MCP access |
|
|
99
|
+
| `browser_new_group_limit` | `INFORMANT_BROWSER_NEW_GROUP_LIMIT` | `50` | Most new browser error groups created per hour; reports that would start another are dropped |
|
|
100
|
+
| `capture_browser_errors` | `INFORMANT_CAPTURE_BROWSER_ERRORS` | `false` | Accept JavaScript error reports from your pages |
|
|
91
101
|
| `capture_errors` | `INFORMANT_CAPTURE_ERRORS` | `true` | Enable/disable error capture |
|
|
92
102
|
| `capture_runner_errors` | `INFORMANT_CAPTURE_RUNNER_ERRORS` | `true` | Capture errors raised under `rails runner` |
|
|
93
103
|
| `capture_user_email` | _(none)_ | `false` | Capture email from detected user (PII -- opt-in) |
|
|
94
104
|
| `ignored_exceptions` | `INFORMANT_IGNORED_EXCEPTIONS` | `[]` | Exception classes to skip (walks cause chain) |
|
|
95
105
|
| `ignored_paths` | `INFORMANT_IGNORED_PATHS` | `[]` | Request paths to skip (exact or segment match) |
|
|
96
|
-
| `job_attempt_threshold` | `INFORMANT_JOB_ATTEMPT_THRESHOLD` | `nil` | Suppress job
|
|
106
|
+
| `job_attempt_threshold` | `INFORMANT_JOB_ATTEMPT_THRESHOLD` | `nil` | Suppress job failures until the Nth attempt (handled reports are always recorded) |
|
|
97
107
|
| `retention_days` | `INFORMANT_RETENTION_DAYS` | `nil` | Auto-purge resolved errors after N days |
|
|
98
108
|
| `slack_webhook_url` | `INFORMANT_SLACK_WEBHOOK_URL` | `nil` | Slack incoming webhook URL |
|
|
99
109
|
| `spike_protection` | _(none)_ | `nil` | Rate-limit per error group: `{ threshold: 50, window: 1.minute }` |
|
|
@@ -164,7 +174,7 @@ config.before_record do |event|
|
|
|
164
174
|
end
|
|
165
175
|
```
|
|
166
176
|
|
|
167
|
-
The `event` exposes: `error`, `error_class`, `message`, `severity`, `controller_action`, `job_class`, `request_path`, `fingerprint`. Callbacks that raise are logged and skipped.
|
|
177
|
+
The `event` exposes: `error`, `error_class`, `message`, `severity`, `controller_action`, `job_class`, `request_path`, `source` (`"server"` or `"browser"`), `fingerprint`. Callbacks that raise are logged and skipped.
|
|
168
178
|
|
|
169
179
|
### Custom Exception Context
|
|
170
180
|
|
|
@@ -191,6 +201,73 @@ curl -X POST https://myapp.com/informant/api/v1/deploy \
|
|
|
191
201
|
|
|
192
202
|
Resolved errors automatically reopen on regression. Also available as the `notify_deploy` MCP tool.
|
|
193
203
|
|
|
204
|
+
## Browser Errors
|
|
205
|
+
|
|
206
|
+
Informant can also record JavaScript errors from your pages, so the agent triages them with the same tools and lifecycle as server errors. It is off by default.
|
|
207
|
+
|
|
208
|
+
### What is captured
|
|
209
|
+
|
|
210
|
+
- Uncaught errors and unhandled promise rejections, including errors thrown in Stimulus controllers
|
|
211
|
+
- Turbo failures that point to an app defect, such as a frame response missing its matching frame. Network failures (offline, aborted requests) are not reported.
|
|
212
|
+
|
|
213
|
+
Identical errors are reported once per short window, and each page view has a report budget that resets on every Turbo visit. Cross-origin `Script error.`, the ResizeObserver loop warning, and errors raised by browser extensions or third-party scripts are dropped.
|
|
214
|
+
|
|
215
|
+
Browser groups are named `JS::<Name>` (for example `JS::TypeError`) and tagged with `source: "browser"`. They are grouped by error name and the first frame in your own JavaScript (files under `app/javascript/`), so a new asset digest, host, line or column does not start a new group. The message is used only when the stack has no app frame. Frames name your source files, not digested asset URLs.
|
|
216
|
+
|
|
217
|
+
Browser reports go through the same `ignored_exceptions` (use the `JS::` name), `ignored_paths` (matched against the page path), `before_record` callbacks, spike protection and notifications as server errors.
|
|
218
|
+
|
|
219
|
+
A resolved browser group reopens only on a report from a page rendered after it was resolved. A report from an older open tab, or one with no verifiable render time, does not reopen it.
|
|
220
|
+
|
|
221
|
+
### Setup
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
bin/rails generate rails_informant:browser
|
|
225
|
+
bin/rails db:migrate
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Then restart the app. The generator is safe to re-run. It:
|
|
229
|
+
|
|
230
|
+
- writes a migration adding the browser columns (skipped when they already exist)
|
|
231
|
+
- adds `pin "rails_informant"` to `config/importmap.rb`
|
|
232
|
+
- inserts `<%= informant_browser_tag %>` before `javascript_importmap_tags` in `app/views/layouts/application.html.erb`
|
|
233
|
+
- sets `config.capture_browser_errors = true` in the initializer
|
|
234
|
+
|
|
235
|
+
The tag renders nothing while capture is off, so it can stay in the layout.
|
|
236
|
+
|
|
237
|
+
### Requirements and caveats
|
|
238
|
+
|
|
239
|
+
- Supported setup: importmap and Propshaft, with your JavaScript served from the same origin as the app. Bundled or minified builds (jsbundling, esbuild, Vite) and frontends served from another origin are not supported.
|
|
240
|
+
- Content Security Policy: the tag's inline script carries the request's CSP nonce, and `connect-src 'self'` already covers the endpoint.
|
|
241
|
+
- The tag finds the endpoint through the engine's `rails_informant` route helper. Mounting the engine with a custom `as:` name renames that helper and breaks the tag.
|
|
242
|
+
- Mounting the engine inside an authentication constraint blocks reports from signed-out pages.
|
|
243
|
+
- Until the migration has run, the endpoint answers 503 and logs which generator to run.
|
|
244
|
+
- Keep `informant_browser_tag` out of cached output (HTTP or CDN page caching, or a cached layout fragment). The tag carries the signed-in user for the request that rendered it, so a cached copy attributes every visitor's errors to that user for up to 24 hours.
|
|
245
|
+
- On PostgreSQL with a very large `informant_occurrences` table, the migration's index builds block error recording while they run. Run it in a quiet window, or change the generated migration to add the indexes with `algorithm: :concurrently` (and `disable_ddl_transaction!`).
|
|
246
|
+
|
|
247
|
+
### The endpoint
|
|
248
|
+
|
|
249
|
+
The client posts to `POST <mount path>/browser_reports` (for example `/informant/browser_reports`). The route exists only while `capture_browser_errors` is on. It takes no token: it accepts only same-origin requests, checked with `Sec-Fetch-Site` and, when a browser does not send that header, `Origin`.
|
|
250
|
+
|
|
251
|
+
Each request is bounded:
|
|
252
|
+
|
|
253
|
+
- 30 requests per minute per IP, via Rails `rate_limit` on your app's `Rails.cache`. Under `:null_store` the limit does nothing. Behind a proxy or CDN that Rails does not trust (see `config.action_dispatch.trusted_proxies`), every visitor has the same IP and shares one limit.
|
|
254
|
+
- 64 KB request body and 10 reports per request
|
|
255
|
+
- `browser_new_group_limit` new browser groups per hour. Once the cap is full, Informant records a `RailsInformant::BrowserGroupLimitReached` warning group, which alerts like any other error, so a flood of noise cannot silently hide a new bug.
|
|
256
|
+
|
|
257
|
+
### What the client sends
|
|
258
|
+
|
|
259
|
+
- Error name, message and stack
|
|
260
|
+
- The page URL, without its fragment
|
|
261
|
+
- The last 20 breadcrumbs: Turbo visits, clicks, and failing requests with their status and `X-Request-Id`. A click is recorded by element identity (tag, `id`, `data-controller`, `data-action`) and never by its text.
|
|
262
|
+
- The request ID of the last failing request
|
|
263
|
+
- An encrypted context the server wrote into the page when it rendered it: the user, the rendering request's ID, the app revision and the render time. The page cannot read or change it.
|
|
264
|
+
|
|
265
|
+
The user on a browser occurrence always comes from that server-written context. Nothing in a report can set it.
|
|
266
|
+
|
|
267
|
+
### Following a browser error to the server
|
|
268
|
+
|
|
269
|
+
A browser occurrence records `request_id` (the request that rendered the page) and `failing_request_id` (the last request that failed). The `list_occurrences` MCP tool takes a `request_id` filter that matches either, so the agent can find the server occurrence for the same request. A miss is normal: the request may have raised nothing on the server, and cooldown and trimming drop repeat server occurrences. `list_errors` takes a `source` filter (`browser` or `server`) and lists both by default.
|
|
270
|
+
|
|
194
271
|
## MCP Server
|
|
195
272
|
|
|
196
273
|
The bundled `informant-mcp` executable connects Claude Code to your error data via [Model Context Protocol](https://modelcontextprotocol.io).
|
|
@@ -310,13 +387,26 @@ Occurrences store: user ID (always), email (opt-in via `capture_user_email`), IP
|
|
|
310
387
|
RailsInformant::Current.user_context = { id: current_user.id }
|
|
311
388
|
```
|
|
312
389
|
|
|
390
|
+
Browser occurrences store the same user context, taken from the page's encrypted render-time context rather than from the report. Their page URL, breadcrumbs and context pass through `filter_parameters` like server request data, and click breadcrumbs never include visible text.
|
|
391
|
+
|
|
313
392
|
## Security
|
|
314
393
|
|
|
315
394
|
- Token authentication (`secure_compare`), HTTPS enforced by default
|
|
316
395
|
- All context filtered through `ActiveSupport::ParameterFilter`
|
|
317
396
|
- Security headers: `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`
|
|
318
397
|
- Error capture never breaks the host application
|
|
319
|
-
-
|
|
398
|
+
- The browser endpoint (`/informant/browser_reports`, only while `capture_browser_errors` is on) is unauthenticated: it accepts same-origin requests only and is rate limited per IP with Rails `rate_limit`. A scripted client can forge those headers, so every report is size-bounded and new browser groups are capped per hour.
|
|
399
|
+
- Text in browser groups (message, frames, breadcrumbs, URLs) is written by anonymous visitors. The MCP instructions and skill tell the agent to treat it as data, never as instructions.
|
|
400
|
+
- The API has no built-in rate limiting -- use [Rack::Attack](https://github.com/rack/rack-attack) on `/informant/api/`
|
|
401
|
+
|
|
402
|
+
## Development
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
bin/test # Ruby suite
|
|
406
|
+
node --test 'test/javascript/*.test.js' # browser client
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The JavaScript tests use Node's built-in runner with no npm dependencies, and need Node 22.12 or later. Pass the quoted glob: Node 24 does not run a bare directory argument.
|
|
320
410
|
|
|
321
411
|
## License
|
|
322
412
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
const BUDGET = 10
|
|
2
|
+
const CRUMBS = 20
|
|
3
|
+
const DEDUPE_MS = 5000
|
|
4
|
+
const FLUSH_MS = 1000
|
|
5
|
+
// The server rejects a body over 64 KB whole, and browsers cap keepalive and beacon bodies at 64 KiB.
|
|
6
|
+
const MAX_BODY = 60000
|
|
7
|
+
const MAX_REPORTS = 10
|
|
8
|
+
// Turbo's minified build mangles the error class name, so its rejection is recognised by this text.
|
|
9
|
+
const FRAME_MISSING = 'did not contain the expected <turbo-frame'
|
|
10
|
+
const V8_FRAME = /^\s*at (?:.+? \()?(\S+?):\d+:\d+\)?$/
|
|
11
|
+
const FIREFOX_FRAME = /^\s*[^@]*@(.+):\d+:\d+$/
|
|
12
|
+
|
|
13
|
+
const clip = (value, length) => String(value ?? '').slice(0, length)
|
|
14
|
+
const frameUrl = line => (line.match(V8_FRAME) || line.match(FIREFOX_FRAME))?.[1]
|
|
15
|
+
const topFrame = stack => String(stack).split('\n').find(frameUrl) || ''
|
|
16
|
+
const withoutFragment = href => href.split('#')[0]
|
|
17
|
+
const meta = (document, name) => document.querySelector(`meta[name="${name}"]`)?.content
|
|
18
|
+
|
|
19
|
+
export const isNoise = ({ error, message, stack }, origin) => {
|
|
20
|
+
if (!error && /^Script error\.?$/.test(message)) return true
|
|
21
|
+
if (/ResizeObserver loop/.test(message)) return true
|
|
22
|
+
const url = frameUrl(topFrame(stack))
|
|
23
|
+
return Boolean(url) && !url.startsWith(`${origin}/`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const signature = ({ message, name, stack }) => `${name}|${message}|${topFrame(stack)}`
|
|
27
|
+
|
|
28
|
+
export const classifyFrameMissing = response => {
|
|
29
|
+
if (response.redirected) return 'drop'
|
|
30
|
+
return response.ok ? 'report' : 'breadcrumb'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const chooseTransport = (trigger, navigator) =>
|
|
34
|
+
(trigger === 'pagehide' || trigger === 'visibilitychange') && navigator.sendBeacon ? 'beacon' : 'fetch'
|
|
35
|
+
|
|
36
|
+
export const buildReport = (kind, { message, name, stack }, { breadcrumbs, context, failingRequestId, url }) => ({
|
|
37
|
+
breadcrumbs: breadcrumbs.slice(-CRUMBS),
|
|
38
|
+
context,
|
|
39
|
+
failing_request_id: failingRequestId,
|
|
40
|
+
kind,
|
|
41
|
+
message: clip(message, 1000),
|
|
42
|
+
name: clip(name, 100),
|
|
43
|
+
occurred_at: new Date().toISOString(),
|
|
44
|
+
stack: String(stack).split('\n').slice(0, 50).join('\n').slice(0, 20000),
|
|
45
|
+
url: clip(withoutFragment(url), 2000)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export class Budget {
|
|
49
|
+
#left = BUDGET
|
|
50
|
+
|
|
51
|
+
take () {
|
|
52
|
+
if (this.#left === 0) return false
|
|
53
|
+
this.#left--
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reset () {
|
|
58
|
+
this.#left = BUDGET
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Breadcrumbs {
|
|
63
|
+
#crumbs = []
|
|
64
|
+
failingRequestId = null
|
|
65
|
+
|
|
66
|
+
add (category, message, metadata = {}) {
|
|
67
|
+
this.#crumbs.push({ category, duration: null, message: clip(message, 500), metadata, timestamp: new Date().toISOString() })
|
|
68
|
+
if (this.#crumbs.length > CRUMBS) this.#crumbs.shift()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A Turbo request is seen by both the fetch wrapper and Turbo's own events.
|
|
72
|
+
fetchError (message, status, requestId) {
|
|
73
|
+
const last = this.#crumbs.at(-1)
|
|
74
|
+
const seen = requestId
|
|
75
|
+
? this.#crumbs.some(it => it.metadata.request_id === requestId)
|
|
76
|
+
: last?.category === 'fetch.error' && last.message === clip(message, 500) && last.metadata.status === status
|
|
77
|
+
if (seen) return
|
|
78
|
+
this.add('fetch.error', message, { request_id: requestId, status })
|
|
79
|
+
if (requestId) this.failingRequestId = requestId
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
all () {
|
|
83
|
+
return [...this.#crumbs]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const describe = (value, fallback) => value && typeof value === 'object'
|
|
88
|
+
? { error: value, message: String(value.message ?? value), name: String(value.name || 'Error'), stack: String(value.stack || '') }
|
|
89
|
+
: { error: value, message: String(value ?? fallback), name: 'Error', stack: '' }
|
|
90
|
+
|
|
91
|
+
export function start (win) {
|
|
92
|
+
const { document, location, navigator } = win
|
|
93
|
+
const budget = new Budget()
|
|
94
|
+
const crumbs = new Breadcrumbs()
|
|
95
|
+
const networkFailures = new WeakSet()
|
|
96
|
+
const seen = new Map()
|
|
97
|
+
const send = win.fetch
|
|
98
|
+
const state = win.__railsInformant
|
|
99
|
+
let bytes = 0
|
|
100
|
+
let queue = []
|
|
101
|
+
let timer
|
|
102
|
+
|
|
103
|
+
const path = href => {
|
|
104
|
+
const url = new URL(href, location.href)
|
|
105
|
+
return (url.origin === location.origin ? '' : url.origin) + url.pathname + url.search
|
|
106
|
+
}
|
|
107
|
+
const endpoint = () => meta(document, 'informant-browser-endpoint')
|
|
108
|
+
const isEndpoint = href => withoutFragment(new URL(href, location.href).href).startsWith(new URL(endpoint(), location.href).href)
|
|
109
|
+
|
|
110
|
+
const flush = trigger => {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
if (!queue.length) return
|
|
113
|
+
const body = JSON.stringify({ v: 1, reports: queue })
|
|
114
|
+
queue = []
|
|
115
|
+
bytes = 0
|
|
116
|
+
if (chooseTransport(trigger, navigator) === 'beacon') {
|
|
117
|
+
navigator.sendBeacon(endpoint(), new Blob([body], { type: 'text/plain' }))
|
|
118
|
+
} else {
|
|
119
|
+
// A failed send must not surface as an unhandled rejection, which would be reported in turn.
|
|
120
|
+
send.call(win, endpoint(), {
|
|
121
|
+
body,
|
|
122
|
+
credentials: 'same-origin',
|
|
123
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
124
|
+
keepalive: true,
|
|
125
|
+
method: 'POST'
|
|
126
|
+
}).catch(() => {})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const enqueue = report => {
|
|
131
|
+
const size = new TextEncoder().encode(JSON.stringify(report)).length + 1
|
|
132
|
+
if (queue.length && (bytes + size > MAX_BODY || queue.length >= MAX_REPORTS)) flush('size')
|
|
133
|
+
queue.push(report)
|
|
134
|
+
bytes += size
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
timer = setTimeout(() => flush('timer'), FLUSH_MS)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const capture = (kind, details) => {
|
|
140
|
+
if (isNoise(details, location.origin)) return
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
for (const [key, at] of seen) if (now - at >= DEDUPE_MS) seen.delete(key)
|
|
143
|
+
const key = signature(details)
|
|
144
|
+
if (seen.has(key)) return
|
|
145
|
+
seen.set(key, now)
|
|
146
|
+
if (!budget.take()) return
|
|
147
|
+
enqueue(buildReport(kind, details, {
|
|
148
|
+
breadcrumbs: crumbs.all(),
|
|
149
|
+
context: meta(document, 'informant-browser'),
|
|
150
|
+
failingRequestId: crumbs.failingRequestId,
|
|
151
|
+
url: location.href
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const onError = (message, source, lineno, colno, error) => {
|
|
156
|
+
const details = describe(error, message)
|
|
157
|
+
if (!details.stack && source) details.stack = `@${source}:${lineno}:${colno}`
|
|
158
|
+
capture('error', details)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Turbo rethrows network failures and frame-missing errors from un-awaited promises; its events already classified them.
|
|
162
|
+
const onRejection = reason => {
|
|
163
|
+
if (networkFailures.has(reason)) return
|
|
164
|
+
const details = describe(reason)
|
|
165
|
+
if (!details.message.includes(FRAME_MISSING)) capture('rejection', details)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (state) win.removeEventListener('unhandledrejection', state.onrejection)
|
|
169
|
+
const previous = state && win.onerror === state.onerror ? state.previousOnerror : win.onerror
|
|
170
|
+
win.onerror = (...args) => {
|
|
171
|
+
onError(...args)
|
|
172
|
+
return previous ? previous.apply(win, args) : false
|
|
173
|
+
}
|
|
174
|
+
win.addEventListener('unhandledrejection', event => onRejection(event.reason))
|
|
175
|
+
|
|
176
|
+
win.fetch = async function (input, init) {
|
|
177
|
+
const href = String(input?.url ?? input)
|
|
178
|
+
try {
|
|
179
|
+
const response = await send.call(win, input, init)
|
|
180
|
+
if (response.status >= 500 && !isEndpoint(href)) crumbs.fetchError(path(href), response.status, response.headers.get('X-Request-Id'))
|
|
181
|
+
return response
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error?.name !== 'AbortError' && !isEndpoint(href)) crumbs.fetchError(path(href), null, null)
|
|
184
|
+
throw error
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
document.addEventListener('click', ({ target }) => {
|
|
189
|
+
if (!target?.closest) return
|
|
190
|
+
const element = target.closest('[data-action],a,button,input,select,textarea,label') || target
|
|
191
|
+
const tag = element.tagName.toLowerCase()
|
|
192
|
+
const metadata = { tag }
|
|
193
|
+
if (element.id) metadata.id = clip(element.id, 100)
|
|
194
|
+
const action = element.closest('[data-action]')?.getAttribute('data-action')
|
|
195
|
+
const controller = element.closest('[data-controller]')?.getAttribute('data-controller')
|
|
196
|
+
if (action) metadata.action = clip(action, 200)
|
|
197
|
+
if (controller) metadata.controller = clip(controller, 200)
|
|
198
|
+
crumbs.add('ui.click', element.id ? `${tag}#${metadata.id}` : tag, metadata)
|
|
199
|
+
}, true)
|
|
200
|
+
|
|
201
|
+
document.addEventListener('turbo:visit', ({ detail }) => crumbs.add('turbo.visit', path(detail.url), { action: detail.action }))
|
|
202
|
+
document.addEventListener('turbo:load', ({ detail }) => {
|
|
203
|
+
budget.reset()
|
|
204
|
+
crumbs.add('turbo.visit', path(detail?.url ?? location.href))
|
|
205
|
+
})
|
|
206
|
+
document.addEventListener('turbo:before-fetch-response', ({ detail: { fetchResponse } }) => {
|
|
207
|
+
const { response, statusCode } = fetchResponse
|
|
208
|
+
if (statusCode >= 500) crumbs.fetchError(path(response.url), statusCode, response.headers.get('X-Request-Id'))
|
|
209
|
+
})
|
|
210
|
+
document.addEventListener('turbo:fetch-request-error', ({ detail }) => {
|
|
211
|
+
networkFailures.add(detail.error)
|
|
212
|
+
crumbs.fetchError(path(detail.request.url.href), null, null)
|
|
213
|
+
})
|
|
214
|
+
document.addEventListener('turbo:frame-missing', ({ detail: { response }, target }) => {
|
|
215
|
+
const verdict = classifyFrameMissing(response)
|
|
216
|
+
if (verdict === 'report') {
|
|
217
|
+
capture('turbo', {
|
|
218
|
+
message: `The response (${response.status}) did not contain the expected <turbo-frame id="${target.id}">`,
|
|
219
|
+
name: 'TurboFrameMissingError',
|
|
220
|
+
stack: ''
|
|
221
|
+
})
|
|
222
|
+
} else if (verdict === 'breadcrumb') {
|
|
223
|
+
crumbs.fetchError(path(response.url), response.status, response.headers.get('X-Request-Id'))
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
win.addEventListener('pagehide', () => flush('pagehide'))
|
|
228
|
+
document.addEventListener('visibilitychange', () => {
|
|
229
|
+
if (document.visibilityState === 'hidden') flush('visibilitychange')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (state) {
|
|
233
|
+
for (const item of state.queue) item.type === 'error' ? onError(...item.args) : onRejection(item.reason)
|
|
234
|
+
delete win.__railsInformant
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (typeof document !== 'undefined' && document.querySelector('meta[name="informant-browser"]')) start(window)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
module SecurityHeaders
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
before_action :set_security_headers
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def set_security_headers
|
|
12
|
+
response.headers["Cache-Control"] = "no-store"
|
|
13
|
+
response.headers["Content-Security-Policy"] = "default-src 'none'"
|
|
14
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
15
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -2,7 +2,7 @@ module RailsInformant
|
|
|
2
2
|
module Api
|
|
3
3
|
class BaseController < ActionController::API
|
|
4
4
|
before_action :authenticate_token!
|
|
5
|
-
|
|
5
|
+
include SecurityHeaders
|
|
6
6
|
|
|
7
7
|
rescue_from RailsInformant::InvalidParameterError do |e|
|
|
8
8
|
render json: { error: e.message }, status: :bad_request
|
|
@@ -18,6 +18,13 @@ module RailsInformant
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
def require_browser_schema_for!(param)
|
|
22
|
+
return if RailsInformant.browser_schema_ready?
|
|
23
|
+
|
|
24
|
+
raise RailsInformant::InvalidParameterError,
|
|
25
|
+
"#{param} needs the browser capture columns: run `bin/rails generate rails_informant:browser` and migrate"
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def parse_time(value)
|
|
22
29
|
Time.parse(value)
|
|
23
30
|
rescue ArgumentError
|
|
@@ -39,13 +46,6 @@ module RailsInformant
|
|
|
39
46
|
end
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
def set_security_headers
|
|
43
|
-
response.headers["Cache-Control"] = "no-store"
|
|
44
|
-
response.headers["Content-Security-Policy"] = "default-src 'none'"
|
|
45
|
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
46
|
-
response.headers["X-Frame-Options"] = "DENY"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
49
|
def paginate(scope, only:)
|
|
50
50
|
page = [ params.fetch(:page, 1).to_i, 1 ].max
|
|
51
51
|
per_page = [ [ params.fetch(:per_page, 20).to_i, 1 ].max, 100 ].min
|
|
@@ -4,12 +4,14 @@ module RailsInformant
|
|
|
4
4
|
before_action :find_error_group, only: [ :show, :update, :destroy, :fix_pending, :duplicate ]
|
|
5
5
|
|
|
6
6
|
def index
|
|
7
|
+
require_browser_schema_for!("source") if params[:source]
|
|
7
8
|
groups = params[:status] == "duplicate" ? ErrorGroup.all : ErrorGroup.active
|
|
8
9
|
groups = groups
|
|
9
10
|
.by_controller_action(params[:controller_action])
|
|
10
11
|
.by_error_class(params[:error_class])
|
|
11
12
|
.by_job_class(params[:job_class])
|
|
12
13
|
.by_severity(params[:severity])
|
|
14
|
+
.by_source(params[:source])
|
|
13
15
|
.by_status(params[:status])
|
|
14
16
|
.search(params[:q])
|
|
15
17
|
.since(params[:since] && parse_time(params[:since]))
|
|
@@ -2,8 +2,10 @@ module RailsInformant
|
|
|
2
2
|
module Api
|
|
3
3
|
class OccurrencesController < BaseController
|
|
4
4
|
def index
|
|
5
|
+
require_browser_schema_for!("request_id") if params[:request_id]
|
|
5
6
|
occurrences = Occurrence.order(created_at: :desc)
|
|
6
7
|
occurrences = occurrences.where(error_group_id: params[:error_group_id]) if params[:error_group_id]
|
|
8
|
+
occurrences = occurrences.where(request_id: params[:request_id]).or(occurrences.where(failing_request_id: params[:request_id])) if params[:request_id]
|
|
7
9
|
occurrences = occurrences.where(created_at: parse_time(params[:since])..) if params[:since]
|
|
8
10
|
occurrences = occurrences.where(created_at: ..parse_time(params[:until])) if params[:until]
|
|
9
11
|
|
|
@@ -10,12 +10,22 @@ module RailsInformant
|
|
|
10
10
|
resolved_count: counts.fetch("resolved", 0),
|
|
11
11
|
unresolved_count: counts.fetch("unresolved", 0),
|
|
12
12
|
deploy_sha: RailsInformant.current_git_sha,
|
|
13
|
-
top_errors: top_errors
|
|
13
|
+
top_errors: top_errors,
|
|
14
|
+
**browser_group_limit
|
|
14
15
|
}
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
private
|
|
18
19
|
|
|
20
|
+
def browser_group_limit
|
|
21
|
+
return {} unless RailsInformant.config.capture_browser_errors && RailsInformant.browser_schema_ready?
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
browser_groups_created_this_hour: ErrorGroup.browser_created_this_hour.count,
|
|
25
|
+
browser_new_group_limit: RailsInformant.config.browser_new_group_limit
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
def top_errors
|
|
20
30
|
ErrorGroup
|
|
21
31
|
.where(status: "unresolved")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
# Receives reports from the browser module. It does not inherit the API
|
|
3
|
+
# base controller: pages post here with no token, so the trust boundary is
|
|
4
|
+
# the same-origin check, and every field in the body is attacker-controlled.
|
|
5
|
+
class BrowserReportsController < ActionController::API
|
|
6
|
+
MAX_BODY_BYTES = 64.kilobytes
|
|
7
|
+
MAX_REPORTS = 10
|
|
8
|
+
|
|
9
|
+
# rate_limit captures its store when the class loads, so this reads the
|
|
10
|
+
# controller's cache_store per request instead; under a null store the
|
|
11
|
+
# limit is inert.
|
|
12
|
+
module LiveCacheStore
|
|
13
|
+
def self.increment(...) = BrowserReportsController.cache_store.increment(...)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_attribute :schema_warning_logged, default: false
|
|
17
|
+
|
|
18
|
+
include SecurityHeaders
|
|
19
|
+
|
|
20
|
+
# Parameter wrapping would parse the body before the size check.
|
|
21
|
+
wrap_parameters false
|
|
22
|
+
|
|
23
|
+
# Instrumentation parses a JSON body and logs it as Parameters before any
|
|
24
|
+
# callback runs, so a declared oversize body is refused ahead of it.
|
|
25
|
+
def process_action(...)
|
|
26
|
+
return head :content_too_large if request.content_length.to_i > MAX_BODY_BYTES
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
before_action :verify_same_origin!
|
|
32
|
+
rate_limit to: 30, within: 1.minute, store: LiveCacheStore, with: -> { head :too_many_requests }
|
|
33
|
+
before_action :require_browser_schema!
|
|
34
|
+
before_action { Current.ingesting_browser_report = true }
|
|
35
|
+
|
|
36
|
+
def create
|
|
37
|
+
body = request.body.read(MAX_BODY_BYTES + 1).to_s
|
|
38
|
+
return head :content_too_large if body.bytesize > MAX_BODY_BYTES
|
|
39
|
+
|
|
40
|
+
reports = parse_reports(body) or return head :bad_request
|
|
41
|
+
|
|
42
|
+
reports.first(MAX_REPORTS).each do |report|
|
|
43
|
+
next unless report.is_a?(Hash) && report.key?("name") && report.key?("message")
|
|
44
|
+
|
|
45
|
+
ErrorRecorder.record_browser report,
|
|
46
|
+
base_url: request.base_url,
|
|
47
|
+
context: BrowserContext.verify(report["context"]),
|
|
48
|
+
remote_ip: request.remote_ip,
|
|
49
|
+
user_agent: request.user_agent
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
head :no_content
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Browsers set both headers and pages cannot forge them. Without
|
|
58
|
+
# Sec-Fetch-Site (older browsers), Origin must name this app exactly.
|
|
59
|
+
def verify_same_origin!
|
|
60
|
+
fetch_site = request.headers["Sec-Fetch-Site"]
|
|
61
|
+
same_origin = fetch_site ? fetch_site == "same-origin" : request.origin == request.base_url
|
|
62
|
+
head :forbidden unless same_origin
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def require_browser_schema!
|
|
66
|
+
return if RailsInformant.browser_schema_ready?
|
|
67
|
+
|
|
68
|
+
unless schema_warning_logged
|
|
69
|
+
self.class.schema_warning_logged = true
|
|
70
|
+
Rails.logger.warn "[RailsInformant] Browser reports rejected: run `bin/rails generate rails_informant:browser` and migrate to add the browser capture columns"
|
|
71
|
+
end
|
|
72
|
+
head :service_unavailable
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_reports(body)
|
|
76
|
+
payload = JSON.parse(body)
|
|
77
|
+
payload["reports"] if payload.is_a?(Hash) && payload.key?("v") && payload["reports"].is_a?(Array)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
module BrowserHelper
|
|
3
|
+
# Module scripts run in document order, so an app module that throws while
|
|
4
|
+
# evaluating would beat the pinned module to it. This classic script buffers
|
|
5
|
+
# errors from the start and hands them to the module once it loads.
|
|
6
|
+
EARLY_HOOK = <<~JS.freeze
|
|
7
|
+
(() => {
|
|
8
|
+
const state = { queue: [], previousOnerror: window.onerror }
|
|
9
|
+
state.onerror = (...args) => {
|
|
10
|
+
state.queue.push({ type: 'error', args })
|
|
11
|
+
return state.previousOnerror ? state.previousOnerror(...args) : false
|
|
12
|
+
}
|
|
13
|
+
state.onrejection = event => state.queue.push({ type: 'rejection', reason: event.reason })
|
|
14
|
+
window.onerror = state.onerror
|
|
15
|
+
window.addEventListener('unhandledrejection', state.onrejection)
|
|
16
|
+
window.__railsInformant = state
|
|
17
|
+
const load = () => import('rails_informant')
|
|
18
|
+
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', load, { once: true }) : load()
|
|
19
|
+
})()
|
|
20
|
+
JS
|
|
21
|
+
|
|
22
|
+
# The flag is read here rather than at boot because hosts set it in an
|
|
23
|
+
# initializer, which runs after the engine's.
|
|
24
|
+
def informant_browser_tag
|
|
25
|
+
return unless RailsInformant.config.capture_browser_errors
|
|
26
|
+
|
|
27
|
+
safe_join [
|
|
28
|
+
tag.meta(name: "informant-browser", content: RailsInformant::BrowserContext.generate(request)),
|
|
29
|
+
tag.meta(name: "informant-browser-endpoint", content: rails_informant.browser_reports_path),
|
|
30
|
+
javascript_tag(EARLY_HOOK, nonce: true)
|
|
31
|
+
], "\n"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -20,6 +20,7 @@ module RailsInformant
|
|
|
20
20
|
original_sha
|
|
21
21
|
resolved_at
|
|
22
22
|
severity
|
|
23
|
+
source
|
|
23
24
|
status
|
|
24
25
|
total_occurrences
|
|
25
26
|
updated_at
|
|
@@ -53,10 +54,12 @@ module RailsInformant
|
|
|
53
54
|
|
|
54
55
|
scope :active, -> { where.not(status: "duplicate") }
|
|
55
56
|
scope :before, ->(time) { where(last_seen_at: ..time) if time }
|
|
57
|
+
scope :browser_created_this_hour, -> { where(source: "browser", created_at: 1.hour.ago..) }
|
|
56
58
|
scope :by_controller_action, ->(action) { where(controller_action: action) if action }
|
|
57
59
|
scope :by_error_class, ->(error_class) { where(error_class:) if error_class }
|
|
58
60
|
scope :by_job_class, ->(job_class) { where(job_class:) if job_class }
|
|
59
61
|
scope :by_severity, ->(severity) { where(severity:) if severity }
|
|
62
|
+
scope :by_source, ->(source) { where(source:) if source }
|
|
60
63
|
scope :by_status, ->(status) { where(status:) if status }
|
|
61
64
|
scope :search, ->(q) { where(arel_table[:message].matches("%#{sanitize_sql_like(q)}%")) if q }
|
|
62
65
|
scope :since, ->(time) { where(last_seen_at: time..) if time }
|
|
@@ -130,6 +133,20 @@ module RailsInformant
|
|
|
130
133
|
update!(status: "duplicate", duplicate_of: target)
|
|
131
134
|
end
|
|
132
135
|
|
|
136
|
+
# A page rendered before the group was resolved is still running the code
|
|
137
|
+
# the fix replaced, so its errors are not evidence the fix failed.
|
|
138
|
+
# A page with no verified render time could be a forged report, so only a
|
|
139
|
+
# page the server vouches for as newer than the fix may reopen the group.
|
|
140
|
+
# Installs that have not run the browser migration have no source column,
|
|
141
|
+
# and every group they hold is a server group.
|
|
142
|
+
def source
|
|
143
|
+
RailsInformant.browser_schema_ready? ? super : "server"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def rendered_before_resolution?(rendered_at)
|
|
147
|
+
status == "resolved" && (rendered_at.nil? || rendered_at <= resolved_at)
|
|
148
|
+
end
|
|
149
|
+
|
|
133
150
|
def detect_regression!
|
|
134
151
|
return unless status == "resolved"
|
|
135
152
|
|