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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +10 -0
- data/VERSION +1 -0
- data/app/controllers/rails_informant/api/base_controller.rb +62 -0
- data/app/controllers/rails_informant/api/errors_controller.rb +72 -0
- data/app/controllers/rails_informant/api/occurrences_controller.rb +14 -0
- data/app/controllers/rails_informant/api/status_controller.rb +29 -0
- data/app/jobs/rails_informant/application_job.rb +4 -0
- data/app/jobs/rails_informant/notify_job.rb +34 -0
- data/app/jobs/rails_informant/purge_job.rb +29 -0
- data/app/models/rails_informant/application_record.rb +5 -0
- data/app/models/rails_informant/error_group.rb +175 -0
- data/app/models/rails_informant/occurrence.rb +22 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260227000000_create_informant_tables.rb +65 -0
- data/exe/informant-mcp +27 -0
- data/lib/generators/rails_informant/devin/templates/error-triage.devin.md +48 -0
- data/lib/generators/rails_informant/devin_generator.rb +12 -0
- data/lib/generators/rails_informant/install_generator.rb +20 -0
- data/lib/generators/rails_informant/skill/templates/SKILL.md +168 -0
- data/lib/generators/rails_informant/skill_generator.rb +12 -0
- data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +55 -0
- data/lib/generators/rails_informant/templates/initializer.rb.erb +33 -0
- data/lib/rails_informant/breadcrumb_buffer.rb +30 -0
- data/lib/rails_informant/breadcrumb_subscriber.rb +51 -0
- data/lib/rails_informant/configuration.rb +51 -0
- data/lib/rails_informant/context_builder.rb +142 -0
- data/lib/rails_informant/context_filter.rb +45 -0
- data/lib/rails_informant/current.rb +5 -0
- data/lib/rails_informant/engine.rb +86 -0
- data/lib/rails_informant/error_recorder.rb +47 -0
- data/lib/rails_informant/error_subscriber.rb +17 -0
- data/lib/rails_informant/fingerprint.rb +23 -0
- data/lib/rails_informant/mcp/base_tool.rb +38 -0
- data/lib/rails_informant/mcp/client.rb +123 -0
- data/lib/rails_informant/mcp/configuration.rb +90 -0
- data/lib/rails_informant/mcp/server.rb +29 -0
- data/lib/rails_informant/mcp/tools/annotate_error.rb +25 -0
- data/lib/rails_informant/mcp/tools/delete_error.rb +25 -0
- data/lib/rails_informant/mcp/tools/get_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/get_informant_status.rb +22 -0
- data/lib/rails_informant/mcp/tools/ignore_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/list_environments.rb +20 -0
- data/lib/rails_informant/mcp/tools/list_errors.rb +32 -0
- data/lib/rails_informant/mcp/tools/list_occurrences.rb +27 -0
- data/lib/rails_informant/mcp/tools/mark_duplicate.rb +25 -0
- data/lib/rails_informant/mcp/tools/mark_fix_pending.rb +27 -0
- data/lib/rails_informant/mcp/tools/reopen_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/resolve_error.rb +24 -0
- data/lib/rails_informant/mcp.rb +22 -0
- data/lib/rails_informant/middleware/error_capture.rb +28 -0
- data/lib/rails_informant/middleware/rescued_exception_interceptor.rb +16 -0
- data/lib/rails_informant/notifiers/devin.rb +61 -0
- data/lib/rails_informant/notifiers/notification_policy.rb +85 -0
- data/lib/rails_informant/notifiers/slack.rb +77 -0
- data/lib/rails_informant/notifiers/webhook.rb +31 -0
- data/lib/rails_informant/structured_event_subscriber.rb +14 -0
- data/lib/rails_informant/version.rb +3 -0
- data/lib/rails_informant.rb +147 -0
- data/lib/tasks/rails_informant.rake +30 -0
- 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
|
+
◆ <a href="#quick-start">Quick Start</a>
|
|
16
|
+
◆ <a href="#configuration">Configuration</a>
|
|
17
|
+
◆ <a href="#mcp-server">MCP Server</a>
|
|
18
|
+
◆ <a href="#architecture">Architecture</a>
|
|
19
|
+
◆ <a href="#data--privacy">Data & Privacy</a>
|
|
20
|
+
◆ <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 ❤️ and 🤖</sub>
|
|
340
|
+
</div>
|
data/Rakefile
ADDED
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,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
|