rails-informant 0.0.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +10 -0
  5. data/VERSION +1 -0
  6. data/app/controllers/rails_informant/api/base_controller.rb +62 -0
  7. data/app/controllers/rails_informant/api/errors_controller.rb +72 -0
  8. data/app/controllers/rails_informant/api/occurrences_controller.rb +14 -0
  9. data/app/controllers/rails_informant/api/status_controller.rb +29 -0
  10. data/app/jobs/rails_informant/application_job.rb +4 -0
  11. data/app/jobs/rails_informant/notify_job.rb +34 -0
  12. data/app/jobs/rails_informant/purge_job.rb +29 -0
  13. data/app/models/rails_informant/application_record.rb +5 -0
  14. data/app/models/rails_informant/error_group.rb +175 -0
  15. data/app/models/rails_informant/occurrence.rb +22 -0
  16. data/config/routes.rb +14 -0
  17. data/db/migrate/20260227000000_create_informant_tables.rb +65 -0
  18. data/exe/informant-mcp +27 -0
  19. data/lib/generators/rails_informant/devin/templates/error-triage.devin.md +48 -0
  20. data/lib/generators/rails_informant/devin_generator.rb +12 -0
  21. data/lib/generators/rails_informant/install_generator.rb +20 -0
  22. data/lib/generators/rails_informant/skill/templates/SKILL.md +168 -0
  23. data/lib/generators/rails_informant/skill_generator.rb +12 -0
  24. data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +55 -0
  25. data/lib/generators/rails_informant/templates/initializer.rb.erb +33 -0
  26. data/lib/rails_informant/breadcrumb_buffer.rb +30 -0
  27. data/lib/rails_informant/breadcrumb_subscriber.rb +51 -0
  28. data/lib/rails_informant/configuration.rb +51 -0
  29. data/lib/rails_informant/context_builder.rb +142 -0
  30. data/lib/rails_informant/context_filter.rb +45 -0
  31. data/lib/rails_informant/current.rb +5 -0
  32. data/lib/rails_informant/engine.rb +86 -0
  33. data/lib/rails_informant/error_recorder.rb +47 -0
  34. data/lib/rails_informant/error_subscriber.rb +17 -0
  35. data/lib/rails_informant/fingerprint.rb +23 -0
  36. data/lib/rails_informant/mcp/base_tool.rb +38 -0
  37. data/lib/rails_informant/mcp/client.rb +123 -0
  38. data/lib/rails_informant/mcp/configuration.rb +90 -0
  39. data/lib/rails_informant/mcp/server.rb +29 -0
  40. data/lib/rails_informant/mcp/tools/annotate_error.rb +25 -0
  41. data/lib/rails_informant/mcp/tools/delete_error.rb +25 -0
  42. data/lib/rails_informant/mcp/tools/get_error.rb +24 -0
  43. data/lib/rails_informant/mcp/tools/get_informant_status.rb +22 -0
  44. data/lib/rails_informant/mcp/tools/ignore_error.rb +24 -0
  45. data/lib/rails_informant/mcp/tools/list_environments.rb +20 -0
  46. data/lib/rails_informant/mcp/tools/list_errors.rb +32 -0
  47. data/lib/rails_informant/mcp/tools/list_occurrences.rb +27 -0
  48. data/lib/rails_informant/mcp/tools/mark_duplicate.rb +25 -0
  49. data/lib/rails_informant/mcp/tools/mark_fix_pending.rb +27 -0
  50. data/lib/rails_informant/mcp/tools/reopen_error.rb +24 -0
  51. data/lib/rails_informant/mcp/tools/resolve_error.rb +24 -0
  52. data/lib/rails_informant/mcp.rb +22 -0
  53. data/lib/rails_informant/middleware/error_capture.rb +28 -0
  54. data/lib/rails_informant/middleware/rescued_exception_interceptor.rb +16 -0
  55. data/lib/rails_informant/notifiers/devin.rb +61 -0
  56. data/lib/rails_informant/notifiers/notification_policy.rb +85 -0
  57. data/lib/rails_informant/notifiers/slack.rb +77 -0
  58. data/lib/rails_informant/notifiers/webhook.rb +31 -0
  59. data/lib/rails_informant/structured_event_subscriber.rb +14 -0
  60. data/lib/rails_informant/version.rb +3 -0
  61. data/lib/rails_informant.rb +147 -0
  62. data/lib/tasks/rails_informant.rake +30 -0
  63. metadata +177 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e238c932add4022daab840ab2144d18caf3f09c4f978c2ffdba125085947c9ad
