errmine 0.1.1 → 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: 14575fe3e975be6308fefa8663defb90c80e552766ac0b1c552feee30c3e7209
4
- data.tar.gz: a967ca060190180ff8bb6d76abe237f131aab11b842f8b57c1ce6cef16fd417f
3
+ metadata.gz: b1a9a7a387a6803ba0b0adad94a879f28532586c6e5ac0bb78f8319a01e94258
4
+ data.tar.gz: f77ffb7eb12eec128b2f83170781bc443b71c171e91852374ab0b3cabd087e8d
5
5
  SHA512:
6
- metadata.gz: e057ce4bd6639aa550b148c245bb2224ac126546a4cc287842fcb26d5cbf227fddfed1fc473a5d241bf0405511712be256308234a96cc0504e32149214b2489a
7
- data.tar.gz: 6291e7878535a180967c319a05d78379962f6209c4e972f062f8d1d0cdfa36c92be4fada846e8d552d9c983fb301d71833dad3cc52f418b4acf7de020e8f35ea
6
+ metadata.gz: 5e571d17b1b8f989be5c19c3cd0170e0e33ca5046e4f57aeb2cc544f9e8d58e7f1619cfdf115198b19532d3da0d6a1cf12eb1e8528e323908ce4fe4f5d1966fc
7
+ data.tar.gz: 31a9fb8289ccafb5d7de40beadf653117d240055bfd2d8ecfff708f4465f9b8655989a7c27c24b05bfae0206202efe15bb81a7d7af0484b613555aea2a8de606
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
12
+ ## 0.2.0 (2025-12-24)
13
+ - [Feature] Add app name to issue title for easy identification when multiple apps report to the same Redmine project.
14
+ - [Feature] Include app_name in checksum calculation so same errors in different apps create separate issues.
15
+ - [Feature] Add `Errmine.create_issue` method for creating custom Redmine issues with arbitrary subject, description, tracker, project, and tags.
16
+ - [Feature] Add tags support via the `tags` option for both exception reporting and custom issues.
17
+ - [Feature] Add `default_tags` configuration option for tags applied to all issues.
18
+ - [Maintenance] Update actions/checkout to v6 in CI.
19
+
3
20
  ## 0.1.1 (2025-12-23)
4
21
  - [Fix] Fix file permissions for gem packaging.
5
22
  - [Fix] Fix gemspec metadata duplicate URI warning.
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
@@ -62,6 +73,44 @@ module Errmine
62
73
  @mutex.synchronize { @cache.clear }
63
74
  end
64
75
 
76
+ # Creates a custom issue in Redmine
77
+ #
78
+ # @param subject [String] issue subject/title
79
+ # @param description [String] issue description (Textile format)
80
+ # @param options [Hash] optional overrides
81
+ # @option options [String] :project_id Redmine project identifier
82
+ # @option options [Integer] :tracker_id Redmine tracker ID
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
85
+ # @return [Hash, nil] the created issue or nil on failure
86
+ def create_custom_issue(subject:, description:, **options)
87
+ uri = build_uri('/issues.json')
88
+
89
+ issue_data = {
90
+ project_id: options[:project_id] || config.project_id,
91
+ tracker_id: options[:tracker_id] || config.tracker_id,
92
+ subject: to_utf8(subject),
93
+ description: to_utf8(description)
94
+ }
95
+
96
+ tags = build_tag_list(options[:tags])
97
+ issue_data[:tag_list] = tags unless tags.empty?
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
+
102
+ response = http_post(uri, { issue: issue_data })
103
+
104
+ data = JSON.parse(response.body)
105
+ data['issue']
106
+ rescue JSON::ParserError => e
107
+ warn "[Errmine] Failed to parse response: #{e.message}"
108
+ nil
109
+ rescue RequestError => e
110
+ warn "[Errmine] #{e.message}"
111
+ nil
112
+ end
113
+
65
114
  private
66
115
 
67
116
  # Returns the Errmine configuration
@@ -71,13 +120,37 @@ module Errmine
71
120
  Errmine.configuration
72
121
  end
73
122
 
