rails-informant 0.6.2 → 0.7.1

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +92 -2
  3. data/app/assets/javascripts/rails_informant.js +238 -0
  4. data/app/controllers/concerns/rails_informant/security_headers.rb +18 -0
  5. data/app/controllers/rails_informant/api/base_controller.rb +8 -8
  6. data/app/controllers/rails_informant/api/errors_controller.rb +2 -0
  7. data/app/controllers/rails_informant/api/occurrences_controller.rb +2 -0
  8. data/app/controllers/rails_informant/api/status_controller.rb +11 -1
  9. data/app/controllers/rails_informant/browser_reports_controller.rb +82 -0
  10. data/app/helpers/rails_informant/browser_helper.rb +34 -0
  11. data/app/models/rails_informant/error_group.rb +17 -0
  12. data/app/models/rails_informant/occurrence.rb +2 -0
  13. data/config/routes.rb +4 -0
  14. data/db/migrate/20260227000000_create_informant_tables.rb +4 -0
  15. data/lib/generators/rails_informant/browser_generator.rb +103 -0
  16. data/lib/generators/rails_informant/install_generator.rb +3 -0
  17. data/lib/generators/rails_informant/templates/add_browser_capture_to_informant_tables.rb.erb +33 -0
  18. data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +4 -0
  19. data/lib/generators/rails_informant/templates/initializer.rb.erb +6 -0
  20. data/lib/rails_informant/browser_context.rb +48 -0
  21. data/lib/rails_informant/browser_error.rb +14 -0
  22. data/lib/rails_informant/browser_fingerprint.rb +21 -0
  23. data/lib/rails_informant/browser_report.rb +102 -0
  24. data/lib/rails_informant/configuration.rb +5 -1
  25. data/lib/rails_informant/context_builder.rb +25 -2
  26. data/lib/rails_informant/current.rb +1 -1
  27. data/lib/rails_informant/engine.rb +6 -0
  28. data/lib/rails_informant/error_recorder.rb +77 -13
  29. data/lib/rails_informant/event.rb +4 -3
  30. data/lib/rails_informant/fingerprint.rb +2 -0
  31. data/lib/rails_informant/javascript_stack.rb +93 -0
  32. data/lib/rails_informant/mcp/client.rb +4 -4
  33. data/lib/rails_informant/mcp/server.rb +20 -0
  34. data/lib/rails_informant/mcp/tools/get_informant_status.rb +1 -1
  35. data/lib/rails_informant/mcp/tools/list_errors.rb +1 -0
  36. data/lib/rails_informant/mcp/tools/list_occurrences.rb +1 -0
  37. data/lib/rails_informant/notifiers/slack.rb +22 -8
  38. data/lib/rails_informant/notifiers/webhook.rb +1 -0
  39. data/lib/rails_informant.rb +32 -4
  40. data/skills/informant/SKILL.md +12 -0
  41. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f1d6f0b5274e2da8e271fdc47352ff3c35f181e6b799b64a8f5273853b07139
4
- data.tar.gz: 165015e620935cca923dc3fbf89b049d587666d4047bb8fafc4b73049be50631
3
+ metadata.gz: 6d4058b109f43e27c16dd253fad646852a4a8e50384def456bba81c999f12fca
4
+ data.tar.gz: 7cefef2d44178f325a513f5bd442729289426ec346e0c65fa3e89d3c16b30c59
5
5
  SHA512:
6
- metadata.gz: aa70bfe035cf0cb73d4aa8a9976c558ab5977ca858fdb526613e55e727f72261b0410143678a6eb523071fce99e4758f598f307727b2e51336adf4c8336a214e
7
- data.tar.gz: a613e453aa984669d16c5c74ddbd8772faacbde476f341f2f94c405d6dea27db3b6a6bdc7115a2a54c38a74da26e33da74fe5412c8faf87cc294743e0c62c6db
6
+ metadata.gz: 1bf1bec5c0b667a8c0b57242bc8f47d27feba3db00bd35fe2c091446e267ff567cd6ce35bdff9b4cf050ac1cf940edb166d4b45f93048ee9041c0081bb6eed0b
7
+ data.tar.gz: 2280af8c61cd35735c879d7b8f12a0cfd744a16a27dfb760b6a0a7c8bffb93cbc5a75af01adc4c19ddcc2e257fc61f57630d04e6b12be78a3f0df8db9f5abbe1
data/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  <a href="#quick-start">Quick Start</a>
15
15
  &#9670; <a href="#configuration">Configuration</a>
16
16
  &#9670; <a href="#noise-suppression">Noise Suppression</a>
17
+ &#9670; <a href="#browser-errors">Browser Errors</a>
17
18
  &#9670; <a href="#mcp-server">MCP Server</a>
18
19
  &#9670; <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,6 +96,8 @@ 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) |
@@ -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
- - No built-in rate limiting -- use [Rack::Attack](https://github.com/rack/rack-attack) on `/informant/`
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
- before_action :set_security_headers
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, instance_accessor: false
17
+
18
+ include SecurityHeaders
19
+
20
+ # Parameter wrapping would parse the body before the size check.
21
+ wrap_parameters false
22
+
23
+ before_action :verify_same_origin!
24
+ rate_limit to: 30, within: 1.minute, store: LiveCacheStore, with: -> { head :too_many_requests }
25
+ before_action :require_browser_schema!
26
+ before_action { Current.ingesting_browser_report = true }
27
+
28
+ def create
29
+ body = request.body.read(MAX_BODY_BYTES + 1).to_s
30
+ return head :content_too_large if body.bytesize > MAX_BODY_BYTES
31
+
32
+ reports = parse_reports(body) or return head :bad_request
33
+
34
+ reports.first(MAX_REPORTS).each do |report|
35
+ next unless report.is_a?(Hash) && report.key?("name") && report.key?("message")
36
+
37
+ ErrorRecorder.record_browser report,
38
+ base_url: request.base_url,
39
+ context: BrowserContext.verify(report["context"]),
40
+ remote_ip: request.remote_ip,
41
+ user_agent: request.user_agent
42
+ end
43
+
44
+ head :no_content
45
+ end
46
+
47
+ private
48
+
49
+ # Instrumentation parses a JSON body and logs it as Parameters before any
50
+ # callback runs, so a declared oversize body is refused ahead of it.
51
+ def process_action(...)
52
+ return head :content_too_large if request.content_length.to_i > MAX_BODY_BYTES
53
+
54
+ super
55
+ end
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 self.class.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