4
+ data.tar.gz: 68518ff9ebda56a59932163e983316a671070bc006afec073e32965493591446
5
+ SHA512:
6
+ metadata.gz: 3a902d7e2bd33450ddccaa1a380255ae15a3219073c4b75f950114647f1b0349654c20e0eb76758c456b0b2b247bafeb0c661d87935fd1454c6e3481f79478fb
7
+ data.tar.gz: 2b09916e94d366b8aae539104060a0aa14c29277297a827acec2433dadbe69befa2df6fbb0aabc5935e18bf92800435382975426dcc3db8011651f5ce274a6b9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel López Prat
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,340 @@
1
+ <div align="center">
2
+
3
+ <h1 style="margin-top: 10px;">Rails Informant</h1>
4
+
5
+ <h2>Self-hosted error monitoring for Rails, built for AI agents</h2>
6
+
7
+ <div align="center">
8
+ <a href="https://github.com/6temes/rails-informant/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-green"/></a>
9
+ <a href="https://www.ruby-lang.org/"><img alt="Ruby" src="https://img.shields.io/badge/ruby-4.0+-red.svg"/></a>
10
+ <a href="https://rubyonrails.org/"><img alt="Rails" src="https://img.shields.io/badge/rails-8.1+-red.svg"/></a>
11
+ </div>
12
+
13
+ <p>
14
+ <a href="#why-rails-informant">Why Rails Informant?</a>
15
+ &#9670; <a href="#quick-start">Quick Start</a>
16
+ &#9670; <a href="#configuration">Configuration</a>
17
+ &#9670; <a href="#mcp-server">MCP Server</a>
18
+ &#9670; <a href="#architecture">Architecture</a>
19
+ &#9670; <a href="#data--privacy">Data & Privacy</a>
20
+ &#9670; <a href="#security">Security</a>
21
+ </p>
22
+ </div>
23
+
24
+ ---
25
+
26
+ Captures exceptions, stores them in your app's database with rich context (backtraces, breadcrumbs, request data), sends notifications, and exposes error data via a bundled MCP server -- so Claude Code and Devin AI can query, triage, and fix production errors directly.
27
+
28
+ No dashboard. The agent *is* the interface.
29
+
30
+ ## Why Rails Informant?
31
+
32
+ - **Agent-native** -- 12 MCP tools let AI agents list, inspect, resolve, and fix errors without a browser. The `/informant` Claude Code skill provides a complete triage-to-fix workflow.
33
+ - **Self-hosted** -- Errors stay in your database. No external service, no data leaving your infrastructure (unless you configure Slack, webhook, or Devin notifications).
34
+ - **Zero-config capture** -- Errors captured automatically via `Rails.error` subscriber and Rack middleware. Breadcrumbs from `ActiveSupport::Notifications` provide structured debugging context.
35
+ - **Autonomous fixing** -- Devin AI integration triggers investigation sessions on new errors, writes fixes with tests, and opens draft PRs. Humans retain the merge button.
36
+ - **Lightweight** -- Two database tables, no Redis, no background workers beyond ActiveJob. Runtime dependencies: Rails 8.1+ only.
37
+
38
+ ## Quick Start
39
+
40
+ Add to your Gemfile:
41
+
42
+ ```ruby
43
+ gem "rails-informant"
44
+ ```
45
+
46
+ Run the install generator:
47
+
48
+ ```sh
49
+ bin/rails generate rails_informant:install
50
+ bin/rails db:migrate
51
+ ```
52
+
53
+ This creates a migration for `informant_error_groups` and `informant_occurrences` tables and an initializer at `config/initializers/rails_informant.rb`.
54
+
55
+ Optional generators for AI agent integration:
56
+
57
+ ```sh
58
+ bin/rails generate rails_informant:skill # Claude Code skill at .claude/skills/informant/SKILL.md
59
+ bin/rails generate rails_informant:devin # Devin playbook at .devin/error-triage.devin.md
60
+ ```
61
+
62
+ Errors are captured automatically in non-local environments. To capture errors manually:
63
+
64
+ ```ruby
65
+ RailsInformant.capture(exception, context: { order_id: 42 })
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ ```ruby
71
+ # config/initializers/rails_informant.rb
72
+ RailsInformant.configure do |config|
73
+ config.capture_errors = !Rails.env.local?
74
+ config.api_token = Rails.application.credentials.dig(:rails_informant, :api_token)
75
+ config.slack_webhook_url = Rails.application.credentials.dig(:rails_informant, :slack_webhook_url)
76
+ config.retention_days = 30
77
+ end
78
+ ```
79
+
80
+ Every option can be set via an environment variable. The initializer takes precedence over env vars.
81
+
82
+ | Option | Env var | Default | Description |
83
+ |--------|---------|---------|-------------|
84
+ | `api_token` | `INFORMANT_API_TOKEN` | `nil` | Bearer token for API authentication (required for MCP) |
85
+ | `capture_errors` | `INFORMANT_CAPTURE_ERRORS` | `true` | Enable/disable error capture (set to `"false"` to disable) |
86
+ | `devin_api_key` | `INFORMANT_DEVIN_API_KEY` | `nil` | Devin AI API key for autonomous error fixing |
87
+ | `devin_playbook_id` | `INFORMANT_DEVIN_PLAYBOOK_ID` | `nil` | Devin playbook ID for error triage workflow |
88
+ | `ignored_exceptions` | `INFORMANT_IGNORED_EXCEPTIONS` | `[]` | Exception classes to skip (comma-separated in env var) |
89
+ | `retention_days` | `INFORMANT_RETENTION_DAYS` | `nil` | Auto-purge resolved errors after N days |
90
+ | `slack_webhook_url` | `INFORMANT_SLACK_WEBHOOK_URL` | `nil` | Slack incoming webhook URL |
91
+ | `capture_user_email` | _(none)_ | `false` | Capture email from detected user (PII -- opt-in) |
92
+ | `webhook_url` | `INFORMANT_WEBHOOK_URL` | `nil` | Generic webhook URL for notifications |
93
+
94
+ ## Error Capture
95
+
96
+ Errors are captured automatically via:
97
+
98
+ 1. **`Rails.error` subscriber** -- background jobs, mailer errors, `Rails.error.handle` blocks
99
+ 2. **Rack middleware** -- unhandled request exceptions and rescued framework exceptions
100
+
101
+ ### Fingerprinting
102
+
103
+ Errors are grouped by `SHA256(class_name:first_app_backtrace_frame)`. Line numbers are normalized so the same error at different lines groups together.
104
+
105
+ ### Ignored Exceptions
106
+
107
+ Common framework exceptions (404s, CSRF, etc.) are ignored by default. Add more:
108
+
109
+ ```ruby
110
+ config.ignored_exceptions = ["MyApp::BoringError", /Stripe::/]
111
+ ```
112
+
113
+ ### Breadcrumbs
114
+
115
+ Structured events from `ActiveSupport::Notifications` are captured automatically as breadcrumbs -- SQL query names, cache hits, template renders, HTTP calls, job executions. Stored per-occurrence for rich debugging context without raw log lines.
116
+
117
+ ## API
118
+
119
+ Token-authenticated JSON API mounted at `/informant/api/v1/`.
120
+
121
+ ```text
122
+ GET /informant/api/v1/errors # List error groups (paginated, filterable)
123
+ GET /informant/api/v1/errors/:id # Show with recent occurrences
124
+ PATCH /informant/api/v1/errors/:id # Update status or notes
125
+ DELETE /informant/api/v1/errors/:id # Delete group and occurrences
126
+ PATCH /informant/api/v1/errors/:id/fix_pending # Mark fix pending
127
+ PATCH /informant/api/v1/errors/:id/duplicate # Mark as duplicate
128
+ GET /informant/api/v1/occurrences # List occurrences
129
+ GET /informant/api/v1/status # Error monitoring summary
130
+ ```
131
+
132
+ Authenticate with `Authorization: Bearer <token>`.
133
+
134
+ ## MCP Server
135
+
136
+ The bundled `informant-mcp` executable connects Claude Code to your error data via [Model Context Protocol (MCP)](https://modelcontextprotocol.io).
137
+
138
+ The MCP server requires the `mcp` gem, which is not a runtime dependency. Add it to your Gemfile:
139
+
140
+ ```ruby
141
+ gem "mcp", ">= 0.7", "< 2"
142
+ ```
143
+
144
+ ### Setup
145
+
146
+ Add to your Claude Code MCP config:
147
+
148
+ ```json
149
+ {
150
+ "mcpServers": {
151
+ "informant": {
152
+ "command": "informant-mcp",
153
+ "env": {
154
+ "INFORMANT_PRODUCTION_URL": "https://myapp.com",
155
+ "INFORMANT_PRODUCTION_TOKEN": "your-api-token"
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ Or create `~/.config/informant-mcp.yml` for multi-environment setups:
163
+
164
+ ```yaml
165
+ environments:
166
+ production:
167
+ url: https://myapp.com
168
+ token: ${INFORMANT_PRODUCTION_TOKEN}
169
+ staging:
170
+ url: https://staging.myapp.com
171
+ token: ${INFORMANT_STAGING_TOKEN}
172
+ ```
173
+
174
+ ### Tools
175
+
176
+ | Tool | Description |
177
+ |------|-------------|
178
+ | `list_environments` | List configured environments |
179
+ | `list_errors` | List error groups with filtering and pagination |
180
+ | `get_error` | Full error detail with recent occurrences |
181
+ | `resolve_error` | Mark as resolved |
182
+ | `ignore_error` | Mark as ignored |
183
+ | `reopen_error` | Reopen a resolved/ignored error |
184
+ | `mark_fix_pending` | Mark with fix SHA for auto-resolve on deploy |
185
+ | `mark_duplicate` | Mark as duplicate of another group |
186
+ | `delete_error` | Delete group and occurrences |
187
+ | `annotate_error` | Add investigation notes |
188
+ | `get_informant_status` | Summary with counts and top errors |
189
+ | `list_occurrences` | List occurrences with filtering |
190
+
191
+ ## Claude Code Skill
192
+
193
+ Use `/informant` in Claude Code to triage and fix errors interactively. The skill:
194
+
195
+ 1. Checks error status with `get_informant_status`
196
+ 2. Lists unresolved errors
197
+ 3. Investigates with full occurrence data
198
+ 4. Implements fixes with test-first workflow
199
+ 5. Marks `fix_pending` for auto-resolution on deploy
200
+
201
+ ## Devin AI
202
+
203
+ Automate error investigation and fixing with [Devin AI](https://devin.ai). When a new error is captured, Rails Informant creates a Devin session that investigates via MCP tools, writes a fix with tests, and opens a draft PR.
204
+
205
+ ### Setup
206
+
207
+ 1. Add the `informant-mcp` server to Devin's [MCP Marketplace](https://docs.devin.ai/work-with-devin/mcp) with your API URL and token.
208
+
209
+ 2. Upload the playbook installed at `.devin/error-triage.devin.md` to Devin and note the playbook ID. See [Creating Playbooks](https://docs.devin.ai/product-guides/creating-playbooks).
210
+
211
+ 3. Configure Rails Informant:
212
+
213
+ ```ruby
214
+ RailsInformant.configure do |config|
215
+ config.devin_api_key = Rails.application.credentials.dig(:rails_informant, :devin_api_key)
216
+ config.devin_playbook_id = "your-playbook-id"
217
+ end
218
+ ```
219
+
220
+ ### How It Works
221
+
222
+ - Triggers on the **first occurrence only** -- repeated occurrences of the same error do not create additional Devin sessions.
223
+ - Sends error class, message (truncated to 500 chars), severity, backtrace (first 5 frames), and error group ID.
224
+ - Devin connects to your MCP server to investigate errors, then either opens a draft PR with a fix or annotates the error with investigation findings.
225
+
226
+ ### Data Sent to Devin
227
+
228
+ The notification prompt includes: error class, error message (truncated), severity, occurrence count, timestamps, controller action or job class, backtrace frames, and git SHA. It does **not** include request parameters, user context, or PII.
229
+
230
+ ## Architecture
231
+
232
+ ```text
233
+ Development Machine Remote Servers
234
+ +-----------------------+ +-----------------------+
235
+ | Claude Code | | Production |
236
+ | | | | /informant/api/v1 |
237
+ | | stdio | +-----------------------+
238
+ | v | HTTPS+Token
239
+ | MCP Server | -----------> +-----------------------+
240
+ | (exe/informant-mcp) | | Staging |
241
+ | | | /informant/api/v1 |
242
+ +-----------------------+ +-----------------------+
243
+
244
+ Inside the Rails app:
245
+ +-------------------------------------------------+
246
+ | Rails.error subscriber (primary capture) |
247
+ | Rack Middleware (safety net) |
248
+ | - ErrorCapture (before ShowExceptions) |
249
+ | - RescuedExceptionInterceptor (after Debug) |
250
+ | | |
251
+ | v |
252
+ | Fingerprint + Upsert (atomic counter) |
253
+ | | |
254
+ | v |
255
+ | Occurrence.create (with breadcrumbs, context) |
256
+ | | |
257
+ | v |
258
+ | NotifyJob.perform_later (async dispatch) |
259
+ | - Slack (Block Kit, Net::HTTP) |
260
+ | - Webhook (PII stripped by default) |
261
+ | - Devin AI (creates investigation session) |
262
+ +-------------------------------------------------+
263
+ ```
264
+
265
+ ### Error Group Lifecycle
266
+
267
+ ```text
268
+ unresolved --> fix_pending --> resolved (auto, on deploy)
269
+ unresolved --> resolved (manual)
270
+ unresolved --> ignored
271
+ unresolved --> duplicate
272
+ resolved --> unresolved [REGRESSION]
273
+ fix_pending --> unresolved (reopen)
274
+ ignored --> unresolved (reopen)
275
+ duplicate --> unresolved (reopen)
276
+ ```
277
+
278
+ ## Deploy Detection
279
+
280
+ On boot, the engine checks if `fix_pending` errors have been deployed by comparing the current git SHA against `original_sha`. Deployed fixes are automatically transitioned to `resolved`.
281
+
282
+ Git SHA is resolved from environment variables (`GIT_SHA`, `REVISION`, `KAMAL_VERSION`) or `.git/HEAD`.
283
+
284
+ ## Rake Tasks
285
+
286
+ ```sh
287
+ bin/rails informant:stats # Show error monitoring statistics
288
+ bin/rails informant:purge # Purge resolved errors older than retention_days
289
+ ```
290
+
291
+ ## Data & Privacy
292
+
293
+ Each occurrence stores the following PII:
294
+
295
+ - **User email** -- only captured when `config.capture_user_email = true` and the user model responds to `#email`
296
+ - **IP address** -- from `request.remote_ip`
297
+ - **Custom user context** -- anything set via `RailsInformant::Current.user_context`
298
+
299
+ For GDPR compliance, only include identifiers needed for debugging (e.g., user ID) rather than personal data. You can override automatic user detection by setting user context explicitly:
300
+
301
+ ```ruby
302
+ # In a before_action or around_action
303
+ RailsInformant::Current.user_context = { id: current_user.id }
304
+ ```
305
+
306
+ All stored context passes through `ActiveSupport::ParameterFilter`, so adding keys to `filter_parameters` suppresses them:
307
+
308
+ ```ruby
309
+ # config/application.rb
310
+ config.filter_parameters += [:email]
311
+ ```
312
+
313
+ This replaces email values with `[FILTERED]` in occurrence data. IP addresses can be suppressed the same way by adding `:ip`.
314
+
315
+ ## Security
316
+
317
+ - API requires bearer token authentication (`secure_compare`)
318
+ - All stored context is filtered through `ActiveSupport::ParameterFilter`
319
+ - MCP server enforces HTTPS by default
320
+ - Security headers: `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`
321
+ - Error capture never breaks the host application
322
+ - Webhook payloads strip PII by default
323
+ - **Rate limiting** -- the API does not include built-in rate limiting. Add rate limiting on the `/informant/api/` prefix in production, for example with [Rack::Attack](https://github.com/rack/rack-attack):
324
+
325
+ ```ruby
326
+ # config/initializers/rack_attack.rb
327
+ Rack::Attack.throttle("informant/api", limit: 60, period: 1.minute) do |req|
328
+ req.ip if req.path.start_with?("/informant/api/")
329
+ end
330
+ ```
331
+
332
+ ## License
333
+
334
+ This project is licensed under the **MIT License** -- see the [LICENSE](LICENSE) file for details.
335
+
336
+ ---
337
+
338
+ <div align="center">
339
+ <sub>Made in Tokyo with &#10084;&#65039; and &#129302;</sub>
340
+ </div>
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/setup"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.pattern = "test/**/*_test.rb"
7
+ t.verbose = false
8
+ end
9
+
10
+ task default: :test
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,62 @@
1
+ module RailsInformant
2
+ module Api
3
+ class BaseController < ActionController::API
4
+ before_action :authenticate_token!
5
+ before_action :set_security_headers
6
+
7
+ rescue_from RailsInformant::InvalidParameterError do |e|
8
+ render json: { error: e.message }, status: :bad_request
9
+ end
10
+
11
+ rescue_from ActiveRecord::RecordInvalid do |e|
12
+ render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
13
+ end
14
+
15
+ rescue_from ActiveRecord::RecordNotFound do
16
+ render json: { error: "Not found" }, status: :not_found
17
+ end
18
+
19
+ private
20
+
21
+ def parse_time(value)
22
+ Time.parse(value)
23
+ rescue ArgumentError
24
+ raise RailsInformant::InvalidParameterError, "Invalid date format"
25
+ end
26
+
27
+ def authenticate_token!
28
+ configured_token = RailsInformant.api_token
29
+
30
+ unless configured_token.present?
31
+ return render json: { error: "API token not configured" }, status: :service_unavailable
32
+ end
33
+
34
+ token = request.headers["Authorization"]&.delete_prefix("Bearer ")
35
+
36
+ unless token.present? && ActiveSupport::SecurityUtils.secure_compare(token, configured_token)
37
+ Rails.logger.warn "[RailsInformant] Auth failure from #{request.remote_ip} at #{Time.current.iso8601}"
38
+ render json: { error: "Unauthorized" }, status: :unauthorized
39
+ end
40
+ end
41
+
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
+ def paginate(scope, only:)
50
+ page = [ params.fetch(:page, 1).to_i, 1 ].max
51
+ per_page = [ [ params.fetch(:per_page, 20).to_i, 1 ].max, 100 ].min
52
+
53
+ records = scope.offset((page - 1) * per_page).limit(per_page + 1).to_a
54
+ has_more = records.size > per_page
55
+ records = records.first(per_page)
56
+ data = records.map { it.as_json(only:) }
57
+
58
+ { data:, meta: { page:, per_page:, has_more: } }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ module RailsInformant
2
+ module Api
3
+ class ErrorsController < BaseController
4
+ before_action :find_error_group, only: [ :show, :update, :destroy, :fix_pending, :duplicate ]
5
+
6
+ def index
7
+ groups = params[:status] == "duplicate" ? ErrorGroup.all : ErrorGroup.active
8
+ groups = groups
9
+ .by_controller_action(params[:controller_action])
10
+ .by_error_class(params[:error_class])
11
+ .by_job_class(params[:job_class])
12
+ .by_severity(params[:severity])
13
+ .by_status(params[:status])
14
+ .search(params[:q])
15
+ .since(params[:since] && parse_time(params[:since]))
16
+ .before(params[:until] && parse_time(params[:until]))
17
+ .order(last_seen_at: :desc)
18
+
19
+ render json: paginate(groups, only: ErrorGroup::API_FIELDS)
20
+ end
21
+
22
+ def show
23
+ occurrences = @error_group.occurrences.order(created_at: :desc).limit(10)
24
+ render json: @error_group.as_json(only: ErrorGroup::API_DETAIL_FIELDS).merge(
25
+ recent_occurrences: occurrences.as_json(only: Occurrence::API_FIELDS)
26
+ )
27
+ end
28
+
29
+ def update
30
+ permitted = params.permit(:status, :notes)
31
+
32
+ updates = {}
33
+ updates[:status] = permitted[:status] if permitted[:status]
34
+ updates[:notes] = permitted[:notes] if permitted.key?(:notes)
35
+
36
+ @error_group.update!(updates)
37
+ render json: @error_group.as_json(only: ErrorGroup::API_DETAIL_FIELDS)
38
+ end
39
+
40
+ def destroy
41
+ if ErrorGroup.exists?(duplicate_of_id: @error_group.id)
42
+ render json: { error: "Cannot delete: other errors reference this as a duplicate target" },
43
+ status: :unprocessable_entity
44
+ else
45
+ @error_group.destroy!
46
+ head :no_content
47
+ end
48
+ end
49
+
50
+ def fix_pending
51
+ permitted = params.permit(:fix_sha, :original_sha, :fix_pr_url)
52
+ @error_group.mark_as_fix_pending!(
53
+ fix_sha: permitted[:fix_sha],
54
+ original_sha: permitted[:original_sha],
55
+ fix_pr_url: permitted[:fix_pr_url]
56
+ )
57
+ render json: @error_group.as_json(only: ErrorGroup::API_DETAIL_FIELDS)
58
+ end
59
+
60
+ def duplicate
61
+ @error_group.mark_as_duplicate_of! params[:duplicate_of_id]
62
+ render json: @error_group.as_json(only: ErrorGroup::API_DETAIL_FIELDS)
63
+ end
64
+
65
+ private
66
+
67
+ def find_error_group
68
+ @error_group = ErrorGroup.find(params[:id])
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,14 @@
1
+ module RailsInformant
2
+ module Api
3
+ class OccurrencesController < BaseController
4
+ def index
5
+ occurrences = Occurrence.order(created_at: :desc)
6
+ occurrences = occurrences.where(error_group_id: params[:error_group_id]) if params[:error_group_id]
7
+ occurrences = occurrences.where(created_at: parse_time(params[:since])..) if params[:since]
8
+ occurrences = occurrences.where(created_at: ..parse_time(params[:until])) if params[:until]
9
+
10
+ render json: paginate(occurrences, only: Occurrence::API_FIELDS)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ module RailsInformant
2
+ module Api
3
+ class StatusController < BaseController
4
+ def show
5
+ counts = ErrorGroup.group(:status).count
6
+ render json: {
7
+ duplicate_count: counts.fetch("duplicate", 0),
8
+ fix_pending_count: counts.fetch("fix_pending", 0),
9
+ ignored_count: counts.fetch("ignored", 0),
10
+ resolved_count: counts.fetch("resolved", 0),
11
+ unresolved_count: counts.fetch("unresolved", 0),
12
+ deploy_sha: RailsInformant.current_git_sha,
13
+ top_errors: top_errors
14
+ }
15
+ end
16
+
17
+ private
18
+
19
+ def top_errors
20
+ ErrorGroup
21
+ .where(status: "unresolved")
22
+ .order(total_occurrences: :desc)
23
+ .limit(5)
24
+ .select(:id, :error_class, :message, :total_occurrences)
25
+ .map { |g| { id: g.id, error_class: g.error_class, message: g.message&.truncate(100), total_occurrences: g.total_occurrences } }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,4 @@
1
+ module RailsInformant
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,34 @@
1
+ module RailsInformant
2
+ class NotifyJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ retry_on ::Net::OpenTimeout, ::Net::ReadTimeout, ::SocketError, RailsInformant::NotifierError, attempts: 5, wait: 15.seconds
6
+ discard_on ActiveRecord::RecordNotFound
7
+
8
+ def perform(group)
9
+ occurrence = group.occurrences.order(created_at: :desc).first
10
+ failures = []
11
+
12
+ notifiers.each do |notifier|
13
+ next unless notifier.should_notify?(group)
14
+
15
+ notifier.notify(group, occurrence)
16
+ rescue StandardError => e
17
+ failures << e
18
+ end
19
+
20
+ group.update_column(:last_notified_at, Time.current) if failures.empty?
21
+
22
+ if failures.any?
23
+ failures.drop(1).each { |e| Rails.logger.error "[RailsInformant] Notifier failed: #{e.class}: #{e.message}" }
24
+ raise failures.first
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def notifiers
31
+ RailsInformant.config.notifiers
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ module RailsInformant
2
+ class PurgeJob < ApplicationJob
3
+ queue_as :default
4
+ retry_on ActiveRecord::InvalidForeignKey, wait: 1.second, attempts: 3
5
+
6
+ def perform
7
+ return unless RailsInformant.retention_days
8
+
9
+ cutoff = RailsInformant.retention_days.days.ago
10
+
11
+ # IDs referenced as duplicate targets must be kept (subquery avoids TOCTOU)
12
+ duplicate_target_ids = ErrorGroup.where(status: "duplicate")
13
+ .where.not(duplicate_of_id: nil)
14
+ .distinct
15
+ .select(:duplicate_of_id)
16
+
17
+ # Split into separate queries so each can hit its composite index cleanly
18
+ resolved_scope = ErrorGroup.where(status: "resolved").where(resolved_at: ...cutoff).where.not(id: duplicate_target_ids)
19
+ ignored_scope = ErrorGroup.where(status: "ignored").where(updated_at: ...cutoff).where.not(id: duplicate_target_ids)
20
+
21
+ [ ignored_scope, resolved_scope ].each do |purgeable|
22
+ purgeable.in_batches(of: 500) do |batch|
23
+ Occurrence.where(error_group_id: batch.select(:id)).delete_all
24
+ batch.delete_all
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ module RailsInformant
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end