123
+ # Builds a combined tag list from default tags and provided tags
124
+ #
125
+ # @param tags [Array<String>, nil] additional tags to include
126
+ # @return [Array<String>] combined unique tags
127
+ def build_tag_list(tags)
128
+ combined = config.default_tags.dup
129
+ combined.concat(Array(tags)) if tags
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
145
+ end
146
+
74
147
  # Generates an 8-character checksum for the exception
75
148
  #
76
149
  # @param exception [Exception]
77
150
  # @return [String]
78
151
  def generate_checksum(exception)
79
152
  first_app_line = first_app_backtrace_line(exception)
80
- data = "#{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(':')
81
154
  Digest::MD5.hexdigest(data)[0, 8]
82
155
  end
83
156
 
@@ -117,6 +190,20 @@ module Errmine
117
190
  end
118
191
  end
119
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
+
120
207
  # Cleans up old entries from the cache
121
208
  #
122
209
  # @return [void]
@@ -127,14 +214,15 @@ module Errmine
127
214
  return unless @cache.size >= MAX_CACHE_SIZE
128
215
 
129
216
  sorted = @cache.sort_by { |_, time| time }
130
- to_remove = sorted.first(@cache.size - (MAX_CACHE_SIZE / 2))
131
- 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) }
132
219
  end
133
220
 
134
221
  # Finds an existing open issue with the given checksum
135
222
  #
136
223
  # @param checksum [String]
137
- # @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
138
226
  def find_existing_issue(checksum)
139
227
  uri = build_uri('/issues.json')
140
228
  uri.query = URI.encode_www_form(
@@ -144,7 +232,6 @@ module Errmine
144
232
  )
145
233
 
146
234
  response = http_get(uri)
147
- return :error unless response
148
235
 
149
236
  data = JSON.parse(response.body)
150
237
  issues = data['issues'] || []
@@ -161,23 +248,27 @@ module Errmine
161
248
  # @param context [Hash]
162
249
  # @param checksum [String]
163
250
  # @return [Hash, nil]
251
+ # @raise [RequestError] when the request fails
164
252
  def create_issue(exception, context, checksum)
165
253
  uri = build_uri('/issues.json')
166
254
 
167
255
  subject = build_subject(checksum, 1, exception)
168
256
  description = build_description(exception, context)
169
257
 
170
- payload = {
171
- issue: {
172
- project_id: config.project_id,
173
- tracker_id: config.tracker_id,
174
- subject: subject,
175
- description: description
176
- }
258
+ issue_data = {
259
+ project_id: config.project_id,
260
+ tracker_id: config.tracker_id,
261
+ subject: subject,
262
+ description: description
177
263
  }
178
264
 
179
- response = http_post(uri, payload)
180
- return nil unless response
265
+ tags = build_tag_list(context[:tags])
266
+ issue_data[:tag_list] = tags unless tags.empty?
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
+
271
+ response = http_post(uri, { issue: issue_data })
181
272
 
182
273
  data = JSON.parse(response.body)
183
274
  data['issue']
@@ -191,7 +282,8 @@ module Errmine
191
282
  # @param issue [Hash]
192
283
  # @param exception [Exception]
193
284
  # @param context [Hash]
194
- # @return [Net::HTTPResponse, nil]
285
+ # @return [Net::HTTPResponse]
286
+ # @raise [RequestError] when the request fails
195
287
  def update_issue(issue, exception, context)
196
288
  issue_id = issue['id']
197
289
  current_subject = issue['subject']
@@ -221,11 +313,11 @@ module Errmine
221
313
  # @param exception [Exception]
222
314
  # @return [String]
223
315
  def build_subject(checksum, count, exception)
224
- message = exception.message.to_s
316
+ message = to_utf8(exception.message)
225
317
  truncated = message.length > SUBJECT_MESSAGE_LENGTH ? "#{message[0, SUBJECT_MESSAGE_LENGTH]}..." : message
226
318
  truncated = truncated.gsub(/[\r\n]+/, ' ').strip
227
319
 
228
- "[#{checksum}][#{count}] #{exception.class}: #{truncated}"
320
+ "[#{config.app_name}][#{checksum}][#{count}] #{exception.class}: #{truncated}"
229
321
  end
230
322
 
231
323
  # Builds the issue description in Textile format
@@ -236,19 +328,19 @@ module Errmine
236
328
  def build_description(exception, context)
237
329
  lines = []
238
330
  lines << "**Exception:** @#{exception.class}@"
239
- lines << "**Message:** #{exception.message}"
331
+ lines << "**Message:** #{to_utf8(exception.message)}"
240
332
  lines << "**App:** #{config.app_name}"
241
333
  lines << "**First seen:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
242
334
  lines << ''
243
335
 
244
- lines << "**URL:** #{context[:url]}" if context[:url]
336
+ lines << "**URL:** #{to_utf8(context[:url])}" if context[:url]
245
337
 
246
- lines << "**User:** #{context[:user]}" if context[:user]
338
+ lines << "**User:** #{to_utf8(context[:user])}" if context[:user]
247
339
 
248
340
  context.each do |key, value|
249
341
  next if %i[url user].include?(key)
250
342
 
251
- lines << "**#{key.to_s.capitalize}:** #{value}"
343
+ lines << "**#{to_utf8(key).capitalize}:** #{to_utf8(value)}"
252
344
  end
253
345
 
254
346
  lines << ''
@@ -272,8 +364,8 @@ module Errmine
272
364
  lines << "Occurred again (*#{count}x*) at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
273
365
  lines << ''
274
366
 
275
- lines << "URL: #{context[:url]}" if context[:url]
276
- 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]
277
369
 
