errmine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +73 -19
- data/lib/errmine/middleware.rb +34 -2
- data/lib/errmine/notifier.rb +84 -35
- data/lib/errmine/railtie.rb +8 -1
- data/lib/errmine/version.rb +1 -1
- data/lib/errmine.rb +9 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1a9a7a387a6803ba0b0adad94a879f28532586c6e5ac0bb78f8319a01e94258
|
|
4
|
+
data.tar.gz: f77ffb7eb12eec128b2f83170781bc443b71c171e91852374ab0b3cabd087e8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e571d17b1b8f989be5c19c3cd0170e0e33ca5046e4f57aeb2cc544f9e8d58e7f1619cfdf115198b19532d3da0d6a1cf12eb1e8528e323908ce4fe4f5d1966fc
|
|
7
|
+
data.tar.gz: 31a9fb8289ccafb5d7de40beadf653117d240055bfd2d8ecfff708f4465f9b8655989a7c27c24b05bfae0206202efe15bb81a7d7af0484b613555aea2a8de606
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
# Errmine changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-10)
|
|
4
|
+
- [Breaking] Require Ruby `>= 4.0.0`. Errmine 0.2.0 and earlier required Ruby `>= 3.3.0`.
|
|
5
|
+
- [Feature] Add the `assigned_to_id` option to `Errmine.create_issue` and `Errmine.notify` (in the context), and the `assigned_to_id` setting (default `nil`) as its default, to assign new issues to a Redmine user. Without either, Errmine sends no assignee and Redmine uses the project's default, as before.
|
|
6
|
+
- [Enhancement] Add Ruby warning category opt-in to test helpers.
|
|
7
|
+
- [Fix] Fix cache cleanup crashing with `NoMethodError` when the cache exceeds 500 entries within one cooldown, which dropped every new error until entries expired.
|
|
8
|
+
- [Fix] Report the next occurrence of an error whose report to Redmine failed (HTTP error, timeout, connection error) after 60 seconds (or the cooldown, if shorter) instead of dropping it for the whole cooldown, and log one warning with the error class and the failure.
|
|
9
|
+
- [Fix] Report errors whose message, backtrace or context has invalid UTF-8 or binary bytes, and `Errmine.create_issue` subjects or descriptions with such bytes, with those bytes replaced, instead of failing to build the Redmine request and never reporting them.
|
|
10
|
+
- [Fix] Replace the values of sensitive query parameters in the URL that `Errmine::Middleware` reports with `[FILTERED]`, instead of sending them to Redmine in clear text. Add the `filter_parameters` option (default: the Rails 7.1 list `passw email secret token _key crypt salt certificate otp ssn cvv cvc`); in Rails, the app's `config.filter_parameters` is added to it.
|
|
11
|
+
|
|
3
12
|
## 0.2.0 (2025-12-24)
|
|
4
13
|
- [Feature] Add app name to issue title for easy identification when multiple apps report to the same Redmine project.
|
|
5
14
|
- [Feature] Include app_name in checksum calculation so same errors in different apps create separate issues.
|
|
6
15
|
- [Feature] Add `Errmine.create_issue` method for creating custom Redmine issues with arbitrary subject, description, tracker, project, and tags.
|
|
7
|
-
- [Feature] Add tags support via `
|
|
16
|
+
- [Feature] Add tags support via the `tags` option for both exception reporting and custom issues.
|
|
8
17
|
- [Feature] Add `default_tags` configuration option for tags applied to all issues.
|
|
9
18
|
- [Maintenance] Update actions/checkout to v6 in CI.
|
|
10
19
|
|
data/README.md
CHANGED
|
@@ -4,15 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
# Errmine
|
|
6
6
|
|
|
7
|
-
[](https://github.com/mensfeld/errmine/actions?query=workflow%3Aci)
|
|
8
7
|
[](http://badge.fury.io/rb/errmine)
|
|
9
8
|
|
|
10
9
|
Dead simple, zero-dependency exception tracking for Redmine.
|
|
11
10
|
|
|
12
|
-
Errmine automatically creates and updates Redmine issues from Ruby/Rails exceptions. When an error occurs, it creates a new issue. When the same error occurs again, it increments a counter and adds a journal note instead of creating duplicates.
|
|
11
|
+
Errmine automatically creates and updates Redmine issues from Ruby/Rails exceptions. When an error occurs, it creates a new issue. When the same error occurs again after the cooldown, it increments a counter and adds a journal note instead of creating duplicates.
|
|
13
12
|
|
|
14
13
|
```
|
|
15
|
-
[a1b2c3d4][47] NoMethodError: undefined method 'foo' for nil...
|
|
14
|
+
[my-app][a1b2c3d4][47] NoMethodError: undefined method 'foo' for nil...
|
|
16
15
|
```
|
|
17
16
|
|
|
18
17
|
## Why Errmine?
|
|
@@ -27,24 +26,26 @@ If you already use Redmine for project management, Errmine lets you track produc
|
|
|
27
26
|
**vs. Exception Notification gem**
|
|
28
27
|
- Automatic deduplication - same error updates existing issue instead of creating duplicates
|
|
29
28
|
- Rate limiting prevents inbox/Redmine flooding during error storms
|
|
30
|
-
- Occurrence
|
|
29
|
+
- Occurrence counter in the subject - counts the reports that passed the cooldown, not every occurrence (at most one per error per cooldown period in each process)
|
|
31
30
|
|
|
32
31
|
**vs. Rolling your own**
|
|
33
32
|
- Zero dependencies - uses only Ruby stdlib
|
|
34
|
-
- Handles edge cases: rate limiting, timeouts, thread safety, fail-safe error handling
|
|
33
|
+
- Handles edge cases: rate limiting, timeouts, thread safety, fail-safe error handling (reporting is synchronous, see [Synchronous Reporting](#synchronous-reporting))
|
|
35
34
|
- Works out of the box with Rails 7+ Error Reporting API
|
|
36
35
|
|
|
37
36
|
## Features
|
|
38
37
|
|
|
39
38
|
- **Zero dependencies** - Uses only Ruby stdlib (net/http, json, digest, uri)
|
|
40
|
-
- **Automatic deduplication** - Same errors update existing issues instead of creating duplicates
|
|
39
|
+
- **Automatic deduplication** - Same errors update existing issues instead of creating duplicates (no lock across processes, see [Deduplication](#deduplication))
|
|
41
40
|
- **Rate limiting** - Prevents flooding Redmine during error loops
|
|
42
41
|
- **Rails integration** - Works with Rails 7+ Error Reporting API or as Rack middleware
|
|
43
42
|
- **Thread-safe** - Safe to use in multi-threaded environments
|
|
44
|
-
- **Fail-safe** - Never crashes your application, logs errors to stderr
|
|
43
|
+
- **Fail-safe** - Never crashes your application, logs errors to stderr (reporting still blocks the caller, see [Synchronous Reporting](#synchronous-reporting))
|
|
45
44
|
|
|
46
45
|
## Installation
|
|
47
46
|
|
|
47
|
+
Errmine requires Ruby `>= 4.0.0`.
|
|
48
|
+
|
|
48
49
|
Add to your Gemfile:
|
|
49
50
|
|
|
50
51
|
```ruby
|
|
@@ -105,8 +106,11 @@ end
|
|
|
105
106
|
| `project_id` | `ENV['ERRMINE_PROJECT']` or `'bug-tracker'` | Redmine project identifier |
|
|
106
107
|
| `tracker_id` | `1` | Tracker ID (usually 1 = Bug) |
|
|
107
108
|
| `app_name` | `ENV['ERRMINE_APP_NAME']` or `'unknown'` | Application name shown in issues |
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
109
|
+
| `default_tags` | `[]` | Tags (array of strings) added to every issue Errmine creates |
|
|
110
|
+
| `assigned_to_id` | `nil` | Redmine user ID that new issues are assigned to. When `nil`, Errmine sends no assignee and Redmine uses the project's default |
|
|
111
|
+
| `enabled` | `true` | Enable/disable notifications and custom issues |
|
|
112
|
+
| `cooldown` | `300` | Seconds between same-error notifications (after a failed report, the next occurrence is reported after 60 seconds, or after the cooldown if it is shorter) |
|
|
113
|
+
| `filter_parameters` | `%w[passw email secret token _key crypt salt certificate otp ssn cvv cvc]` | Query parameter names whose values the middleware replaces with `[FILTERED]`. Strings and symbols match any part of the name, case-insensitively; regexps match with `match?`. In Rails, the app's `config.filter_parameters` (strings, symbols, regexps; procs are skipped) is added after initialization |
|
|
110
114
|
|
|
111
115
|
## Usage
|
|
112
116
|
|
|
@@ -123,10 +127,14 @@ For all Rails versions or any Rack-based application:
|
|
|
123
127
|
config.middleware.use Errmine::Middleware
|
|
124
128
|
|
|
125
129
|
# or config.ru (Sinatra, etc.)
|
|
130
|
+
require 'errmine'
|
|
131
|
+
require 'errmine/middleware'
|
|
126
132
|
use Errmine::Middleware
|
|
127
133
|
```
|
|
128
134
|
|
|
129
|
-
|
|
135
|
+
Outside Rails, `require 'errmine'` does not load `Errmine::Middleware`. Require `errmine/middleware` before you use it. In Rails, the Railtie loads it.
|
|
136
|
+
|
|
137
|
+
The middleware captures request context (URL, HTTP method, user via Warden). The URL includes the query string, with the value of each parameter that matches `filter_parameters` replaced with `[FILTERED]` (for example `/password/edit?reset_password_token=[FILTERED]&page=2`). If a parameter name cannot be decoded, the whole query is replaced: `/path?[FILTERED]`.
|
|
130
138
|
|
|
131
139
|
### Manual Notification
|
|
132
140
|
|
|
@@ -137,21 +145,29 @@ begin
|
|
|
137
145
|
risky_operation
|
|
138
146
|
rescue => e
|
|
139
147
|
Errmine.notify(e, {
|
|
140
|
-
url: request.
|
|
148
|
+
url: request.filtered_path,
|
|
141
149
|
user: current_user&.email,
|
|
150
|
+
tags: ['checkout'],
|
|
142
151
|
custom_field: 'any value'
|
|
143
152
|
})
|
|
144
153
|
raise
|
|
145
154
|
end
|
|
146
155
|
```
|
|
147
156
|
|
|
157
|
+
- Errmine does not filter a `url` you pass. In Rails, `request.filtered_path` gives the path and query with `config.filter_parameters` applied; `request.url` holds every query value in clear text
|
|
158
|
+
- When set, `url` and `user` add the `**URL:**` and `**User:**` lines to the issue description. Every other key adds one `**Key:** value` line, with the key name capitalized and the value as Ruby text, for example `**Custom_field:** any value`
|
|
159
|
+
- `tags` is optional, an array of strings. When the report creates a new issue, the issue gets `default_tags` plus these tags, without duplicates
|
|
160
|
+
- The issue description also gets a `**Tags:**` line with these tags as a Ruby array, for example `**Tags:** ["checkout"]` (without `default_tags`)
|
|
161
|
+
- When a report that passes the cooldown finds an open issue for the error, it updates that issue and ignores `tags`: Errmine adds no tags to the issue and no tags line to the journal note
|
|
162
|
+
- `assigned_to_id` is optional, a Redmine user ID. When the report creates a new issue, the issue is assigned to this user instead of the configured `assigned_to_id`. It also adds an `**Assigned_to_id:**` line to the description. Updates of an open issue do not change its assignee
|
|
163
|
+
|
|
148
164
|
### rescue_from in Controllers
|
|
149
165
|
|
|
150
166
|
```ruby
|
|
151
167
|
class ApplicationController < ActionController::Base
|
|
152
168
|
rescue_from StandardError do |e|
|
|
153
169
|
Errmine.notify(e, {
|
|
154
|
-
url: request.
|
|
170
|
+
url: request.filtered_path,
|
|
155
171
|
user: current_user&.email
|
|
156
172
|
})
|
|
157
173
|
raise e
|
|
@@ -159,6 +175,25 @@ class ApplicationController < ActionController::Base
|
|
|
159
175
|
end
|
|
160
176
|
```
|
|
161
177
|
|
|
178
|
+
### Custom Issues
|
|
179
|
+
|
|
180
|
+
Create a Redmine issue with your own subject and description:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
Errmine.create_issue(
|
|
184
|
+
subject: 'Nightly import failed',
|
|
185
|
+
description: 'The import stopped at row 1200.',
|
|
186
|
+
tags: ['import']
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `subject` and `description` are required; the description uses Textile format
|
|
191
|
+
- `project_id`, `tracker_id` and `assigned_to_id` are optional and default to the configured values. Without any `assigned_to_id`, Redmine assigns the project's default assignee
|
|
192
|
+
- `tags` is optional; the issue gets `default_tags` plus these tags, without duplicates
|
|
193
|
+
- When `enabled` is `false`, it creates no issue and returns `nil`
|
|
194
|
+
- Otherwise each call creates a new issue with one HTTP request, in the calling thread: no checksum, no deduplication, no cooldown
|
|
195
|
+
- Returns the created issue as a Hash (the `issue` object from Redmine's response), or `nil` when Errmine gets no created issue back (disabled, or the request or response failed). After a read timeout or an unreadable response, Redmine can still have created the issue. Errmine logs its own failures to stderr and does not raise them
|
|
196
|
+
|
|
162
197
|
### Disabling Notifications
|
|
163
198
|
|
|
164
199
|
```ruby
|
|
@@ -172,29 +207,43 @@ end
|
|
|
172
207
|
### Checksum Generation
|
|
173
208
|
|
|
174
209
|
Each exception gets an 8-character MD5 checksum based on:
|
|
210
|
+
- Application name (`app_name`)
|
|
175
211
|
- Exception class name
|
|
176
212
|
- Exception message
|
|
177
|
-
- First application backtrace line (containing `/app/`)
|
|
213
|
+
- First application backtrace line (containing `/app/`), or the first backtrace line if none contains `/app/`
|
|
214
|
+
|
|
215
|
+
The same error in two applications with different `app_name` values creates two separate issues.
|
|
178
216
|
|
|
179
217
|
```
|
|
180
|
-
MD5("NoMethodError:undefined method 'foo':app/controllers/users_controller.rb:45")[0..7]
|
|
218
|
+
MD5("my-app:NoMethodError:undefined method 'foo':app/controllers/users_controller.rb:45")[0..7]
|
|
181
219
|
# => "a1b2c3d4"
|
|
182
220
|
```
|
|
183
221
|
|
|
184
222
|
### Deduplication
|
|
185
223
|
|
|
186
224
|
1. Search Redmine for open issues containing `[{checksum}]` in the subject
|
|
187
|
-
2. If found: increment counter
|
|
225
|
+
2. If found: increment the counter in the subject and add a journal note with the new count, the time, `URL:` and `User:` lines when `url` and `user` are set in the context, and the first 10 backtrace lines (see [Journal Note](#journal-note-on-subsequent-occurrences))
|
|
188
226
|
3. If not found: create new issue
|
|
189
227
|
|
|
228
|
+
The rate limit cache is per process, and the search and the create have no lock across processes. Two processes (for example, two web server workers) that report the same new error at the same time can both find no issue and create two issues.
|
|
229
|
+
|
|
190
230
|
### Rate Limiting
|
|
191
231
|
|
|
192
232
|
To prevent flooding Redmine during error loops:
|
|
193
233
|
|
|
194
|
-
- Each checksum is cached with its last
|
|
195
|
-
- Same error won't hit Redmine more than once per cooldown period (default: 5 minutes)
|
|
234
|
+
- Each checksum is cached with the time of its last report that passed the cooldown; reports inside the cooldown are dropped and do not restart it
|
|
235
|
+
- Same error won't hit Redmine more than once per cooldown period (default: 5 minutes) in each process, unless the report fails
|
|
236
|
+
- When a report fails (HTTP error status, timeout, connection error), Errmine logs one warning to stderr and does not resend that occurrence. The next occurrence of the same error after 60 seconds (or after the cooldown, if it is shorter) is reported, instead of being dropped for the whole cooldown. During a Redmine outage the same error can call Redmine once per 60 seconds or once per cooldown, whichever is shorter, in each process
|
|
196
237
|
- Cache is automatically cleaned when it exceeds 500 entries
|
|
197
238
|
|
|
239
|
+
### Synchronous Reporting
|
|
240
|
+
|
|
241
|
+
`Errmine.notify` runs in the thread that reports the error. This includes the Rack middleware and the Rails error subscriber, so the request or job waits until Errmine finishes.
|
|
242
|
+
|
|
243
|
+
- Each report that passes the cooldown makes up to 2 HTTP requests to Redmine: a search, then a create or an update
|
|
244
|
+
- Each request has a 5-second connect timeout (`CONNECT_TIMEOUT`) and a 10-second read timeout (`READ_TIMEOUT`)
|
|
245
|
+
- Errmine's own failures (timeouts, connection and HTTP errors) never raise into your application: Errmine logs them to stderr and `Errmine.notify` returns `nil`
|
|
246
|
+
|
|
198
247
|
## Redmine Setup
|
|
199
248
|
|
|
200
249
|
1. **Enable REST API**: Administration > Settings > API > Enable REST web service
|
|
@@ -211,10 +260,10 @@ To prevent flooding Redmine during error loops:
|
|
|
211
260
|
### Subject
|
|
212
261
|
|
|
213
262
|
```
|
|
214
|
-
[{checksum}][{count}] {ExceptionClass}: {truncated message}
|
|
263
|
+
[{app_name}][{checksum}][{count}] {ExceptionClass}: {truncated message}
|
|
215
264
|
```
|
|
216
265
|
|
|
217
|
-
Example: `[a1b2c3d4][47] NoMethodError: undefined method 'foo' for nil...`
|
|
266
|
+
Example: `[my-app][a1b2c3d4][47] NoMethodError: undefined method 'foo' for nil...`
|
|
218
267
|
|
|
219
268
|
### Description (Textile format)
|
|
220
269
|
|
|
@@ -235,18 +284,23 @@ app/controllers/application_controller.rb:12:in `authenticate'
|
|
|
235
284
|
</pre>
|
|
236
285
|
```
|
|
237
286
|
|
|
287
|
+
The `Backtrace` section holds the first 20 backtrace lines, or `No backtrace available` when the exception has no backtrace.
|
|
288
|
+
|
|
238
289
|
### Journal Note (on subsequent occurrences)
|
|
239
290
|
|
|
240
291
|
```textile
|
|
241
292
|
Occurred again (*47x*) at 2025-01-15 10:35:00
|
|
242
293
|
|
|
243
294
|
URL: /users/456
|
|
295
|
+
User: user@example.com
|
|
244
296
|
|
|
245
297
|
<pre>
|
|
246
298
|
app/controllers/users_controller.rb:45:in `show'
|
|
247
299
|
</pre>
|
|
248
300
|
```
|
|
249
301
|
|
|
302
|
+
The `URL:` and `User:` lines appear when `url` and `user` are set in the context. Other context keys do not appear in the note.
|
|
303
|
+
|
|
250
304
|
## License
|
|
251
305
|
|
|
252
306
|
MIT License. See [LICENSE](LICENSE) for details.
|
data/lib/errmine/middleware.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
4
5
|
module Errmine
|
|
5
6
|
# Rack middleware that catches exceptions and reports them to Redmine.
|
|
6
7
|
# Re-raises the exception after reporting to allow normal error handling.
|
|
7
8
|
class Middleware
|
|
9
|
+
# Replacement for the value of a filtered query parameter
|
|
10
|
+
FILTERED = '[FILTERED]'
|
|
11
|
+
|
|
8
12
|
# Creates a new middleware instance
|
|
9
13
|
#
|
|
10
14
|
# @param app [#call] the Rack application
|
|
@@ -45,7 +49,7 @@ module Errmine
|
|
|
45
49
|
context = {}
|
|
46
50
|
|
|
47
51
|
request_uri = env['REQUEST_URI'] || env['PATH_INFO']
|
|
48
|
-
context[:url] = request_uri if request_uri
|
|
52
|
+
context[:url] = filter_url(request_uri) if request_uri
|
|
49
53
|
|
|
50
54
|
request_method = env['REQUEST_METHOD']
|
|
51
55
|
context[:method] = request_method if request_method
|
|
@@ -57,5 +61,33 @@ module Errmine
|
|
|
57
61
|
|
|
58
62
|
context
|
|
59
63
|
end
|
|
64
|
+
|
|
65
|
+
# Replaces the values of sensitive query parameters with FILTERED
|
|
66
|
+
#
|
|
67
|
+
# @param url [String] the request URI
|
|
68
|
+
# @return [String] the URI with sensitive query values filtered
|
|
69
|
+
def filter_url(url)
|
|
70
|
+
path, query = url.split('?', 2)
|
|
71
|
+
return url unless query
|
|
72
|
+
|
|
73
|
+
pairs = query.split('&', -1).map do |pair|
|
|
74
|
+
raw_key = pair.split('=', 2).first.to_s
|
|
75
|
+
filter_key?(URI.decode_www_form_component(raw_key)) ? "#{raw_key}=#{FILTERED}" : pair
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
"#{path}?#{pairs.join('&')}"
|
|
79
|
+
rescue ArgumentError, EncodingError
|
|
80
|
+
"#{path}?#{FILTERED}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks if a query parameter name matches a configured filter
|
|
84
|
+
#
|
|
85
|
+
# @param key [String] the decoded parameter name
|
|
86
|
+
# @return [Boolean] true if the value of this parameter must be filtered
|
|
87
|
+
def filter_key?(key)
|
|
88
|
+
Errmine.configuration.filter_parameters.any? do |filter|
|
|
89
|
+
filter.is_a?(Regexp) ? filter.match?(key) : key.downcase.include?(filter.to_s.downcase)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
60
92
|
end
|
|
61
93
|
end
|
data/lib/errmine/notifier.rb
CHANGED
|
@@ -6,7 +6,6 @@ require 'json'
|
|
|
6
6
|
require 'digest'
|
|
7
7
|
require 'singleton'
|
|
8
8
|
|
|
9
|
-
# @see Errmine
|
|
10
9
|
module Errmine
|
|
11
10
|
# Core notifier that handles exception reporting to Redmine.
|
|
12
11
|
# Manages checksum generation, rate limiting, and API communication.
|
|
@@ -25,6 +24,14 @@ module Errmine
|
|
|
25
24
|
# Maximum length of exception message in issue subject
|
|
26
25
|
SUBJECT_MESSAGE_LENGTH = 60
|
|
27
26
|
|
|
27
|
+
# Seconds before an error whose report failed is reported again (capped by the cooldown)
|
|
28
|
+
FAILED_REPORT_RETRY_DELAY = 60
|
|
29
|
+
|
|
30
|
+
# Raised internally when a Redmine request fails
|
|
31
|
+
class RequestError < StandardError; end
|
|
32
|
+
|
|
33
|
+
private_constant :FAILED_REPORT_RETRY_DELAY, :RequestError
|
|
34
|
+
|
|
28
35
|
# Initializes the notifier with empty cache
|
|
29
36
|
def initialize
|
|
30
37
|
@cache = {}
|
|
@@ -53,6 +60,10 @@ module Errmine
|
|
|
53
60
|
else
|
|
54
61
|
update_issue(result, exception, context)
|
|
55
62
|
end
|
|
63
|
+
rescue RequestError => e
|
|
64
|
+
retry_delay = schedule_retry(checksum)
|
|
65
|
+
warn "[Errmine] Failed to report #{exception.class} (#{e.message}), retrying in #{retry_delay}s"
|
|
66
|
+
nil
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
# Clears the rate limit cache
|
|
@@ -70,6 +81,7 @@ module Errmine
|
|
|
70
81
|
# @option options [String] :project_id Redmine project identifier
|
|
71
82
|
# @option options [Integer] :tracker_id Redmine tracker ID
|
|
72
83
|
# @option options [Array<String>] :tags tags to add to the issue
|
|
84
|
+
# @option options [Integer] :assigned_to_id Redmine user ID to assign the issue to
|
|
73
85
|
# @return [Hash, nil] the created issue or nil on failure
|
|
74
86
|
def create_custom_issue(subject:, description:, **options)
|
|
75
87
|
uri = build_uri('/issues.json')
|
|
@@ -77,21 +89,26 @@ module Errmine
|
|
|
77
89
|
issue_data = {
|
|
78
90
|
project_id: options[:project_id] || config.project_id,
|
|
79
91
|
tracker_id: options[:tracker_id] || config.tracker_id,
|
|
80
|
-
subject: subject,
|
|
81
|
-
description: description
|
|
92
|
+
subject: to_utf8(subject),
|
|
93
|
+
description: to_utf8(description)
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
tags = build_tag_list(options[:tags])
|
|
85
97
|
issue_data[:tag_list] = tags unless tags.empty?
|
|
86
98
|
|
|
99
|
+
assigned_to_id = options[:assigned_to_id] || config.assigned_to_id
|
|
100
|
+
issue_data[:assigned_to_id] = assigned_to_id if assigned_to_id
|
|
101
|
+
|
|
87
102
|
response = http_post(uri, { issue: issue_data })
|
|
88
|
-
return nil unless response
|
|
89
103
|
|
|
90
104
|
data = JSON.parse(response.body)
|
|
91
105
|
data['issue']
|
|
92
106
|
rescue JSON::ParserError => e
|
|
93
107
|
warn "[Errmine] Failed to parse response: #{e.message}"
|
|
94
108
|
nil
|
|
109
|
+
rescue RequestError => e
|
|
110
|
+
warn "[Errmine] #{e.message}"
|
|
111
|
+
nil
|
|
95
112
|
end
|
|
96
113
|
|
|
97
114
|
private
|
|
@@ -110,7 +127,21 @@ module Errmine
|
|
|
110
127
|
def build_tag_list(tags)
|
|
111
128
|
combined = config.default_tags.dup
|
|
112
129
|
combined.concat(Array(tags)) if tags
|
|
113
|
-
combined.uniq
|
|
130
|
+
combined.map { |tag| to_utf8(tag) }.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Converts a value to valid UTF-8 text, replacing invalid and undefined bytes
|
|
134
|
+
#
|
|
135
|
+
# @param value [Object]
|
|
136
|
+
# @return [String]
|
|
137
|
+
def to_utf8(value)
|
|
138
|
+
text = value.to_s
|
|
139
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
140
|
+
return text.dup.force_encoding(Encoding::UTF_8).scrub if text.encoding == Encoding::BINARY
|
|
141
|
+
|
|
142
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
143
|
+
rescue EncodingError
|
|
144
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
114
145
|
end
|
|
115
146
|
|
|
116
147
|
# Generates an 8-character checksum for the exception
|
|
@@ -119,7 +150,7 @@ module Errmine
|
|
|
119
150
|
# @return [String]
|
|
120
151
|
def generate_checksum(exception)
|
|
121
152
|
first_app_line = first_app_backtrace_line(exception)
|
|
122
|
-
data =
|
|
153
|
+
data = [config.app_name, exception.class, exception.message, first_app_line].map { |part| part.to_s.b }.join(':')
|
|
123
154
|
Digest::MD5.hexdigest(data)[0, 8]
|
|
124
155
|
end
|
|
125
156
|
|
|
@@ -159,6 +190,20 @@ module Errmine
|
|
|
159
190
|
end
|
|
160
191
|
end
|
|
161
192
|
|
|
193
|
+
# Shortens the rate limit of a checksum whose report failed to the retry delay
|
|
194
|
+
#
|
|
195
|
+
# @param checksum [String]
|
|
196
|
+
# @return [Numeric] seconds until the checksum may be reported again
|
|
197
|
+
def schedule_retry(checksum)
|
|
198
|
+
retry_delay = [FAILED_REPORT_RETRY_DELAY, config.cooldown].min
|
|
199
|
+
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
@cache[checksum] = Time.now - config.cooldown + retry_delay
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
retry_delay
|
|
205
|
+
end
|
|
206
|
+
|
|
162
207
|
# Cleans up old entries from the cache
|
|
163
208
|
#
|
|
164
209
|
# @return [void]
|
|
@@ -169,14 +214,15 @@ module Errmine
|
|
|
169
214
|
return unless @cache.size >= MAX_CACHE_SIZE
|
|
170
215
|
|
|
171
216
|
sorted = @cache.sort_by { |_, time| time }
|
|
172
|
-
to_remove = sorted.first(@cache.size - (MAX_CACHE_SIZE / 2))
|
|
173
|
-
to_remove.
|
|
217
|
+
to_remove = sorted.first(@cache.size - (MAX_CACHE_SIZE / 2)).map(&:first)
|
|
218
|
+
to_remove.each { |key| @cache.delete(key) }
|
|
174
219
|
end
|
|
175
220
|
|
|
176
221
|
# Finds an existing open issue with the given checksum
|
|
177
222
|
#
|
|
178
223
|
# @param checksum [String]
|
|
179
|
-
# @return [Hash, Symbol, nil] the issue hash, :error on
|
|
224
|
+
# @return [Hash, Symbol, nil] the issue hash, :error on an unparsable response, or nil if not found
|
|
225
|
+
# @raise [RequestError] when the request fails
|
|
180
226
|
def find_existing_issue(checksum)
|
|
181
227
|
uri = build_uri('/issues.json')
|
|
182
228
|
uri.query = URI.encode_www_form(
|
|
@@ -186,7 +232,6 @@ module Errmine
|
|
|
186
232
|
)
|
|
187
233
|
|
|
188
234
|
response = http_get(uri)
|
|
189
|
-
return :error unless response
|
|
190
235
|
|
|
191
236
|
data = JSON.parse(response.body)
|
|
192
237
|
issues = data['issues'] || []
|
|
@@ -203,6 +248,7 @@ module Errmine
|
|
|
203
248
|
# @param context [Hash]
|
|
204
249
|
# @param checksum [String]
|
|
205
250
|
# @return [Hash, nil]
|
|
251
|
+
# @raise [RequestError] when the request fails
|
|
206
252
|
def create_issue(exception, context, checksum)
|
|
207
253
|
uri = build_uri('/issues.json')
|
|
208
254
|
|
|
@@ -219,8 +265,10 @@ module Errmine
|
|
|
219
265
|
tags = build_tag_list(context[:tags])
|
|
220
266
|
issue_data[:tag_list] = tags unless tags.empty?
|
|
221
267
|
|
|
268
|
+
assigned_to_id = context[:assigned_to_id] || config.assigned_to_id
|
|
269
|
+
issue_data[:assigned_to_id] = assigned_to_id if assigned_to_id
|
|
270
|
+
|
|
222
271
|
response = http_post(uri, { issue: issue_data })
|
|
223
|
-
return nil unless response
|
|
224
272
|
|
|
225
273
|
data = JSON.parse(response.body)
|
|
226
274
|
data['issue']
|
|
@@ -234,7 +282,8 @@ module Errmine
|
|
|
234
282
|
# @param issue [Hash]
|
|
235
283
|
# @param exception [Exception]
|
|
236
284
|
# @param context [Hash]
|
|
237
|
-
# @return [Net::HTTPResponse
|
|
285
|
+
# @return [Net::HTTPResponse]
|
|
286
|
+
# @raise [RequestError] when the request fails
|
|
238
287
|
def update_issue(issue, exception, context)
|
|
239
288
|
issue_id = issue['id']
|
|
240
289
|
current_subject = issue['subject']
|
|
@@ -264,7 +313,7 @@ module Errmine
|
|
|
264
313
|
# @param exception [Exception]
|
|
265
314
|
# @return [String]
|
|
266
315
|
def build_subject(checksum, count, exception)
|
|
267
|
-
message = exception.message
|
|
316
|
+
message = to_utf8(exception.message)
|
|
268
317
|
truncated = message.length > SUBJECT_MESSAGE_LENGTH ? "#{message[0, SUBJECT_MESSAGE_LENGTH]}..." : message
|
|
269
318
|
truncated = truncated.gsub(/[\r\n]+/, ' ').strip
|
|
270
319
|
|
|
@@ -279,19 +328,19 @@ module Errmine
|
|
|
279
328
|
def build_description(exception, context)
|
|
280
329
|
lines = []
|
|
281
330
|
lines << "**Exception:** @#{exception.class}@"
|
|
282
|
-
lines << "**Message:** #{exception.message}"
|
|
331
|
+
lines << "**Message:** #{to_utf8(exception.message)}"
|
|
283
332
|
lines << "**App:** #{config.app_name}"
|
|
284
333
|
lines << "**First seen:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
285
334
|
lines << ''
|
|
286
335
|
|
|
287
|
-
lines << "**URL:** #{context[:url]}" if context[:url]
|
|
336
|
+
lines << "**URL:** #{to_utf8(context[:url])}" if context[:url]
|
|
288
337
|
|
|
289
|
-
lines << "**User:** #{context[:user]}" if context[:user]
|
|
338
|
+
lines << "**User:** #{to_utf8(context[:user])}" if context[:user]
|
|
290
339
|
|
|
291
340
|
context.each do |key, value|
|
|
292
341
|
next if %i[url user].include?(key)
|
|
293
342
|
|
|
294
|
-
lines << "**#{key.
|
|
343
|
+
lines << "**#{to_utf8(key).capitalize}:** #{to_utf8(value)}"
|
|
295
344
|
end
|
|
296
345
|
|
|
297
346
|
lines << ''
|
|
@@ -315,8 +364,8 @@ module Errmine
|
|
|
315
364
|
lines << "Occurred again (*#{count}x*) at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
316
365
|
lines << ''
|
|
317
366
|
|
|
318
|
-
lines << "URL: #{context[:url]}" if context[:url]
|
|
319
|
-
lines << "User: #{context[:user]}" if context[:user]
|
|
367
|
+
lines << "URL: #{to_utf8(context[:url])}" if context[:url]
|
|
368
|
+
lines << "User: #{to_utf8(context[:user])}" if context[:user]
|
|
320
369
|
|
|
321
370
|
lines << ''
|
|
322
371
|
lines << '<pre>'
|
|
@@ -334,7 +383,7 @@ module Errmine
|
|
|
334
383
|
def format_backtrace(exception, limit: 20)
|
|
335
384
|
return 'No backtrace available' unless exception.backtrace
|
|
336
385
|
|
|
337
|
-
exception.backtrace.first(limit).join("\n")
|
|
386
|
+
exception.backtrace.first(limit).map { |line| to_utf8(line) }.join("\n")
|
|
338
387
|
end
|
|
339
388
|
|
|
340
389
|
# Extracts the occurrence count from issue subject
|
|
@@ -367,7 +416,8 @@ module Errmine
|
|
|
367
416
|
# Performs an HTTP GET request
|
|
368
417
|
#
|
|
369
418
|
# @param uri [URI]
|
|
370
|
-
# @return [Net::HTTPResponse
|
|
419
|
+
# @return [Net::HTTPResponse]
|
|
420
|
+
# @raise [RequestError] when the request fails
|
|
371
421
|
def http_get(uri)
|
|
372
422
|
http_request(uri) do |http|
|
|
373
423
|
request = Net::HTTP::Get.new(uri)
|
|
@@ -380,7 +430,8 @@ module Errmine
|
|
|
380
430
|
#
|
|
381
431
|
# @param uri [URI]
|
|
382
432
|
# @param payload [Hash]
|
|
383
|
-
# @return [Net::HTTPResponse
|
|
433
|
+
# @return [Net::HTTPResponse]
|
|
434
|
+
# @raise [RequestError] when the request fails
|
|
384
435
|
def http_post(uri, payload)
|
|
385
436
|
http_request(uri) do |http|
|
|
386
437
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -395,7 +446,8 @@ module Errmine
|
|
|
395
446
|
#
|
|
396
447
|
# @param uri [URI]
|
|
397
448
|
# @param payload [Hash]
|
|
398
|
-
# @return [Net::HTTPResponse
|
|
449
|
+
# @return [Net::HTTPResponse]
|
|
450
|
+
# @raise [RequestError] when the request fails
|
|
399
451
|
def http_put(uri, payload)
|
|
400
452
|
http_request(uri) do |http|
|
|
401
453
|
request = Net::HTTP::Put.new(uri)
|
|
@@ -406,10 +458,11 @@ module Errmine
|
|
|
406
458
|
end
|
|
407
459
|
end
|
|
408
460
|
|
|
409
|
-
# Executes an HTTP request
|
|
461
|
+
# Executes an HTTP request, converting failures into RequestError
|
|
410
462
|
#
|
|
411
463
|
# @param uri [URI]
|
|
412
|
-
# @return [Net::HTTPResponse
|
|
464
|
+
# @return [Net::HTTPResponse]
|
|
465
|
+
# @raise [RequestError] when the request fails
|
|
413
466
|
def http_request(uri)
|
|
414
467
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
415
468
|
http.use_ssl = uri.scheme == 'https'
|
|
@@ -418,21 +471,17 @@ module Errmine
|
|
|
418
471
|
|
|
419
472
|
response = yield(http)
|
|
420
473
|
|
|
421
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
422
|
-
warn "[Errmine] HTTP #{response.code}: #{response.message}"
|
|
423
|
-
return nil
|
|
424
|
-
end
|
|
474
|
+
raise RequestError, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
425
475
|
|
|
426
476
|
response
|
|
477
|
+
rescue RequestError
|
|
478
|
+
raise
|
|
427
479
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
428
|
-
|
|
429
|
-
nil
|
|
480
|
+
raise RequestError, "Timeout: #{e.message}"
|
|
430
481
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
431
|
-
|
|
432
|
-
nil
|
|
482
|
+
raise RequestError, "Connection error: #{e.message}"
|
|
433
483
|
rescue StandardError => e
|
|
434
|
-
|
|
435
|
-
nil
|
|
484
|
+
raise RequestError, "HTTP error: #{e.message}"
|
|
436
485
|
end
|
|
437
486
|
end
|
|
438
487
|
end
|
data/lib/errmine/railtie.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'middleware'
|
|
4
4
|
|
|
5
|
-
# @see Errmine
|
|
6
5
|
module Errmine
|
|
7
6
|
# Rails integration via Railtie.
|
|
8
7
|
# Automatically subscribes to Rails 7+ Error Reporting API when available.
|
|
@@ -11,6 +10,14 @@ module Errmine
|
|
|
11
10
|
Rails.error.subscribe(ErrorSubscriber.new) if Rails.version >= '7.0'
|
|
12
11
|
end
|
|
13
12
|
|
|
13
|
+
config.after_initialize do |app|
|
|
14
|
+
rails_filters = app.config.filter_parameters.select do |filter|
|
|
15
|
+
filter.is_a?(String) || filter.is_a?(Symbol) || filter.is_a?(Regexp)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Errmine.configuration.filter_parameters |= rails_filters
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
# Error subscriber for Rails 7+ Error Reporting API
|
|
15
22
|
class ErrorSubscriber
|
|
16
23
|
# Called by Rails when an error is reported
|
data/lib/errmine/version.rb
CHANGED
data/lib/errmine.rb
CHANGED
|
@@ -32,12 +32,18 @@ module Errmine
|
|
|
32
32
|
# @return [Array<String>] Default tags to add to all issues
|
|
33
33
|
attr_accessor :default_tags
|
|
34
34
|
|
|
35
|
+
# @return [Integer, nil] Redmine user ID new issues are assigned to (nil: project default)
|
|
36
|
+
attr_accessor :assigned_to_id
|
|
37
|
+
|
|
35
38
|
# @return [Boolean] Whether notifications are enabled
|
|
36
39
|
attr_accessor :enabled
|
|
37
40
|
|
|
38
41
|
# @return [Integer] Cooldown period in seconds between same-error notifications
|
|
39
42
|
attr_accessor :cooldown
|
|
40
43
|
|
|
44
|
+
# @return [Array<String, Symbol, Regexp>] Query parameter names whose values the middleware filters
|
|
45
|
+
attr_accessor :filter_parameters
|
|
46
|
+
|
|
41
47
|
# Initializes configuration with defaults from environment variables
|
|
42
48
|
def initialize
|
|
43
49
|
@redmine_url = ENV.fetch('ERRMINE_REDMINE_URL', nil)
|
|
@@ -48,6 +54,8 @@ module Errmine
|
|
|
48
54
|
@default_tags = []
|
|
49
55
|
@enabled = true
|
|
50
56
|
@cooldown = 300
|
|
57
|
+
@assigned_to_id = nil
|
|
58
|
+
@filter_parameters = %w[passw email secret token _key crypt salt certificate otp ssn cvv cvc]
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
# Checks if the configuration has required values
|
|
@@ -106,6 +114,7 @@ module Errmine
|
|
|
106
114
|
# @option options [String] :project_id Redmine project identifier
|
|
107
115
|
# @option options [Integer] :tracker_id Redmine tracker ID
|
|
108
116
|
# @option options [Array<String>] :tags tags to add to the issue
|
|
117
|
+
# @option options [Integer] :assigned_to_id Redmine user ID to assign the issue to
|
|
109
118
|
# @return [Hash, nil] the created issue or nil on failure
|
|
110
119
|
def create_issue(subject:, description:, **options)
|
|
111
120
|
return unless configuration.enabled
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: errmine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Maciej Mensfeld
|
|
@@ -29,8 +29,11 @@ homepage: https://github.com/mensfeld/errmine
|
|
|
29
29
|
licenses:
|
|
30
30
|
- MIT
|
|
31
31
|
metadata:
|
|
32
|
+
homepage_uri: https://github.com/mensfeld/errmine
|
|
32
33
|
source_code_uri: https://github.com/mensfeld/errmine
|
|
33
34
|
changelog_uri: https://github.com/mensfeld/errmine/blob/master/CHANGELOG.md
|
|
35
|
+
bug_tracker_uri: https://github.com/mensfeld/errmine/issues
|
|
36
|
+
documentation_uri: https://github.com/mensfeld/errmine
|
|
34
37
|
rubygems_mfa_required: 'true'
|
|
35
38
|
rdoc_options: []
|
|
36
39
|
require_paths:
|
|
@@ -39,14 +42,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
39
42
|
requirements:
|
|
40
43
|
- - ">="
|
|
41
44
|
- !ruby/object:Gem::Version
|
|
42
|
-
version:
|
|
45
|
+
version: 4.0.0
|
|
43
46
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
47
|
requirements:
|
|
45
48
|
- - ">="
|
|
46
49
|
- !ruby/object:Gem::Version
|
|
47
50
|
version: '0'
|
|
48
51
|
requirements: []
|
|
49
|
-
rubygems_version:
|
|
52
|
+
rubygems_version: 4.0.20
|
|
50
53
|
specification_version: 4
|
|
51
54
|
summary: Dead simple exception tracking for Redmine
|
|
52
55
|
test_files: []
|