eussiror 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/lib/eussiror/configuration.rb +43 -0
- data/lib/eussiror/error_reporter.rb +116 -0
- data/lib/eussiror/fingerprint.rb +38 -0
- data/lib/eussiror/github_client.rb +95 -0
- data/lib/eussiror/middleware.rb +25 -0
- data/lib/eussiror/railtie.rb +13 -0
- data/lib/eussiror/version.rb +5 -0
- data/lib/eussiror.rb +25 -0
- data/lib/generators/eussiror/install/install_generator.rb +17 -0
- data/lib/generators/eussiror/install/templates/initializer.rb.tt +25 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e721e700a3b37ec73117c35b65774e66c1f9274d249b12b16ea331c62bf34ef9
|
|
4
|
+
data.tar.gz: 20ddffdf0b98103b3777544046a271100383bf4720a7cf0d6a66e9075c2ecf1d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 72534f05812764111a22210f39521833ba30651347e1e68cd335f673c2e832258489f742c417191643a1a1e3cb29cd0d0ceb71d1695679c07ca6ce97e84627b1
|
|
7
|
+
data.tar.gz: 59b04321d5af06bbcd215290b26f2665ac6b3751bec5d76208041c28358e5a456da6c9be475cbe4bb6b696606fe0062b944561d9d74a277ee88de2bbac261ece
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-02-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release.
|
|
14
|
+
- Rack middleware that detects 500 responses and reads `env["action_dispatch.exception"]`.
|
|
15
|
+
- SHA256-based fingerprinting to deduplicate errors across occurrences.
|
|
16
|
+
- GitHub REST API v3 client (zero runtime dependencies, uses `Net::HTTP`).
|
|
17
|
+
- Automatic issue creation on first occurrence of a given error.
|
|
18
|
+
- Automatic comment on existing open issue for repeat occurrences.
|
|
19
|
+
- `Eussiror.configure` block with support for token, repository, environments, labels, assignees, ignored exceptions, and async mode.
|
|
20
|
+
- Rails install generator (`rails generate eussiror:install`).
|
|
21
|
+
- RuboCop configuration.
|
|
22
|
+
- GitHub Actions CI matrix: Ruby 3.1–3.4 × Rails 7.2–8.1.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Equipe Technique
|
|
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,314 @@
|
|
|
1
|
+
# Eussiror
|
|
2
|
+
|
|
3
|
+
**Maintainer:** [@tracyloisel](https://github.com/tracyloisel)
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/eussiror)
|
|
6
|
+
[](https://github.com/tracyloisel/eussiror/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
**Eussiror** automatically creates GitHub issues when your Rails application returns a 500 error in production. If the same error already has an open issue, it adds a comment with the new occurrence timestamp instead — keeping your issue tracker clean and deduplicated.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Requirements](#requirements)
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Configuration](#configuration)
|
|
18
|
+
- [How it works](#how-it-works)
|
|
19
|
+
- [GitHub token setup](#github-token-setup)
|
|
20
|
+
- [Architecture (for contributors)](#architecture-for-contributors)
|
|
21
|
+
- [Development](#development)
|
|
22
|
+
- [Contributing](#contributing)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
| Dependency | Minimum version |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Ruby | 3.1 |
|
|
32
|
+
| Rails | 7.2 |
|
|
33
|
+
|
|
34
|
+
> **Note:** No additional runtime gems are required. Eussiror uses Ruby's built-in `Net::HTTP` to call the GitHub API.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Add the gem to your application's `Gemfile`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
gem "eussiror"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then run:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle install
|
|
50
|
+
rails generate eussiror:install
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The generator creates `config/initializers/eussiror.rb` with all available options commented out. To undo the installation:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
rails destroy eussiror:install
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Edit the generated initializer:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# config/initializers/eussiror.rb
|
|
67
|
+
Eussiror.configure do |config|
|
|
68
|
+
# Required: GitHub personal access token with "repo" scope
|
|
69
|
+
config.github_token = ENV["GITHUB_TOKEN"]
|
|
70
|
+
|
|
71
|
+
# Required: target repository in "owner/repository" format
|
|
72
|
+
config.github_repository = "your-org/your-repo"
|
|
73
|
+
|
|
74
|
+
# Environments where 500 errors will be reported (default: ["production"])
|
|
75
|
+
config.environments = %w[production]
|
|
76
|
+
|
|
77
|
+
# Labels applied to every new issue (optional)
|
|
78
|
+
config.labels = %w[bug automated]
|
|
79
|
+
|
|
80
|
+
# GitHub logins to assign to new issues (optional)
|
|
81
|
+
config.assignees = []
|
|
82
|
+
|
|
83
|
+
# Exception classes that should NOT trigger issue creation (optional)
|
|
84
|
+
config.ignored_exceptions = %w[ActionController::RoutingError]
|
|
85
|
+
|
|
86
|
+
# Set to false to report synchronously — recommended in test environments
|
|
87
|
+
config.async = false
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Configuration options
|
|
92
|
+
|
|
93
|
+
| Option | Type | Default | Description |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| `github_token` | String | `nil` | GitHub token with `repo` (or Issues write) permission |
|
|
96
|
+
| `github_repository` | String | `nil` | Target repo in `owner/repo` format |
|
|
97
|
+
| `environments` | Array | `["production"]` | Environments where reporting is active |
|
|
98
|
+
| `labels` | Array | `[]` | Labels applied to created issues |
|
|
99
|
+
| `assignees` | Array | `[]` | GitHub logins assigned to created issues |
|
|
100
|
+
| `ignored_exceptions` | Array | `[]` | Exception class names (strings) to skip |
|
|
101
|
+
| `async` | Boolean | `true` | Report in a background thread (set `false` in tests) |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
When a 500 error occurs:
|
|
108
|
+
|
|
109
|
+
1. The Rack middleware catches the rendered 500 response.
|
|
110
|
+
2. A **fingerprint** is computed from the exception class, message, and first application backtrace line.
|
|
111
|
+
3. The GitHub API is searched for an open issue containing that fingerprint.
|
|
112
|
+
4. If **no issue exists** → a new issue is created with the exception details.
|
|
113
|
+
5. If **an issue exists** → a comment with the current timestamp is added.
|
|
114
|
+
|
|
115
|
+
### Example GitHub issue
|
|
116
|
+
|
|
117
|
+
**Title:** `[500] RuntimeError: something went wrong`
|
|
118
|
+
|
|
119
|
+
**Body:**
|
|
120
|
+
```
|
|
121
|
+
## Error Details
|
|
122
|
+
|
|
123
|
+
**Exception:** `RuntimeError`
|
|
124
|
+
**Message:** something went wrong
|
|
125
|
+
**First occurrence:** 2026-02-26 10:30:00 UTC
|
|
126
|
+
**Request:** `GET /dashboard`
|
|
127
|
+
**Remote IP:** 1.2.3.4
|
|
128
|
+
|
|
129
|
+
## Backtrace
|
|
130
|
+
|
|
131
|
+
app/controllers/dashboard_controller.rb:42:in 'index'
|
|
132
|
+
...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Example occurrence comment
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
**New occurrence:** 2026-02-26 14:55:02 UTC
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## GitHub token setup
|
|
144
|
+
|
|
145
|
+
Eussiror needs a GitHub token with permission to read and create issues on your target repository.
|
|
146
|
+
|
|
147
|
+
**Option A — Classic personal access token:**
|
|
148
|
+
Generate at `Settings → Developer settings → Personal access tokens → Tokens (classic)` and select the `repo` scope.
|
|
149
|
+
|
|
150
|
+
**Option B — Fine-grained personal access token:**
|
|
151
|
+
Generate at `Settings → Developer settings → Personal access tokens → Fine-grained tokens`. Grant **Issues → Read and write** on the target repository only.
|
|
152
|
+
|
|
153
|
+
Store the token in an environment variable (never hard-code it):
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# .env or your secrets manager
|
|
157
|
+
GITHUB_TOKEN=ghp_xxxxxxxxxxxx
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Architecture (for contributors)
|
|
163
|
+
|
|
164
|
+
This section describes the internal design of Eussiror to help contributors understand where everything lives and how the pieces connect.
|
|
165
|
+
|
|
166
|
+
### File map
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
lib/
|
|
170
|
+
├── eussiror.rb # Public API: .configure / .configuration / .reset_configuration!
|
|
171
|
+
└── eussiror/
|
|
172
|
+
├── version.rb # Gem version constant
|
|
173
|
+
├── configuration.rb # Configuration value object + guards
|
|
174
|
+
├── railtie.rb # Rails integration: inserts Middleware into the stack
|
|
175
|
+
├── middleware.rb # Rack middleware: detects 500s and calls ErrorReporter
|
|
176
|
+
├── fingerprint.rb # Computes a stable SHA256 fingerprint per exception type
|
|
177
|
+
├── github_client.rb # GitHub REST API v3 calls via Net::HTTP
|
|
178
|
+
└── error_reporter.rb # Orchestrator: fingerprint → search → create or comment
|
|
179
|
+
|
|
180
|
+
lib/generators/eussiror/install/
|
|
181
|
+
├── install_generator.rb # `rails generate eussiror:install`
|
|
182
|
+
└── templates/initializer.rb.tt # Template for config/initializers/eussiror.rb
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Request / error flow
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
HTTP Request
|
|
189
|
+
│
|
|
190
|
+
▼
|
|
191
|
+
Eussiror::Middleware (outermost Rack middleware)
|
|
192
|
+
│
|
|
193
|
+
▼
|
|
194
|
+
ActionDispatch::ShowExceptions (catches Rails exceptions, stores them in env)
|
|
195
|
+
│
|
|
196
|
+
▼
|
|
197
|
+
[... rest of Rails stack ...]
|
|
198
|
+
│
|
|
199
|
+
▼ (response travels back up)
|
|
200
|
+
ActionDispatch::ShowExceptions → sets env["action_dispatch.exception"]
|
|
201
|
+
returns HTTP 500 response
|
|
202
|
+
│
|
|
203
|
+
▼
|
|
204
|
+
Eussiror::Middleware
|
|
205
|
+
├── status == 500 AND env["action_dispatch.exception"] present?
|
|
206
|
+
│ YES → ErrorReporter.report(exception, env)
|
|
207
|
+
│ NO → pass response through unchanged
|
|
208
|
+
│
|
|
209
|
+
▼
|
|
210
|
+
HTTP Response returned to client
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Component responsibilities
|
|
214
|
+
|
|
215
|
+
#### `Eussiror` (lib/eussiror.rb)
|
|
216
|
+
Top-level module. Holds the singleton `configuration` object and exposes `.configure { |c| }`. All other components read `Eussiror.configuration`.
|
|
217
|
+
|
|
218
|
+
#### `Eussiror::Configuration`
|
|
219
|
+
Plain Ruby value object with attr_accessors for every option. Contains the two guard predicates used by `ErrorReporter`:
|
|
220
|
+
- `#valid?` — both token and repository are present
|
|
221
|
+
- `#reporting_enabled?` — valid config AND current Rails env is in `environments`
|
|
222
|
+
|
|
223
|
+
#### `Eussiror::Railtie`
|
|
224
|
+
Rails `Railtie` that runs one initializer: it inserts `Eussiror::Middleware` **before** `ActionDispatch::ShowExceptions` in the middleware stack. This positions our middleware as the outermost wrapper, so it sees the fully rendered 500 response on the way back out.
|
|
225
|
+
|
|
226
|
+
#### `Eussiror::Middleware`
|
|
227
|
+
Rack middleware with a standard `#call(env)` interface.
|
|
228
|
+
- On a normal response: passes through.
|
|
229
|
+
- On a 500 response with `env["action_dispatch.exception"]`: calls `ErrorReporter.report`.
|
|
230
|
+
- On a re-raised exception (non-standard setups): calls `ErrorReporter.report` before re-raising.
|
|
231
|
+
|
|
232
|
+
#### `Eussiror::Fingerprint`
|
|
233
|
+
Stateless module with a single public method: `.compute(exception) → String`.
|
|
234
|
+
|
|
235
|
+
The fingerprint is a 12-character hex prefix of a SHA256 digest computed from:
|
|
236
|
+
```
|
|
237
|
+
"#{exception.class.name}|#{exception.message[0,200]}|#{first_app_backtrace_line}"
|
|
238
|
+
```
|
|
239
|
+
Gem and stdlib lines are excluded when looking for the "first app line". This makes the fingerprint stable across deployments while being unique per error location.
|
|
240
|
+
|
|
241
|
+
The fingerprint is embedded as an HTML comment in the issue body:
|
|
242
|
+
```
|
|
243
|
+
<!-- eussiror:fingerprint:a1b2c3d4e5f6 -->
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
#### `Eussiror::GithubClient`
|
|
247
|
+
Thin HTTP client wrapping three GitHub REST API v3 endpoints. Uses only `Net::HTTP` (stdlib). Requires a `token:` and `repository:` at construction time.
|
|
248
|
+
|
|
249
|
+
| Method | Endpoint | Purpose |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `#find_issue(fingerprint)` | `GET /search/issues` | Returns issue number or `nil` |
|
|
252
|
+
| `#create_issue(title:, body:, ...)` | `POST /repos/{owner}/{repo}/issues` | Returns new issue number |
|
|
253
|
+
| `#add_comment(issue_number, body:)` | `POST /repos/{owner}/{repo}/issues/{n}/comments` | Returns comment id |
|
|
254
|
+
|
|
255
|
+
#### `Eussiror::ErrorReporter`
|
|
256
|
+
Stateless module that orchestrates the full reporting flow. Called by the middleware.
|
|
257
|
+
|
|
258
|
+
1. Checks `Eussiror.configuration.reporting_enabled?` — returns early if not.
|
|
259
|
+
2. Checks `ignored_exceptions` — returns early if matched.
|
|
260
|
+
3. Dispatches in a `Thread.new` when `config.async` is `true` (default), or inline otherwise.
|
|
261
|
+
4. Computes fingerprint → searches GitHub → creates issue or adds comment.
|
|
262
|
+
5. All GitHub errors are rescued and emitted as `warn` messages — the gem **never crashes your app**.
|
|
263
|
+
|
|
264
|
+
#### `Eussiror::Generators::InstallGenerator`
|
|
265
|
+
Standard `Rails::Generators::Base` subclass. Copies `templates/initializer.rb.tt` to `config/initializers/eussiror.rb` using Thor's `template` method. Supports `rails destroy eussiror:install` for clean uninstallation.
|
|
266
|
+
|
|
267
|
+
### Testing approach
|
|
268
|
+
|
|
269
|
+
- **Unit specs**: each component is tested in isolation. `GithubClient` uses `WebMock` to stub HTTP calls. `ErrorReporter` uses RSpec doubles for `GithubClient`.
|
|
270
|
+
- **Generator spec**: uses Rails generator test helpers (`prepare_destination`, `run_generator`).
|
|
271
|
+
- **Appraisals**: the `Appraisals` file defines three gemfiles (`rails-7.2`, `rails-8.0`, `rails-8.1`) so the full test suite runs against each supported Rails version.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
# Clone and install
|
|
279
|
+
git clone https://github.com/tracyloisel/eussiror.git
|
|
280
|
+
cd eussiror
|
|
281
|
+
bundle install
|
|
282
|
+
|
|
283
|
+
# Run tests against all Rails versions
|
|
284
|
+
bundle exec appraisal install
|
|
285
|
+
bundle exec appraisal rspec
|
|
286
|
+
|
|
287
|
+
# Run tests against a specific Rails version
|
|
288
|
+
bundle exec appraisal rails-8.0 rspec
|
|
289
|
+
|
|
290
|
+
# Run the linter
|
|
291
|
+
bundle exec rubocop
|
|
292
|
+
|
|
293
|
+
# Run the linter with auto-correct
|
|
294
|
+
bundle exec rubocop -A
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Contributing
|
|
300
|
+
|
|
301
|
+
1. Fork the repository
|
|
302
|
+
2. Create a feature branch: `git checkout -b feature/my-feature`
|
|
303
|
+
3. Write tests for your change
|
|
304
|
+
4. Make the tests pass: `bundle exec appraisal rspec`
|
|
305
|
+
5. Make the linter pass: `bundle exec rubocop`
|
|
306
|
+
6. Open a pull request against `main`
|
|
307
|
+
|
|
308
|
+
Please follow the existing code style. All public behaviour must be covered by specs.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
The gem is available as open source under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Eussiror
|
|
4
|
+
class Configuration
|
|
5
|
+
# Required settings
|
|
6
|
+
attr_accessor :github_token, :github_repository
|
|
7
|
+
|
|
8
|
+
# Environments where issue reporting is active (default: production only)
|
|
9
|
+
attr_accessor :environments
|
|
10
|
+
|
|
11
|
+
# Optional GitHub issue metadata
|
|
12
|
+
attr_accessor :labels, :assignees
|
|
13
|
+
|
|
14
|
+
# Exception classes to ignore (array of strings)
|
|
15
|
+
attr_accessor :ignored_exceptions
|
|
16
|
+
|
|
17
|
+
# Set to false to report synchronously (useful in tests)
|
|
18
|
+
attr_accessor :async
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@environments = %w[production]
|
|
22
|
+
@labels = []
|
|
23
|
+
@assignees = []
|
|
24
|
+
@ignored_exceptions = []
|
|
25
|
+
@async = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def valid?
|
|
29
|
+
github_token.to_s.strip.length.positive? &&
|
|
30
|
+
github_repository.to_s.strip.length.positive?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reporting_enabled?
|
|
34
|
+
valid? && environments.include?(current_environment)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def current_environment
|
|
40
|
+
defined?(Rails) ? Rails.env.to_s : ENV.fetch("RAILS_ENV", "development")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Eussiror
|
|
4
|
+
module ErrorReporter
|
|
5
|
+
# Maximum number of backtrace lines included in an issue body.
|
|
6
|
+
MAX_BACKTRACE_LINES = 20
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Entry point called by the middleware.
|
|
10
|
+
# Checks configuration guards, then dispatches async or sync.
|
|
11
|
+
def report(exception, env = {})
|
|
12
|
+
config = Eussiror.configuration
|
|
13
|
+
|
|
14
|
+
return unless config.reporting_enabled?
|
|
15
|
+
return if ignored?(exception, config)
|
|
16
|
+
|
|
17
|
+
if config.async
|
|
18
|
+
Thread.new { process(exception, env, config) }
|
|
19
|
+
else
|
|
20
|
+
process(exception, env, config)
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
warn "[Eussiror] ErrorReporter.report raised an unexpected error: #{e.class}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def ignored?(exception, config)
|
|
29
|
+
config.ignored_exceptions.any? do |klass_name|
|
|
30
|
+
exception.is_a?(Object.const_get(klass_name))
|
|
31
|
+
rescue NameError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def process(exception, env, config)
|
|
37
|
+
fingerprint = Fingerprint.compute(exception)
|
|
38
|
+
client = GithubClient.new(
|
|
39
|
+
token: config.github_token,
|
|
40
|
+
repository: config.github_repository
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
existing_issue = client.find_issue(fingerprint)
|
|
44
|
+
|
|
45
|
+
if existing_issue
|
|
46
|
+
client.add_comment(existing_issue, body: occurrence_comment)
|
|
47
|
+
else
|
|
48
|
+
client.create_issue(
|
|
49
|
+
title: issue_title(exception),
|
|
50
|
+
body: issue_body(exception, env, fingerprint),
|
|
51
|
+
labels: config.labels,
|
|
52
|
+
assignees: config.assignees
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
warn "[Eussiror] Failed to report exception to GitHub: #{e.class}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def issue_title(exception)
|
|
60
|
+
message = exception.message.to_s.lines.first.to_s.strip[0, 120]
|
|
61
|
+
"[500] #{exception.class}: #{message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def issue_body(exception, env, fingerprint)
|
|
65
|
+
request_info = build_request_info(env)
|
|
66
|
+
backtrace = format_backtrace(exception)
|
|
67
|
+
|
|
68
|
+
<<~BODY
|
|
69
|
+
## Error Details
|
|
70
|
+
|
|
71
|
+
**Exception:** `#{exception.class}`
|
|
72
|
+
**Message:** #{exception.message}
|
|
73
|
+
**First occurrence:** #{current_timestamp}
|
|
74
|
+
#{request_info}
|
|
75
|
+
|
|
76
|
+
## Backtrace
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
#{backtrace}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
<!-- #{GithubClient::FINGERPRINT_MARKER}:#{fingerprint} -->
|
|
83
|
+
BODY
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def occurrence_comment
|
|
87
|
+
"**New occurrence:** #{current_timestamp}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_timestamp
|
|
91
|
+
Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_request_info(env)
|
|
95
|
+
return "" if env.nil? || env.empty?
|
|
96
|
+
|
|
97
|
+
method = env["REQUEST_METHOD"]
|
|
98
|
+
path = env["PATH_INFO"]
|
|
99
|
+
remote_addr = env["REMOTE_ADDR"]
|
|
100
|
+
|
|
101
|
+
return "" unless method && path
|
|
102
|
+
|
|
103
|
+
parts = ["**Request:** `#{method} #{path}`"]
|
|
104
|
+
parts << "**Remote IP:** #{remote_addr}" if remote_addr
|
|
105
|
+
|
|
106
|
+
"\n#{parts.join("\n")}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def format_backtrace(exception)
|
|
110
|
+
(exception.backtrace || [])
|
|
111
|
+
.first(MAX_BACKTRACE_LINES)
|
|
112
|
+
.join("\n")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Eussiror
|
|
6
|
+
module Fingerprint
|
|
7
|
+
# Number of hex characters kept from the SHA256 digest.
|
|
8
|
+
DIGEST_LENGTH = 12
|
|
9
|
+
|
|
10
|
+
# Lines from these path fragments are excluded when looking for the
|
|
11
|
+
# "first application line" in a backtrace.
|
|
12
|
+
GEM_PATH_PATTERNS = %w[/gems/ /ruby/ /rubygems vendor/bundle].freeze
|
|
13
|
+
|
|
14
|
+
# Computes a stable, short fingerprint for a given exception.
|
|
15
|
+
#
|
|
16
|
+
# The fingerprint is based on:
|
|
17
|
+
# - The exception class name
|
|
18
|
+
# - The first 200 characters of the message
|
|
19
|
+
# - The first backtrace line that belongs to the application (not a gem)
|
|
20
|
+
#
|
|
21
|
+
# Returns a 12-character lowercase hex string.
|
|
22
|
+
def self.compute(exception)
|
|
23
|
+
parts = [
|
|
24
|
+
exception.class.name,
|
|
25
|
+
exception.message.to_s[0, 200],
|
|
26
|
+
first_app_backtrace_line(exception)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
Digest::SHA256.hexdigest(parts.join("|"))[0, DIGEST_LENGTH]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.first_app_backtrace_line(exception)
|
|
33
|
+
backtrace = exception.backtrace || []
|
|
34
|
+
backtrace.find { |line| GEM_PATH_PATTERNS.none? { |pattern| line.include?(pattern) } } || backtrace.first.to_s
|
|
35
|
+
end
|
|
36
|
+
private_class_method :first_app_backtrace_line
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Eussiror
|
|
8
|
+
class GithubClient
|
|
9
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
10
|
+
# Marker embedded as an HTML comment in every issue body for searching.
|
|
11
|
+
FINGERPRINT_MARKER = "eussiror:fingerprint"
|
|
12
|
+
|
|
13
|
+
def initialize(token:, repository:)
|
|
14
|
+
@token = token
|
|
15
|
+
@repository = repository
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Searches for an open issue whose body contains the given fingerprint.
|
|
19
|
+
# Returns the issue number (Integer) or nil when none is found.
|
|
20
|
+
def find_issue(fingerprint)
|
|
21
|
+
query = "repo:#{@repository} is:issue is:open \"#{FINGERPRINT_MARKER}:#{fingerprint}\" in:body"
|
|
22
|
+
params = URI.encode_www_form(q: query, per_page: 1)
|
|
23
|
+
uri = URI("#{GITHUB_API_BASE}/search/issues?#{params}")
|
|
24
|
+
|
|
25
|
+
response = get(uri)
|
|
26
|
+
data = JSON.parse(response.body)
|
|
27
|
+
|
|
28
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
29
|
+
return nil if data["items"].nil? || data["items"].empty?
|
|
30
|
+
|
|
31
|
+
data["items"].first["number"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Creates a new GitHub issue and returns the issue number.
|
|
35
|
+
def create_issue(title:, body:, labels: [], assignees: [])
|
|
36
|
+
uri = URI("#{GITHUB_API_BASE}/repos/#{@repository}/issues")
|
|
37
|
+
payload = { title: title, body: body }
|
|
38
|
+
payload[:labels] = labels if labels.any?
|
|
39
|
+
payload[:assignees] = assignees if assignees.any?
|
|
40
|
+
|
|
41
|
+
response = post(uri, payload)
|
|
42
|
+
data = JSON.parse(response.body)
|
|
43
|
+
|
|
44
|
+
raise_api_error!(response, "create issue") unless response.is_a?(Net::HTTPSuccess)
|
|
45
|
+
|
|
46
|
+
data["number"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Adds a comment to an existing issue. Returns the comment id.
|
|
50
|
+
def add_comment(issue_number, body:)
|
|
51
|
+
uri = URI("#{GITHUB_API_BASE}/repos/#{@repository}/issues/#{issue_number}/comments")
|
|
52
|
+
|
|
53
|
+
response = post(uri, { body: body })
|
|
54
|
+
data = JSON.parse(response.body)
|
|
55
|
+
|
|
56
|
+
raise_api_error!(response, "add comment") unless response.is_a?(Net::HTTPSuccess)
|
|
57
|
+
|
|
58
|
+
data["id"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def get(uri)
|
|
64
|
+
request = Net::HTTP::Get.new(uri)
|
|
65
|
+
apply_headers!(request)
|
|
66
|
+
execute(uri, request)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def post(uri, payload)
|
|
70
|
+
request = Net::HTTP::Post.new(uri)
|
|
71
|
+
apply_headers!(request)
|
|
72
|
+
request.body = JSON.generate(payload)
|
|
73
|
+
execute(uri, request)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def execute(uri, request)
|
|
77
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
78
|
+
http.request(request)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_headers!(request)
|
|
83
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
84
|
+
request["Accept"] = "application/vnd.github+json"
|
|
85
|
+
request["X-GitHub-Api-Version"] = "2022-11-28"
|
|
86
|
+
request["Content-Type"] = "application/json"
|
|
87
|
+
request["User-Agent"] = "eussiror/#{Eussiror::VERSION}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def raise_api_error!(response, action)
|
|
91
|
+
raise "Eussiror: GitHub API failed to #{action} " \
|
|
92
|
+
"(HTTP #{response.code}): #{response.body}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Eussiror
|
|
4
|
+
class Middleware
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
status, headers, body = @app.call(env)
|
|
11
|
+
|
|
12
|
+
if status == 500
|
|
13
|
+
exception = env["action_dispatch.exception"]
|
|
14
|
+
ErrorReporter.report(exception, env) if exception
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
[status, headers, body]
|
|
18
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
19
|
+
# The Rails stack re-raises after ShowExceptions in non-standard setups.
|
|
20
|
+
# We still want to capture the exception before propagating it.
|
|
21
|
+
ErrorReporter.report(e, env)
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Eussiror
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
# Insert before ShowExceptions so we wrap the full Rails error rendering.
|
|
8
|
+
# On the way back out, we inspect the rendered response and env to detect 500s.
|
|
9
|
+
initializer "eussiror.insert_middleware" do |app|
|
|
10
|
+
app.middleware.insert_before ActionDispatch::ShowExceptions, Eussiror::Middleware
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/eussiror.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "eussiror/version"
|
|
4
|
+
require "eussiror/configuration"
|
|
5
|
+
require "eussiror/fingerprint"
|
|
6
|
+
require "eussiror/github_client"
|
|
7
|
+
require "eussiror/error_reporter"
|
|
8
|
+
require "eussiror/middleware"
|
|
9
|
+
require "eussiror/railtie" if defined?(Rails::Railtie)
|
|
10
|
+
|
|
11
|
+
module Eussiror
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_configuration!
|
|
22
|
+
@configuration = Configuration.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Eussiror
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates an Eussiror initializer in config/initializers."
|
|
11
|
+
|
|
12
|
+
def create_initializer_file
|
|
13
|
+
template "initializer.rb.tt", "config/initializers/eussiror.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Eussiror.configure do |config|
|
|
2
|
+
# Required: GitHub personal access token with "repo" scope (or a fine-grained
|
|
3
|
+
# token with Issues read/write permission on the target repository).
|
|
4
|
+
config.github_token = ENV["GITHUB_TOKEN"]
|
|
5
|
+
|
|
6
|
+
# Required: target GitHub repository in "owner/repository" format.
|
|
7
|
+
config.github_repository = "your-org/your-repo"
|
|
8
|
+
|
|
9
|
+
# Environments where 500 errors will be reported to GitHub.
|
|
10
|
+
# Default: ["production"]
|
|
11
|
+
config.environments = %w[production]
|
|
12
|
+
|
|
13
|
+
# Labels applied to every new issue created by Eussiror (optional).
|
|
14
|
+
# config.labels = %w[bug automated]
|
|
15
|
+
|
|
16
|
+
# GitHub login(s) to assign to new issues (optional).
|
|
17
|
+
# config.assignees = []
|
|
18
|
+
|
|
19
|
+
# Exception classes that should NOT trigger issue creation (optional).
|
|
20
|
+
# config.ignored_exceptions = %w[ActionController::RoutingError]
|
|
21
|
+
|
|
22
|
+
# Set to false to report synchronously instead of in a background thread.
|
|
23
|
+
# Recommended for test environments or when using a job queue.
|
|
24
|
+
# config.async = false
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: eussiror
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Equipe Technique
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-02-26 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.2'
|
|
27
|
+
description: |
|
|
28
|
+
Eussiror hooks into your Rails app and automatically creates GitHub issues
|
|
29
|
+
when unhandled exceptions produce 500 responses in configured environments.
|
|
30
|
+
If an issue already exists for the same error (identified by fingerprint),
|
|
31
|
+
it adds a comment with the new occurrence timestamp instead.
|
|
32
|
+
email: []
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- lib/eussiror.rb
|
|
41
|
+
- lib/eussiror/configuration.rb
|
|
42
|
+
- lib/eussiror/error_reporter.rb
|
|
43
|
+
- lib/eussiror/fingerprint.rb
|
|
44
|
+
- lib/eussiror/github_client.rb
|
|
45
|
+
- lib/eussiror/middleware.rb
|
|
46
|
+
- lib/eussiror/railtie.rb
|
|
47
|
+
- lib/eussiror/version.rb
|
|
48
|
+
- lib/generators/eussiror/install/install_generator.rb
|
|
49
|
+
- lib/generators/eussiror/install/templates/initializer.rb.tt
|
|
50
|
+
homepage: https://github.com/tracyloisel/eussiror
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
homepage_uri: https://github.com/tracyloisel/eussiror
|
|
55
|
+
source_code_uri: https://github.com/tracyloisel/eussiror
|
|
56
|
+
changelog_uri: https://github.com/tracyloisel/eussiror/blob/main/CHANGELOG.md
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
post_install_message:
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 3.1.0
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.5.22
|
|
74
|
+
signing_key:
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Automatically create GitHub issues from Rails 500 errors
|
|
77
|
+
test_files: []
|