278
370
  lines << ''
279
371
  lines << '<pre>'
@@ -291,7 +383,7 @@ module Errmine
291
383
  def format_backtrace(exception, limit: 20)
292
384
  return 'No backtrace available' unless exception.backtrace
293
385
 
294
- exception.backtrace.first(limit).join("\n")
386
+ exception.backtrace.first(limit).map { |line| to_utf8(line) }.join("\n")
295
387
  end
296
388
 
297
389
  # Extracts the occurrence count from issue subject
@@ -324,7 +416,8 @@ module Errmine
324
416
  # Performs an HTTP GET request
325
417
  #
326
418
  # @param uri [URI]
327
- # @return [Net::HTTPResponse, nil]
419
+ # @return [Net::HTTPResponse]
420
+ # @raise [RequestError] when the request fails
328
421
  def http_get(uri)
329
422
  http_request(uri) do |http|
330
423
  request = Net::HTTP::Get.new(uri)
@@ -337,7 +430,8 @@ module Errmine
337
430
  #
338
431
  # @param uri [URI]
339
432
  # @param payload [Hash]
340
- # @return [Net::HTTPResponse, nil]
433
+ # @return [Net::HTTPResponse]
434
+ # @raise [RequestError] when the request fails
341
435
  def http_post(uri, payload)
342
436
  http_request(uri) do |http|
343
437
  request = Net::HTTP::Post.new(uri)
@@ -352,7 +446,8 @@ module Errmine
352
446
  #
353
447
  # @param uri [URI]
354
448
  # @param payload [Hash]
355
- # @return [Net::HTTPResponse, nil]
449
+ # @return [Net::HTTPResponse]
450
+ # @raise [RequestError] when the request fails
356
451
  def http_put(uri, payload)
357
452
  http_request(uri) do |http|
358
453
  request = Net::HTTP::Put.new(uri)
@@ -363,10 +458,11 @@ module Errmine
363
458
  end
364
459
  end
365
460
 
366
- # Executes an HTTP request with error handling
461
+ # Executes an HTTP request, converting failures into RequestError
367
462
  #
368
463
  # @param uri [URI]
369
- # @return [Net::HTTPResponse, nil]
464
+ # @return [Net::HTTPResponse]
465
+ # @raise [RequestError] when the request fails
370
466
  def http_request(uri)
371
467
  http = Net::HTTP.new(uri.host, uri.port)
372
468
  http.use_ssl = uri.scheme == 'https'
@@ -375,21 +471,17 @@ module Errmine
375
471
 
376
472
  response = yield(http)
377
473
 
