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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f71e4d605d9ce77753b0ffdea51499feb507ecc8b65fc156890c04a86bad270
4
- data.tar.gz: 2c1d3242c5daa6c66035e13a59f68c6a890156f9f48d7635a6d535c9762ba0bb
3
+ metadata.gz: b1a9a7a387a6803ba0b0adad94a879f28532586c6e5ac0bb78f8319a01e94258
4
+ data.tar.gz: f77ffb7eb12eec128b2f83170781bc443b71c171e91852374ab0b3cabd087e8d
5
5
  SHA512:
6
- metadata.gz: dc3955573efbb9a1cff490071a3678f3a2bc7a6c04469f15a253b44560d275b0c4a26e84cd41583e80f47e421f11cfaf0b0a74ee5238b33ec8b8df27bfa6a140
7
- data.tar.gz: 06eef437876a00211d6ccceaac6d06dca2846e1a1526c595e531e341379e30b788ce63f38aab46d97ff2b139e67ebb5f5ef83b06e22093b8b5af2785effb8ede
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 `tag_list` parameter for both exception reporting and custom issues.
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
- [![Build Status](https://github.com/mensfeld/errmine/workflows/ci/badge.svg)](https://github.com/mensfeld/errmine/actions?query=workflow%3Aci)
8
7
  [![Gem Version](https://badge.fury.io/rb/errmine.svg)](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 counting shows error frequency at a glance
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
- | `enabled` | `true` | Enable/disable notifications |
109
- | `cooldown` | `300` | Seconds between same-error notifications |
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
- The middleware captures request context (URL, HTTP method, user via Warden).
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.url,
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.url,
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, add journal note with timestamp and backtrace
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 occurrence time
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.
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # @see Errmine
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
@@ -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 = "#{config.app_name}:#{exception.class}:#{exception.message}:#{first_app_line}"
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.each_key { |key| @cache.delete(key) }
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 failure, or nil if not found
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, nil]
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.to_s
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.to_s.capitalize}:** #{value}"
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, nil]
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, nil]
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, nil]
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 with error handling
461
+ # Executes an HTTP request, converting failures into RequestError
410
462
  #
411
463
  # @param uri [URI]
412
- # @return [Net::HTTPResponse, nil]
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
- warn "[Errmine] Timeout: #{e.message}"
429
- nil
480
+ raise RequestError, "Timeout: #{e.message}"
430
481
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
431
- warn "[Errmine] Connection error: #{e.message}"
432
- nil
482
+ raise RequestError, "Connection error: #{e.message}"
433
483
  rescue StandardError => e
434
- warn "[Errmine] HTTP error: #{e.message}"
435
- nil
484
+ raise RequestError, "HTTP error: #{e.message}"
436
485
  end
437
486
  end
438
487
  end
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Errmine
4
4
  # Current version of the errmine gem
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
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.2.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: 3.3.0
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: 3.6.9
52
+ rubygems_version: 4.0.20
50
53
  specification_version: 4
51
54
  summary: Dead simple exception tracking for Redmine
52
55
  test_files: []