378
- unless response.is_a?(Net::HTTPSuccess)
379
- warn "[Errmine] HTTP #{response.code}: #{response.message}"
380
- return nil
381
- end
474
+ raise RequestError, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
382
475
 
383
476
  response
477
+ rescue RequestError
478
+ raise
384
479
  rescue Net::OpenTimeout, Net::ReadTimeout => e
385
- warn "[Errmine] Timeout: #{e.message}"
386
- nil
480
+ raise RequestError, "Timeout: #{e.message}"
387
481
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
388
- warn "[Errmine] Connection error: #{e.message}"
389
- nil
482
+ raise RequestError, "Connection error: #{e.message}"
390
483
  rescue StandardError => e
391
- warn "[Errmine] HTTP error: #{e.message}"
392
- nil
484
+ raise RequestError, "HTTP error: #{e.message}"
393
485
  end
394
486
  end
395
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.1.1'
5
+ VERSION = '0.3.0'
6
6
  end
data/lib/errmine.rb CHANGED
@@ -29,21 +29,33 @@ module Errmine
29
29
  # @return [String] Application name shown in issues
30
30
  attr_accessor :app_name
31
31
 
32
+ # @return [Array<String>] Default tags to add to all issues
33
+ attr_accessor :default_tags
34
+
35
+ # @return [Integer, nil] Redmine user ID new issues are assigned to (nil: project default)
36
+ attr_accessor :assigned_to_id
37
+
32
38
  # @return [Boolean] Whether notifications are enabled
33
39
  attr_accessor :enabled
34
40
 
35
41
  # @return [Integer] Cooldown period in seconds between same-error notifications
36
42
  attr_accessor :cooldown
37
43
 
44
+ # @return [Array<String, Symbol, Regexp>] Query parameter names whose values the middleware filters
45
+ attr_accessor :filter_parameters
46
+
38
47
  # Initializes configuration with defaults from environment variables
39
48
  def initialize
40
- @redmine_url = ENV.fetch('ERRMINE_REDMINE_URL', nil)
41
- @api_key = ENV.fetch('ERRMINE_API_KEY', nil)
42
- @project_id = ENV['ERRMINE_PROJECT'] || 'bug-tracker'
43
- @tracker_id = 1
44
- @app_name = ENV['ERRMINE_APP_NAME'] || 'unknown'
45
- @enabled = true
46
- @cooldown = 300
49
+ @redmine_url = ENV.fetch('ERRMINE_REDMINE_URL', nil)
50
+ @api_key = ENV.fetch('ERRMINE_API_KEY', nil)
51
+ @project_id = ENV['ERRMINE_PROJECT'] || 'bug-tracker'
52
+ @tracker_id = 1
53
+ @app_name = ENV['ERRMINE_APP_NAME'] || 'unknown'
54
+ @default_tags = []
55
+ @enabled = true
56
+ @cooldown = 300
57
+ @assigned_to_id = nil
58
+ @filter_parameters = %w[passw email secret token _key crypt salt certificate otp ssn cvv cvc]
47
59
  end
48
60
 
49
61
  # Checks if the configuration has required values
@@ -93,6 +105,26 @@ module Errmine
93
105
  warn "[Errmine] Failed to notify: #{e.message}"
94
106
  nil
95
107
  end
108
+
109
+ # Creates a custom issue in Redmine
110
+ #
111
+ # @param subject [String] issue subject/title
112
+ # @param description [String] issue description (Textile format)
113
+ # @param options [Hash] optional keyword arguments
114
+ # @option options [String] :project_id Redmine project identifier
115
+ # @option options [Integer] :tracker_id Redmine tracker ID
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
118
+ # @return [Hash, nil] the created issue or nil on failure
119
+ def create_issue(subject:, description:, **options)
120
+ return unless configuration.enabled
121
+ return unless configuration.valid?
122
+
123
+ Notifier.instance.create_custom_issue(subject: subject, description: description, **options)
124
+ rescue StandardError => e
125
+ warn "[Errmine] Failed to create issue: #{e.message}"
126
+ nil
127
+ end
96
128
  end
97
129
  end
98
130
 
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.1.1
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: []