enhanced_errors 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +19 -0
- data/README.md +367 -0
- data/benchmark/benchmark.rb +88 -0
- data/benchmark/stackprofile.rb +31 -0
- data/doc/images/enhance.png +0 -0
- data/doc/images/enhanced-error.png +0 -0
- data/doc/images/enhanced-spec.png +0 -0
- data/enhanced_errors.gemspec +24 -0
- data/examples/division_by_zero_example.rb +16 -0
- data/examples/example_spec.rb +27 -0
- data/lib/binding.rb +11 -0
- data/lib/colors.rb +27 -0
- data/lib/enhanced_errors.rb +583 -0
- data/lib/error_enhancements.rb +53 -0
- metadata +74 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1381112bfa16b295aa6916a6248fba5951ae7362984fd7741d8cef0c81d60769
|
4
|
+
data.tar.gz: e60c4a237637dae49192c670ed291d1b6d90d97ea868572b834df2ec17c0e048
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 387e0660918b06a32515f2ef928d9caacf4394640bce877438b4d25d278664999d831aef693bb128b24efbed490e66991b32a1663927b099fa50fb1d77d1e620
|
7
|
+
data.tar.gz: fbfdee8971f463d7697321b4873fd0bc4a48ca39acd59f172ca81e64b8e52bde4c5a83d3c56599e772bfdc61eae034b179fbd7bec42dee1442685fd23e00919d
|
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2019-2024 Eric Beland
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,367 @@
|
|
1
|
+
# EnhancedErrors
|
2
|
+
|
3
|
+
## Overview
|
4
|
+
|
5
|
+
**EnhancedErrors** is a pure Ruby gem that enhances exception messages by capturing and appending variables and their values from the scope where the error was raised.
|
6
|
+
|
7
|
+
**EnhancedErrors** leverages Ruby's built-in [TracePoint](https://ruby-doc.org/core-3.1.0/TracePoint.html) feature to provide detailed context for exceptions, making debugging easier without significant performance overhead.
|
8
|
+
|
9
|
+
When an exception is raised, EnhancedErrors captures the surrounding context. It works like this:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
|
13
|
+
require './lib/enhanced_errors'
|
14
|
+
require 'awesome_print' # Optional, for better output
|
15
|
+
|
16
|
+
EnhancedErrors.enhance!
|
17
|
+
|
18
|
+
def foo
|
19
|
+
begin
|
20
|
+
myvar = 0
|
21
|
+
@myinstance = 10
|
22
|
+
foo = @myinstance / myvar
|
23
|
+
rescue => e
|
24
|
+
puts e.message
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
foo
|
29
|
+
|
30
|
+
```
|
31
|
+
|
32
|
+
#### Enhanced Exception In Code:
|
33
|
+
|
34
|
+
<img src="./doc/images/enhanced-error.png" style="height: 171px; width: 440px;"></img>
|
35
|
+
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
describe 'attains enlightenment' do
|
39
|
+
let(:the_matrix) { 'code rains, dramatically' }
|
40
|
+
|
41
|
+
before(:each) do
|
42
|
+
@spoon = 'there is no spoon'
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'in the matrix' do
|
46
|
+
#activate memoized item
|
47
|
+
the_matrix
|
48
|
+
stop = 'bullets'
|
49
|
+
raise 'No!'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
#### Enhanced Exception In Specs:
|
55
|
+
|
56
|
+
<img src="./doc/images/enhanced-spec.png" style="height: 426px; width: 712px;"></img>
|
57
|
+
|
58
|
+
|
59
|
+
## Features
|
60
|
+
|
61
|
+
- **Pure Ruby**: No external dependencies or C extensions.
|
62
|
+
- **Standalone**: Does not rely on any external libraries.
|
63
|
+
- **Lightweight**: Minimal performance impact, as tracing is only active during exception raising.
|
64
|
+
- **Customizable Output**: Supports multiple output formats (`:json`, `:plaintext`, `:terminal`).
|
65
|
+
- **Flexible Hooks**: Redact or modifying captured data via the `on_capture` hook.
|
66
|
+
- **Environment-Based Defaults**: For Rails apps, automatically adjusts settings based on the environment (`development`, `test`, `production`, `ci`).
|
67
|
+
- **Pre-Populated Skip List**: Comes with predefined skip lists to exclude irrelevant variables from being captured.
|
68
|
+
- **Capture Levels**: Supports `info` and `debug` levels, where `debug` level ignores the skip lists for more comprehensive data capture.
|
69
|
+
- **Capture Types**: Captures variables from the first `raise` and the last `rescue` for an exception by default.
|
70
|
+
- **No dependencies**: EnhancedErrors does not ___require___ any dependencies--it uses [awesome_print](https://github.com/awesome-print/awesome_print) for nicer output if it is installed and available.
|
71
|
+
|
72
|
+
EnhancedErrors has a few big use-cases:
|
73
|
+
|
74
|
+
* Data-driven bugs. For example, if, while processing a 10 gig file, you get an error, you can't just re-run the code with a debugger.
|
75
|
+
You also can't just print out all the data, because it's too big. You want to know what the data was the cause of the error.
|
76
|
+
Ideally, without long instrument-re-run-fix loops.
|
77
|
+
|
78
|
+
If your logging didn't capture the data, normally, you'd be stuck.
|
79
|
+
|
80
|
+
* Debug a complex application erroring deep in the stack when you can't tell where the error originates
|
81
|
+
|
82
|
+
* Faster TDD - Often, you won't have to re-run to see an error--you can go straight to the fix.
|
83
|
+
|
84
|
+
* Faster CI -> Dev fixes. When a bug happens in CI, usually there's a step where you first reproduce it locally.
|
85
|
+
EnhancedErrors can help you skip that step.
|
86
|
+
|
87
|
+
* Faster debugging. In general, you can skip the add-instrumentation step and jump to the fix.
|
88
|
+
|
89
|
+
* Heisenbugs - bugs that disappear when you try to debug them. EnhancedErrors can help you capture the data that causes the bug before it disappears.
|
90
|
+
|
91
|
+
* "Unknown Unknowns" - you can't pre-emptively log variables from failure cases you never imagined.
|
92
|
+
|
93
|
+
* Cron jobs and daemons - when it fails for unknown reasons at 4am, check the log and fix--it probably has what you need.
|
94
|
+
|
95
|
+
## Installation
|
96
|
+
|
97
|
+
Add this line to your `Gemfile`:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
gem 'enhanced_errors'
|
101
|
+
```
|
102
|
+
|
103
|
+
Then execute:
|
104
|
+
|
105
|
+
```shell
|
106
|
+
$ bundle install
|
107
|
+
```
|
108
|
+
|
109
|
+
Or install it yourself with:
|
110
|
+
|
111
|
+
```shell
|
112
|
+
$ gem install enhanced_errors
|
113
|
+
```
|
114
|
+
|
115
|
+
## Basic Usage
|
116
|
+
|
117
|
+
To enable EnhancedErrors, call the `enhance!` method:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
EnhancedErrors.enhance!
|
121
|
+
```
|
122
|
+
|
123
|
+
This activates the TracePoint to start capturing exceptions and their surrounding context.
|
124
|
+
|
125
|
+
### Configuration Options
|
126
|
+
|
127
|
+
You can pass configuration options to `enhance!`:
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
EnhancedErrors.enhance!(enabled: true, max_length: 2000) do
|
131
|
+
# Additional configuration here
|
132
|
+
add_to_skip_list :@instance_variable_to_skip, :local_to_skip
|
133
|
+
end
|
134
|
+
|
135
|
+
```
|
136
|
+
- `enabled`: Enables or disables the enhancement (default: `true`).
|
137
|
+
- `max_length`: Sets the maximum length of the enhanced message (default: `2500`).
|
138
|
+
|
139
|
+
### Environment-Based Defaults
|
140
|
+
|
141
|
+
EnhancedErrors adjusts its default settings based on the environment:
|
142
|
+
|
143
|
+
- **Development/Test**:
|
144
|
+
- Default Output format: `:terminal`
|
145
|
+
- Terminal Color output: Enabled
|
146
|
+
- **Production**:
|
147
|
+
- Output format: `:json`
|
148
|
+
- Terminal Color output: Disabled
|
149
|
+
- **CI Environment**:
|
150
|
+
- Output format: `:plaintext`
|
151
|
+
- Color output: Disabled
|
152
|
+
|
153
|
+
The environment is determined by `ENV['RAILS_ENV']`, `ENV['RACK_ENV']`, or detected CI environment variables like:
|
154
|
+
- `CI=true`
|
155
|
+
|
156
|
+
### Output Formats
|
157
|
+
|
158
|
+
You can customize the output format:
|
159
|
+
|
160
|
+
- **`:json`**: Outputs the captured data in JSON format.
|
161
|
+
- **`:plaintext`**: Outputs plain text without color codes.
|
162
|
+
- **`:terminal`**: Outputs text with terminal color codes.
|
163
|
+
|
164
|
+
Example:
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
EnhancedErrors.format(captured_bindings, :json)
|
168
|
+
```
|
169
|
+
|
170
|
+
### Customizing Data Capture
|
171
|
+
|
172
|
+
#### Using `on_capture`
|
173
|
+
|
174
|
+
The `on_capture` hook allows you to modify or redact data as it is captured. For each captured binding
|
175
|
+
it yields out a hash with the structure below. Modify it as needed and return the modified hash.
|
176
|
+
|
177
|
+
```ruby
|
178
|
+
{
|
179
|
+
source: source_location,
|
180
|
+
object: Object source of error,
|
181
|
+
library: true or false,
|
182
|
+
method_and_args: method_and_args,
|
183
|
+
variables: {
|
184
|
+
locals: locals,
|
185
|
+
instances: instances,
|
186
|
+
lets: lets,
|
187
|
+
globals: globals
|
188
|
+
},
|
189
|
+
exception: exception.class.name,
|
190
|
+
capture_type: capture_type # 'raise' or 'rescue'
|
191
|
+
}
|
192
|
+
```
|
193
|
+
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
EnhancedErrors.on_capture do |binding_info|
|
197
|
+
# Redact sensitive data
|
198
|
+
if binding_info[:variables][:locals][:password]
|
199
|
+
binding_info[:variables][:locals][:password] = '[REDACTED]'
|
200
|
+
end
|
201
|
+
binding_info # Return the modified binding_info
|
202
|
+
end
|
203
|
+
```
|
204
|
+
|
205
|
+
|
206
|
+
#### Using `eligible_for_capture`
|
207
|
+
|
208
|
+
The `eligible_for_capture` hook yields an Exception, and allows you to decide whether you want to capture it or not.
|
209
|
+
By default, all exceptions are captured. When the block result is true, the error will be captured.
|
210
|
+
Error capture is relatively cheap, but ignoring errors you don't care about makes it almost totally free.
|
211
|
+
One use-case for eligible_for_capture is to run a string or regexp off a setting flag, which
|
212
|
+
lets you turn on and off what you capture without redeploying.
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
EnhancedErrors.eligible_for_capture do |exception|
|
216
|
+
exception.class.name == 'ExceptionIWantTOCatch'
|
217
|
+
end
|
218
|
+
|
219
|
+
|
220
|
+
```
|
221
|
+
|
222
|
+
#### Using `on_format`
|
223
|
+
|
224
|
+
`on_format` is the last stop for the message string that will be appended to `exception.message`.
|
225
|
+
|
226
|
+
Here it can be encrypted, rewritten, or otherwise modified.
|
227
|
+
|
228
|
+
|
229
|
+
```ruby
|
230
|
+
EnhancedErrors.on_format do |formatted_string|
|
231
|
+
"---whatever--- #{formatted_string} ---whatever---"
|
232
|
+
end
|
233
|
+
|
234
|
+
```
|
235
|
+
|
236
|
+
|
237
|
+
#### Applying a Variable Skip List
|
238
|
+
|
239
|
+
EnhancedErrors comes with predefined skip lists to exclude sensitive or irrelevant variables.
|
240
|
+
By default, the skip list is used to remove a lot of framework noise from Rails and RSpec.
|
241
|
+
You can add additional variables to the skip list as needed:
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
|
245
|
+
EnhancedErrors.enhance! do
|
246
|
+
add_to_skip_list :@variable_to_skip
|
247
|
+
end
|
248
|
+
|
249
|
+
```
|
250
|
+
|
251
|
+
The skip list is pre-populated with common variables to exclude and can be extended based on your application's requirements.
|
252
|
+
|
253
|
+
|
254
|
+
|
255
|
+
|
256
|
+
### Capture Levels
|
257
|
+
|
258
|
+
EnhancedErrors supports different capture levels to control the verbosity of the captured data:
|
259
|
+
|
260
|
+
- **Info Level**: Respects the skip list, excluding predefined sensitive or irrelevant variables. Global variables are ignored.
|
261
|
+
- **Debug Level**: Ignores the skip lists, capturing all variables including those typically excluded and global variables.
|
262
|
+
Global variables,
|
263
|
+
|
264
|
+
**Default Behavior**: By default, `info` level is used, which excludes variables in the skip list to protect sensitive information. In `debug` mode, the skip lists are ignored to provide more comprehensive data, which is useful during development but should be used cautiously to avoid exposing sensitive data.
|
265
|
+
The info mode is recommended.
|
266
|
+
|
267
|
+
|
268
|
+
|
269
|
+
### Capture Types
|
270
|
+
|
271
|
+
EnhancedErrors differentiates between two types of capture events:
|
272
|
+
|
273
|
+
- **`raise`**: Captures the context when an exception is initially raised.
|
274
|
+
- **`rescue`**: Captures the context when an exception is last rescued.
|
275
|
+
|
276
|
+
**Default Behavior**: By default, EnhancedErrors returns the first `raise` and the last `rescue` event for each exception.
|
277
|
+
This provides a clear picture of where and how the exception was handled.
|
278
|
+
|
279
|
+
|
280
|
+
### Example: Redacting Sensitive Information
|
281
|
+
|
282
|
+
```ruby
|
283
|
+
EnhancedErrors.on_capture do |binding_info|
|
284
|
+
sensitive_keys = [:password, :ssn, :health_info]
|
285
|
+
sensitive_keys.each do |key|
|
286
|
+
if binding_info[:variables][:locals][key]
|
287
|
+
binding_info[:variables][:locals][key] = '[REDACTED]'
|
288
|
+
end
|
289
|
+
end
|
290
|
+
binding_info
|
291
|
+
end
|
292
|
+
```
|
293
|
+
|
294
|
+
### Example: Encrypting Data in Custom Format
|
295
|
+
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
# config/initializers/encryption.rb
|
299
|
+
|
300
|
+
require 'active_support'
|
301
|
+
|
302
|
+
# Retrieve the encryption key from Rails credentials or environment variables
|
303
|
+
ENCRYPTION_KEY = Rails.application.credentials.encryption_key || ENV['ENCRYPTION_KEY']
|
304
|
+
|
305
|
+
# It's recommended to use a 256-bit key (32 bytes)
|
306
|
+
# If your key is in hex or another format, ensure it's properly decoded
|
307
|
+
key = ActiveSupport::KeyGenerator.new(ENCRYPTION_KEY).generate_key('enhanced_errors', 32)
|
308
|
+
ENCRYPTOR = ActiveSupport::MessageEncryptor.new(key)
|
309
|
+
```
|
310
|
+
|
311
|
+
```ruby
|
312
|
+
|
313
|
+
require_relative 'path_to/enhanced_errors' # Adjust the path accordingly
|
314
|
+
require 'active_support/message_encryptor'
|
315
|
+
|
316
|
+
# Ensure the encryptor is initialized
|
317
|
+
encryptor = ENCRYPTOR
|
318
|
+
|
319
|
+
EnhancedErrors.on_format = lambda do |formatted_string|
|
320
|
+
encrypted_data = encryptor.encrypt_and_sign(formatted_string)
|
321
|
+
encrypted_base64 = Base64.strict_encode64(encrypted_data)
|
322
|
+
"ENCRYPTED[#{encrypted_data}]"
|
323
|
+
end
|
324
|
+
```
|
325
|
+
|
326
|
+
|
327
|
+
## How It Works
|
328
|
+
|
329
|
+
EnhancedErrors uses Ruby's `TracePoint` to listen for `:raise` and `:rescue` events.
|
330
|
+
When an exception is raised or rescued, it captures:
|
331
|
+
|
332
|
+
- **Local Variables**: Variables local to the scope where the exception occurred.
|
333
|
+
- **Instance Variables**: Instance variables of the object.
|
334
|
+
- **Method and Arguments**: The method name and its arguments.
|
335
|
+
- **Let Variables**: RSpec let variables, if applicable. Only memoized (evaluated) let variables are captured.
|
336
|
+
- **Global Variables**: Global variables, in debug mode.
|
337
|
+
|
338
|
+
The captured data includes a `capture_type` field indicating whether the data was captured during a `raise` or `rescue` event. By default, EnhancedErrors returns the first `raise` and the last `rescue` event for each exception, providing a clear trace of the exception lifecycle.
|
339
|
+
|
340
|
+
The captured data is then appended to the exception's message, providing rich context for debugging.
|
341
|
+
|
342
|
+
|
343
|
+
## Awesome Print
|
344
|
+
|
345
|
+
EnhancedErrors uses the [awesome_print](https://github.com/awesome-print/awesome_print)
|
346
|
+
gem to format the captured data, if it is installed and available.
|
347
|
+
If not, errors should still work, but the output may be less readable. AwesomePrint is not
|
348
|
+
required directly by EnhancedErrors, so you will need to add it to your Gemfile if you want to use it.
|
349
|
+
|
350
|
+
```ruby
|
351
|
+
gem 'awesome_print'
|
352
|
+
```
|
353
|
+
|
354
|
+
|
355
|
+
## Performance Considerations
|
356
|
+
|
357
|
+
- **Minimal Overhead**: Since TracePoint is only activated during exception raising and rescuing, the performance impact is negligible during normal operation.
|
358
|
+
- **Production Safe**: The gem is designed to be safe for production use, giving you valuable insights without compromising performance.
|
359
|
+
|
360
|
+
## Contributing
|
361
|
+
|
362
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/your_username/enhanced_errors](https://github.com/your_username/enhanced_errors).
|
363
|
+
|
364
|
+
## License
|
365
|
+
|
366
|
+
The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
367
|
+
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'benchmark'
|
2
|
+
require_relative '../lib/enhanced_errors' # Adjust the path if necessary
|
3
|
+
|
4
|
+
# Define the number of iterations
|
5
|
+
ITERATIONS = 10_000
|
6
|
+
EXCEPTIONS_PER_BATCH = 100
|
7
|
+
|
8
|
+
class Boo < StandardError; end
|
9
|
+
|
10
|
+
def calculate_cost(time_in_seconds)
|
11
|
+
milliseconds = time_in_seconds * 1000
|
12
|
+
(milliseconds / (ITERATIONS / EXCEPTIONS_PER_BATCH)).round(2)
|
13
|
+
end
|
14
|
+
|
15
|
+
def with_enhanced_errors
|
16
|
+
EnhancedErrors.enhance!(debug: false)
|
17
|
+
ITERATIONS.times do
|
18
|
+
begin
|
19
|
+
foo = 'bar'
|
20
|
+
@boo = 'baz'
|
21
|
+
raise 'Test exception with EnhancedErrors'
|
22
|
+
rescue => e
|
23
|
+
e.message
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def without_enhanced_errors
|
29
|
+
ITERATIONS.times do
|
30
|
+
begin
|
31
|
+
foo = 'bar'
|
32
|
+
@boo = 'baz'
|
33
|
+
raise 'Test exception without EnhancedErrors'
|
34
|
+
rescue => e
|
35
|
+
e.message
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def when_capture_only_regexp_matched
|
41
|
+
EnhancedErrors.enhance!(debug: false) do
|
42
|
+
eligible_for_capture { |exception| !!/Boo/.match(exception.class.to_s) }
|
43
|
+
end
|
44
|
+
|
45
|
+
ITERATIONS.times do
|
46
|
+
begin
|
47
|
+
foo = 'bar'
|
48
|
+
@boo = 'baz'
|
49
|
+
raise Boo.new('Test exception with EnhancedErrors')
|
50
|
+
rescue => e
|
51
|
+
e.message
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def when_capture_only_regexp_did_not_match
|
57
|
+
EnhancedErrors.enhance!(debug: false) do
|
58
|
+
eligible_for_capture { |exception| !!/Baz/.match(exception.class.to_s) }
|
59
|
+
end
|
60
|
+
|
61
|
+
ITERATIONS.times do
|
62
|
+
begin
|
63
|
+
foo = 'bar'
|
64
|
+
@boo = 'baz'
|
65
|
+
raise Boo.new('Test exception with EnhancedErrors')
|
66
|
+
rescue => e
|
67
|
+
e.message
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
puts "Cost Exploration\n"
|
73
|
+
Benchmark.bm(35) do |x|
|
74
|
+
without_time = x.report('10k Without EnhancedErrors:') { without_enhanced_errors }
|
75
|
+
with_time = x.report('10k With EnhancedErrors:') { with_enhanced_errors }
|
76
|
+
|
77
|
+
puts "\nCost per 100 exceptions (Without EnhancedErrors): #{calculate_cost(without_time.real)} ms"
|
78
|
+
puts "Cost per 100 exceptions (With EnhancedErrors): #{calculate_cost(with_time.real)} ms"
|
79
|
+
end
|
80
|
+
|
81
|
+
puts "\nProof that if you only match the classes you care about, the cost is nominal\n"
|
82
|
+
Benchmark.bm(35) do |x|
|
83
|
+
matched_time = x.report('10k With capture_only_regexp match:') { when_capture_only_regexp_matched }
|
84
|
+
not_matched_time = x.report('10k Without capture_only_regexp match:') { when_capture_only_regexp_did_not_match }
|
85
|
+
|
86
|
+
puts "\nCost per 100 exceptions (Capture Only Match): #{calculate_cost(matched_time.real)} ms"
|
87
|
+
puts "Cost per 100 exceptions (No Match): #{calculate_cost(not_matched_time.real)} ms"
|
88
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'stackprof'
|
2
|
+
require_relative '../lib/enhanced_errors' # Adjust the path if necessary
|
3
|
+
|
4
|
+
# gem install stackprof
|
5
|
+
|
6
|
+
# adjust path as needed
|
7
|
+
# ruby ./lib/core_ext/enhanced_errors/benchmark/stackprofile.rb
|
8
|
+
# dumps to current folder. read the stackprof dump:
|
9
|
+
# stackprof stackprof.dump
|
10
|
+
|
11
|
+
# Define the number of iterations
|
12
|
+
ITERATIONS = 10_000
|
13
|
+
|
14
|
+
def run_with_enhanced_errors
|
15
|
+
EnhancedErrors.enhance!(debug: false)
|
16
|
+
ITERATIONS.times do
|
17
|
+
begin
|
18
|
+
raise 'Test exception with EnhancedErrors'
|
19
|
+
rescue => _e
|
20
|
+
# Exception handled with EnhancedErrors.
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def stackprofile
|
26
|
+
StackProf.run(mode: :wall, out: 'stackprof.dump') do
|
27
|
+
run_with_enhanced_errors
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
stackprofile
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,24 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = "enhanced_errors"
|
3
|
+
spec.version = "0.1.0"
|
4
|
+
spec.authors = ["Eric Beland"]
|
5
|
+
|
6
|
+
spec.summary = "Automatically enhance your errors with messages containing variable values from the moment they were raised."
|
7
|
+
spec.description = "With no extra dependencies, and using only Ruby's built-in TracePoint, EnhancedErrors will automatically enhance your errors with messages containing variable values from the moment they were raised."
|
8
|
+
spec.homepage = "https://github.com/ericbeland/enhanced_errors."
|
9
|
+
spec.required_ruby_version = ">= 3.0.0"
|
10
|
+
|
11
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
12
|
+
spec.metadata["source_code_uri"] = "https://github.com/ericbeland/enhanced_errors"
|
13
|
+
|
14
|
+
# Specify which files should be added to the gem when it is released.
|
15
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
16
|
+
spec.files = Dir.chdir(__dir__) do
|
17
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
(File.expand_path(f) == __FILE__) ||
|
19
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
20
|
+
end
|
21
|
+
end
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
spec.add_development_dependency "rspec", "> 3.4.0"
|
24
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require './lib/enhanced_errors'
|
2
|
+
require 'awesome_print' # Optional, for better output
|
3
|
+
|
4
|
+
EnhancedErrors.enhance!
|
5
|
+
|
6
|
+
def foo
|
7
|
+
begin
|
8
|
+
myvar = 0
|
9
|
+
@myinstance = 10
|
10
|
+
foo = @myinstance / myvar
|
11
|
+
rescue => e
|
12
|
+
puts e.message
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
foo
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'rspec'
|
2
|
+
require_relative '../lib/enhanced_errors'
|
3
|
+
|
4
|
+
# INSTRUCTIONS: Install rspec
|
5
|
+
# gem install rspec
|
6
|
+
# rspec examples/example_spec.rb
|
7
|
+
|
8
|
+
RSpec.describe 'Neo' do
|
9
|
+
before(:each) do
|
10
|
+
EnhancedErrors.enhance!
|
11
|
+
end
|
12
|
+
|
13
|
+
describe 'attains enlightenment' do
|
14
|
+
let(:the_matrix) { 'code rains, dramatically' }
|
15
|
+
|
16
|
+
before(:each) do
|
17
|
+
@spoon = 'there is no spoon'
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'in the matrix' do
|
21
|
+
#activate memoized item
|
22
|
+
the_matrix
|
23
|
+
stop = 'bullets'
|
24
|
+
raise 'No!'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/binding.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module Debugging
|
2
|
+
def let_vars_hash
|
3
|
+
memoized_values = self.receiver.instance_variable_get(:@__memoized)&.instance_variable_get(:@memoized)
|
4
|
+
memoized_values && !memoized_values.empty? ? memoized_values.dup : {}
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class Binding
|
9
|
+
include Debugging
|
10
|
+
end
|
11
|
+
|
data/lib/colors.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
class Colors
|
2
|
+
COLORS = { red: 31, green: 32, yellow: 33, blue: 34, purple: 35, cyan: 36, white: 0 }
|
3
|
+
|
4
|
+
class << self
|
5
|
+
def enabled?
|
6
|
+
@enabled
|
7
|
+
end
|
8
|
+
|
9
|
+
def enabled=(value)
|
10
|
+
@enabled = value
|
11
|
+
end
|
12
|
+
|
13
|
+
def color(num, string)
|
14
|
+
@enabled ? "#{code(num)}#{string}#{code(0)}" : string
|
15
|
+
end
|
16
|
+
|
17
|
+
def code(num)
|
18
|
+
"\e[#{num}m"
|
19
|
+
end
|
20
|
+
|
21
|
+
COLORS.each do |color, code|
|
22
|
+
define_method(color) do |str|
|
23
|
+
color(COLORS[color], str)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,583 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
require_relative 'colors'
|
5
|
+
require_relative 'error_enhancements'
|
6
|
+
require_relative 'binding'
|
7
|
+
|
8
|
+
# The EnhancedErrors class provides mechanisms to enhance exception handling by capturing
|
9
|
+
# additional context such as binding information, variables, and method arguments when exceptions are raised.
|
10
|
+
# It offers customization options for formatting and filtering captured data.
|
11
|
+
class EnhancedErrors
|
12
|
+
class << self
|
13
|
+
# @!attribute [rw] enabled
|
14
|
+
# @return [Boolean] Indicates whether EnhancedErrors is enabled.
|
15
|
+
attr_accessor :enabled
|
16
|
+
|
17
|
+
# @!attribute [rw] trace
|
18
|
+
# @return [TracePoint, nil] The TracePoint object used for tracing exceptions.
|
19
|
+
attr_accessor :trace
|
20
|
+
|
21
|
+
# @!attribute [rw] config_block
|
22
|
+
# @return [Proc, nil] The configuration block provided during enhancement.
|
23
|
+
attr_accessor :config_block
|
24
|
+
|
25
|
+
# @!attribute [rw] max_length
|
26
|
+
# @return [Integer] The maximum length of the formatted exception message.
|
27
|
+
attr_accessor :max_length
|
28
|
+
|
29
|
+
# @!attribute [rw] on_capture_hook
|
30
|
+
# @return [Proc, nil] Hook to modify binding information upon capture.
|
31
|
+
attr_accessor :on_capture_hook
|
32
|
+
|
33
|
+
# @!attribute [rw] capture_let_variables
|
34
|
+
# @return [Boolean] Determines whether RSpec `let` variables are captured.
|
35
|
+
attr_accessor :capture_let_variables
|
36
|
+
|
37
|
+
# @!attribute [rw] eligible_for_capture
|
38
|
+
# @return [Proc, nil] A proc that determines if an exception is eligible for capture.
|
39
|
+
attr_accessor :eligible_for_capture
|
40
|
+
|
41
|
+
# @!attribute [rw] skip_list
|
42
|
+
# @return [Set<Symbol>] A set of variable names to exclude from binding information.
|
43
|
+
attr_accessor :skip_list
|
44
|
+
|
45
|
+
# @!constant GEMS_REGEX
|
46
|
+
# @return [Regexp] Regular expression to identify gem paths.
|
47
|
+
GEMS_REGEX = %r{[\/\\]gems[\/\\]}
|
48
|
+
|
49
|
+
# @!constant DEFAULT_MAX_LENGTH
|
50
|
+
# @return [Integer] The default maximum length for formatted exception messages.
|
51
|
+
DEFAULT_MAX_LENGTH = 2500
|
52
|
+
|
53
|
+
# @!constant RSPEC_SKIP_LIST
|
54
|
+
# @return [Set<Symbol>] A set of RSpec-specific instance variables to skip.
|
55
|
+
RSPEC_SKIP_LIST = Set.new([
|
56
|
+
:@fixture_cache,
|
57
|
+
:@fixture_cache_key,
|
58
|
+
:@fixture_connection_pools,
|
59
|
+
:@connection_subscriber,
|
60
|
+
:@saved_pool_configs,
|
61
|
+
:@loaded_fixtures,
|
62
|
+
:@matcher_definitions,
|
63
|
+
])
|
64
|
+
|
65
|
+
# @!constant RAILS_SKIP_LIST
|
66
|
+
# @return [Set<Symbol>] A set of Rails-specific instance variables to skip.
|
67
|
+
RAILS_SKIP_LIST = Set.new([
|
68
|
+
:@new_record,
|
69
|
+
:@attributes,
|
70
|
+
:@association_cache,
|
71
|
+
:@readonly,
|
72
|
+
:@previously_new_record,
|
73
|
+
:@destroyed,
|
74
|
+
:@marked_for_destruction,
|
75
|
+
:@destroyed_by_association,
|
76
|
+
:@primary_key,
|
77
|
+
:@strict_loading,
|
78
|
+
:@strict_loading_mode,
|
79
|
+
:@mutations_before_last_save,
|
80
|
+
:@mutations_from_database
|
81
|
+
])
|
82
|
+
|
83
|
+
# Gets or sets the maximum length for the formatted exception message.
|
84
|
+
#
|
85
|
+
# @param value [Integer, nil] The desired maximum length. If `nil`, returns the current value.
|
86
|
+
# @return [Integer] The maximum length for the formatted message.
|
87
|
+
def max_length(value = nil)
|
88
|
+
if value.nil?
|
89
|
+
@max_length ||= DEFAULT_MAX_LENGTH
|
90
|
+
else
|
91
|
+
@max_length = value
|
92
|
+
end
|
93
|
+
@max_length
|
94
|
+
end
|
95
|
+
|
96
|
+
# Gets or sets whether to capture RSpec `let` variables.
|
97
|
+
#
|
98
|
+
# @param value [Boolean, nil] The desired state. If `nil`, returns the current value.
|
99
|
+
# @return [Boolean] Whether RSpec `let` variables are being captured.
|
100
|
+
def capture_let_variables(value = nil)
|
101
|
+
if value.nil?
|
102
|
+
@capture_let_variables = @capture_let_variables.nil? ? true : @capture_let_variables
|
103
|
+
else
|
104
|
+
@capture_let_variables = value
|
105
|
+
end
|
106
|
+
@capture_let_variables
|
107
|
+
end
|
108
|
+
|
109
|
+
# Retrieves the current skip list, initializing it with default values if not already set.
|
110
|
+
#
|
111
|
+
# @return [Set<Symbol>] The current skip list.
|
112
|
+
def skip_list
|
113
|
+
@skip_list ||= default_skip_list
|
114
|
+
end
|
115
|
+
|
116
|
+
# Initializes the default skip list by merging Rails and RSpec specific variables.
|
117
|
+
#
|
118
|
+
# @return [Set<Symbol>] The default skip list.
|
119
|
+
def default_skip_list
|
120
|
+
Set.new(RAILS_SKIP_LIST).merge(RSPEC_SKIP_LIST)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Adds variables to the skip list to exclude them from binding information.
|
124
|
+
#
|
125
|
+
# @param vars [Symbol] The variable names to add to the skip list.
|
126
|
+
# @return [Set<Symbol>] The updated skip list.
|
127
|
+
def add_to_skip_list(*vars)
|
128
|
+
skip_list.merge(vars)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Enhances the exception handling by setting up tracing and configuration options.
|
132
|
+
#
|
133
|
+
# @param enabled [Boolean] Whether to enable EnhancedErrors.
|
134
|
+
# @param debug [Boolean] Whether to enable debug mode.
|
135
|
+
# @param options [Hash] Additional configuration options.
|
136
|
+
# @yield [void] A block for additional configuration.
|
137
|
+
# @return [void]
|
138
|
+
def enhance!(enabled: true, debug: false, **options, &block)
|
139
|
+
@output_format = nil
|
140
|
+
@eligible_for_capture = nil
|
141
|
+
@original_global_variables = nil
|
142
|
+
if enabled == false
|
143
|
+
@original_global_variables = nil
|
144
|
+
@enabled = false
|
145
|
+
@trace.disable if @trace
|
146
|
+
else
|
147
|
+
@enabled = true
|
148
|
+
@debug = debug
|
149
|
+
@original_global_variables = global_variables
|
150
|
+
|
151
|
+
options.each do |key, value|
|
152
|
+
setter_method = "#{key}="
|
153
|
+
if respond_to?(setter_method)
|
154
|
+
send(setter_method, value)
|
155
|
+
elsif respond_to?(key)
|
156
|
+
send(key, value)
|
157
|
+
else
|
158
|
+
# Ignore unknown options or handle as needed
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
@config_block = block_given? ? block : nil
|
163
|
+
instance_eval(&@config_block) if @config_block
|
164
|
+
|
165
|
+
start_tracing
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Sets or retrieves the eligibility criteria for capturing exceptions.
|
170
|
+
#
|
171
|
+
# @yieldparam exception [Exception] The exception to evaluate.
|
172
|
+
# @return [Proc] The current eligibility proc.
|
173
|
+
def eligible_for_capture(&block)
|
174
|
+
if block_given?
|
175
|
+
@eligible_for_capture = block
|
176
|
+
else
|
177
|
+
@eligible_for_capture ||= method(:default_eligible_for_capture)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Sets or retrieves the hook to modify binding information upon capture.
|
182
|
+
#
|
183
|
+
# @yieldparam binding_info [Hash] The binding information captured.
|
184
|
+
# @return [Proc] The current on_capture hook.
|
185
|
+
def on_capture(&block)
|
186
|
+
if block_given?
|
187
|
+
@on_capture_hook = block
|
188
|
+
else
|
189
|
+
@on_capture_hook ||= method(:default_on_capture)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Sets the on_capture hook.
|
194
|
+
#
|
195
|
+
# @param value [Proc] The proc to set as the on_capture hook.
|
196
|
+
# @return [Proc] The newly set on_capture hook.
|
197
|
+
def on_capture=(value)
|
198
|
+
self.on_capture_hook = value
|
199
|
+
end
|
200
|
+
|
201
|
+
# Sets or retrieves the hook to modify formatted exception messages.
|
202
|
+
#
|
203
|
+
# @yieldparam formatted_string [String] The formatted exception message.
|
204
|
+
# @return [Proc] The current on_format hook.
|
205
|
+
def on_format(&block)
|
206
|
+
if block_given?
|
207
|
+
@on_format_hook = block
|
208
|
+
else
|
209
|
+
@on_format_hook ||= method(:default_on_format)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Sets the on_format hook.
|
214
|
+
#
|
215
|
+
# @param value [Proc] The proc to set as the on_format hook.
|
216
|
+
# @return [Proc] The newly set on_format hook.
|
217
|
+
def on_format=(value)
|
218
|
+
@on_format_hook = value
|
219
|
+
end
|
220
|
+
|
221
|
+
# Formats the captured binding information into a string based on the specified format.
|
222
|
+
#
|
223
|
+
# @param captured_bindings [Array<Hash>] The array of captured binding information.
|
224
|
+
# @param output_format [Symbol] The format to use for output (:json, :plaintext, :terminal).
|
225
|
+
# @return [String] The formatted exception message.
|
226
|
+
def format(captured_bindings = [], output_format = get_default_format_for_environment)
|
227
|
+
result = binding_infos_array_to_string(captured_bindings, output_format)
|
228
|
+
if @on_format_hook
|
229
|
+
result = @on_format_hook.call(result)
|
230
|
+
else
|
231
|
+
result = default_on_format(result)
|
232
|
+
end
|
233
|
+
result
|
234
|
+
end
|
235
|
+
|
236
|
+
# Converts an array of binding information hashes into a formatted string.
|
237
|
+
#
|
238
|
+
# @param captured_bindings [Array<Hash>] The array of binding information.
|
239
|
+
# @param format [Symbol] The format to use (:json, :plaintext, :terminal).
|
240
|
+
# @return [String] The formatted string representation of the binding information.
|
241
|
+
def binding_infos_array_to_string(captured_bindings, format = :terminal)
|
242
|
+
case format
|
243
|
+
when :json
|
244
|
+
Colors.enabled = false
|
245
|
+
JSON.pretty_generate(captured_bindings)
|
246
|
+
when :plaintext
|
247
|
+
Colors.enabled = false
|
248
|
+
captured_bindings.map { |binding_info| binding_info_string(binding_info) }.join("\n")
|
249
|
+
when :terminal
|
250
|
+
Colors.enabled = true
|
251
|
+
captured_bindings.map { |binding_info| binding_info_string(binding_info) }.join("\n")
|
252
|
+
else
|
253
|
+
Colors.enabled = false
|
254
|
+
captured_bindings.map { |binding_info| binding_info_string(binding_info) }.join("\n")
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Determines the default output format based on the current environment.
|
259
|
+
#
|
260
|
+
# @return [Symbol] The default format (:json, :plaintext, :terminal).
|
261
|
+
def get_default_format_for_environment
|
262
|
+
return @output_format unless @output_format.nil?
|
263
|
+
env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
264
|
+
@output_format = case env
|
265
|
+
when 'development', 'test'
|
266
|
+
if running_in_ci?
|
267
|
+
:plaintext
|
268
|
+
else
|
269
|
+
:terminal
|
270
|
+
end
|
271
|
+
when 'production'
|
272
|
+
:json
|
273
|
+
else
|
274
|
+
:terminal
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# Checks if the code is running in a Continuous Integration (CI) environment.
|
279
|
+
#
|
280
|
+
# @return [Boolean] `true` if running in CI, otherwise `false`.
|
281
|
+
def running_in_ci?
|
282
|
+
return @running_in_ci if defined?(@running_in_ci)
|
283
|
+
ci_env_vars = {
|
284
|
+
'CI' => ENV['CI'],
|
285
|
+
'JENKINS' => ENV['JENKINS'],
|
286
|
+
'GITHUB_ACTIONS' => ENV['GITHUB_ACTIONS'],
|
287
|
+
'CIRCLECI' => ENV['CIRCLECI'],
|
288
|
+
'TRAVIS' => ENV['TRAVIS'],
|
289
|
+
'APPVEYOR' => ENV['APPVEYOR'],
|
290
|
+
'GITLAB_CI' => ENV['GITLAB_CI']
|
291
|
+
}
|
292
|
+
@running_in_ci = ci_env_vars.any? { |_, value| value.to_s.downcase == 'true' }
|
293
|
+
end
|
294
|
+
|
295
|
+
# Applies the skip list to the captured binding information, excluding specified variables.
|
296
|
+
#
|
297
|
+
# @param binding_info [Hash] The binding information to filter.
|
298
|
+
# @return [Hash] The filtered binding information.
|
299
|
+
def apply_skip_list(binding_info)
|
300
|
+
unless @debug
|
301
|
+
variables = binding_info[:variables]
|
302
|
+
variables[:instances]&.reject! { |var, _| skip_list.include?(var) || var.to_s.start_with?('@__') }
|
303
|
+
variables[:locals]&.reject! { |var, _| skip_list.include?(var) }
|
304
|
+
variables[:globals]&.reject! { |var, _| skip_list.include?(var) }
|
305
|
+
end
|
306
|
+
binding_info
|
307
|
+
end
|
308
|
+
|
309
|
+
# Validates the format of the captured binding information.
|
310
|
+
#
|
311
|
+
# @param binding_info [Hash] The binding information to validate.
|
312
|
+
# @return [Hash, nil] The validated binding information or `nil` if invalid.
|
313
|
+
def validate_binding_format(binding_info)
|
314
|
+
unless binding_info.keys.include?(:capture_type) && binding_info[:variables].is_a?(Hash)
|
315
|
+
puts "Invalid binding_info format."
|
316
|
+
return nil
|
317
|
+
end
|
318
|
+
binding_info
|
319
|
+
end
|
320
|
+
|
321
|
+
# Formats a single binding information hash into a string with colorization.
|
322
|
+
#
|
323
|
+
# @param binding_info [Hash] The binding information to format.
|
324
|
+
# @return [String] The formatted string.
|
325
|
+
def binding_info_string(binding_info)
|
326
|
+
result = "\n#{Colors.green("#{binding_info[:capture_type].capitalize}: ")}#{Colors.blue(binding_info[:source])}"
|
327
|
+
|
328
|
+
result += method_and_args_desc(binding_info[:method_and_args])
|
329
|
+
|
330
|
+
variables = binding_info[:variables] || {}
|
331
|
+
|
332
|
+
if variables[:locals] && !variables[:locals].empty?
|
333
|
+
result += "\n#{Colors.green('Locals:')}\n#{variable_description(variables[:locals])}"
|
334
|
+
end
|
335
|
+
|
336
|
+
instance_vars_to_display = variables[:instances] || {}
|
337
|
+
|
338
|
+
|
339
|
+
if instance_vars_to_display && !instance_vars_to_display.empty?
|
340
|
+
result += "\n#{Colors.green('Instances:')}\n#{variable_description(instance_vars_to_display)}"
|
341
|
+
end
|
342
|
+
|
343
|
+
if variables[:lets] && !variables[:lets].empty?
|
344
|
+
result += "\n#{Colors.green('Let Variables:')}\n#{variable_description(variables[:lets])}"
|
345
|
+
end
|
346
|
+
|
347
|
+
if variables[:globals] && !variables[:globals].empty?
|
348
|
+
result += "\n#{Colors.green('Globals:')}\n#{variable_description(variables[:globals])}"
|
349
|
+
end
|
350
|
+
|
351
|
+
if result.length > max_length
|
352
|
+
result = result[0...max_length] + "... (truncated)"
|
353
|
+
end
|
354
|
+
result + "\n\n"
|
355
|
+
end
|
356
|
+
|
357
|
+
private
|
358
|
+
|
359
|
+
# Starts the TracePoint for capturing exceptions based on configured events.
|
360
|
+
#
|
361
|
+
# @return [void]
|
362
|
+
def start_tracing
|
363
|
+
return if @trace && @trace.enabled?
|
364
|
+
|
365
|
+
events = [:raise]
|
366
|
+
events << :rescue if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.3.0')
|
367
|
+
|
368
|
+
@trace = TracePoint.new(*events) do |tp|
|
369
|
+
exception = tp.raised_exception
|
370
|
+
capture_me = EnhancedErrors.eligible_for_capture.call(exception)
|
371
|
+
|
372
|
+
next unless capture_me
|
373
|
+
|
374
|
+
next if Thread.current[:enhanced_errors_processing]
|
375
|
+
Thread.current[:enhanced_errors_processing] = true
|
376
|
+
|
377
|
+
exception = tp.raised_exception
|
378
|
+
binding_context = tp.binding
|
379
|
+
|
380
|
+
unless exception.instance_variable_defined?(:@binding_infos)
|
381
|
+
exception.instance_variable_set(:@binding_infos, [])
|
382
|
+
exception.extend(ErrorEnhancements)
|
383
|
+
end
|
384
|
+
|
385
|
+
method_name = tp.method_id
|
386
|
+
method_and_args = {
|
387
|
+
object_name: determine_object_name(tp, method_name),
|
388
|
+
args: extract_arguments(tp, method_name)
|
389
|
+
}
|
390
|
+
|
391
|
+
locals = binding_context.local_variables.map { |var|
|
392
|
+
[var, binding_context.local_variable_get(var)]
|
393
|
+
}.to_h
|
394
|
+
|
395
|
+
instance_vars = binding_context.receiver.instance_variables
|
396
|
+
|
397
|
+
instances = instance_vars.map { |var|
|
398
|
+
[var, (binding_context.receiver.instance_variable_get(var) rescue "#<Error getting instance variable: #{$!.message}>")]
|
399
|
+
}.to_h
|
400
|
+
|
401
|
+
# Extract 'let' variables from :@__memoized (RSpec specific)
|
402
|
+
lets = {}
|
403
|
+
if capture_let_variables && instance_vars.include?(:@__memoized)
|
404
|
+
outer_memoized = binding_context.receiver.instance_variable_get(:@__memoized)
|
405
|
+
memoized = outer_memoized.instance_variable_get(:@memoized) if outer_memoized.respond_to?(:instance_variable_get)
|
406
|
+
if memoized.is_a?(Hash)
|
407
|
+
lets = memoized&.transform_keys(&:to_sym)
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
globals = {}
|
412
|
+
# Capture global variables
|
413
|
+
if @debug
|
414
|
+
globals = (global_variables - @original_global_variables).map { |var|
|
415
|
+
[var, get_global_variable_value(var)]
|
416
|
+
}.to_h
|
417
|
+
puts "Global Variables: #{globals.inspect}"
|
418
|
+
end
|
419
|
+
|
420
|
+
capture_type = tp.event.to_s # 'raise' or 'rescue'
|
421
|
+
location = "#{tp.path}:#{tp.lineno}"
|
422
|
+
|
423
|
+
binding_info = {
|
424
|
+
source: location,
|
425
|
+
object: tp.self,
|
426
|
+
library: !!GEMS_REGEX.match?(location),
|
427
|
+
method_and_args: method_and_args,
|
428
|
+
test_name: test_name,
|
429
|
+
variables: {
|
430
|
+
locals: locals,
|
431
|
+
instances: instances,
|
432
|
+
lets: lets,
|
433
|
+
globals: globals
|
434
|
+
},
|
435
|
+
exception: exception.class.name,
|
436
|
+
capture_type: capture_type
|
437
|
+
}
|
438
|
+
|
439
|
+
if on_capture_hook
|
440
|
+
binding_info = on_capture_hook.call(binding_info)
|
441
|
+
else
|
442
|
+
binding_info = default_on_capture(binding_info)
|
443
|
+
end
|
444
|
+
|
445
|
+
binding_info = validate_binding_format(binding_info)
|
446
|
+
|
447
|
+
if binding_info
|
448
|
+
exception.instance_variable_get(:@binding_infos) << binding_info
|
449
|
+
else
|
450
|
+
puts "Invalid binding_info returned from on_capture, skipping."
|
451
|
+
end
|
452
|
+
ensure
|
453
|
+
Thread.current[:enhanced_errors_processing] = false
|
454
|
+
end
|
455
|
+
|
456
|
+
@trace.enable
|
457
|
+
end
|
458
|
+
|
459
|
+
def test_name
|
460
|
+
return RSpec&.current_example&.full_description if defined?(RSpec)
|
461
|
+
nil
|
462
|
+
end
|
463
|
+
|
464
|
+
# Extracts method arguments from the TracePoint binding.
|
465
|
+
#
|
466
|
+
# @param tp [TracePoint] The current TracePoint.
|
467
|
+
# @param method_name [Symbol] The name of the method.
|
468
|
+
# @return [String] A string representation of the method arguments.
|
469
|
+
def extract_arguments(tp, method_name)
|
470
|
+
return '' unless method_name
|
471
|
+
begin
|
472
|
+
bind = tp.binding
|
473
|
+
unbound_method = tp.defined_class.instance_method(method_name)
|
474
|
+
method_obj = unbound_method.bind(tp.self)
|
475
|
+
parameters = method_obj.parameters
|
476
|
+
locals = bind.local_variables
|
477
|
+
|
478
|
+
return parameters.map do |(type, name)|
|
479
|
+
value = locals.include?(name) ? bind.local_variable_get(name) : nil
|
480
|
+
"#{name}=#{value.inspect}"
|
481
|
+
rescue => e
|
482
|
+
"#{name}=#<Error getting argument: #{e.message}>"
|
483
|
+
end.join(", ")
|
484
|
+
end
|
485
|
+
|
486
|
+
rescue => e
|
487
|
+
"#<Error getting arguments: #{e.message}>"
|
488
|
+
end
|
489
|
+
|
490
|
+
|
491
|
+
# Determines the object name based on the TracePoint and method name.
|
492
|
+
#
|
493
|
+
# @param tp [TracePoint] The current TracePoint.
|
494
|
+
# @param method_name [Symbol] The name of the method.
|
495
|
+
# @return [String] The formatted object name.
|
496
|
+
def determine_object_name(tp, method_name)
|
497
|
+
if tp.self.is_a?(Class) && tp.self.singleton_class == tp.defined_class
|
498
|
+
"#{tp.self}.#{method_name}"
|
499
|
+
else
|
500
|
+
"#{tp.self.class.name}##{method_name}"
|
501
|
+
end
|
502
|
+
rescue => e
|
503
|
+
"#<Error inspecting value: #{e.message}>"
|
504
|
+
end
|
505
|
+
|
506
|
+
# Retrieves the value of a global variable by its name.
|
507
|
+
#
|
508
|
+
# @param var [Symbol] The name of the global variable.
|
509
|
+
# @return [Object, String] The value of the global variable or an error message.
|
510
|
+
def get_global_variable_value(var)
|
511
|
+
begin
|
512
|
+
var.is_a?(Symbol) ? eval("#{var}") : nil
|
513
|
+
rescue => e
|
514
|
+
"#<Error getting value: #{e.message}>"
|
515
|
+
end
|
516
|
+
end
|
517
|
+
|
518
|
+
# Generates a description for method and arguments.
|
519
|
+
#
|
520
|
+
# @param method_info [Hash] Information about the method and its arguments.
|
521
|
+
# @return [String] The formatted description.
|
522
|
+
def method_and_args_desc(method_info)
|
523
|
+
return '' unless method_info[:object_name] != '' || method_info[:args]&.length.to_i > 0
|
524
|
+
arg_str = method_info[:args]
|
525
|
+
arg_str = "(#{arg_str})" if arg_str != ""
|
526
|
+
str = method_info[:object_name] + arg_str
|
527
|
+
"\n#{Colors.green('Method: ')}#{Colors.blue(str)}\n"
|
528
|
+
end
|
529
|
+
|
530
|
+
# Generates a formatted description for a set of variables.
|
531
|
+
#
|
532
|
+
# @param vars_hash [Hash] A hash of variable names and their values.
|
533
|
+
# @return [String] The formatted variables description.
|
534
|
+
def variable_description(vars_hash)
|
535
|
+
vars_hash.map do |name, value|
|
536
|
+
" #{Colors.purple(name)}: #{format_variable(value)}\n"
|
537
|
+
end.join
|
538
|
+
end
|
539
|
+
|
540
|
+
# Formats a variable for display, using `awesome_print` if available and enabled.
|
541
|
+
#
|
542
|
+
# @param variable [Object] The variable to format.
|
543
|
+
# @return [String] The formatted variable.
|
544
|
+
def format_variable(variable)
|
545
|
+
(awesome_print_available? && Colors.enabled?) ? variable.ai : variable.inspect
|
546
|
+
end
|
547
|
+
|
548
|
+
# Checks if the `AwesomePrint` gem is available.
|
549
|
+
#
|
550
|
+
# @return [Boolean] `true` if `AwesomePrint` is available, otherwise `false`.
|
551
|
+
def awesome_print_available?
|
552
|
+
return @awesome_print_available unless @awesome_print_available.nil?
|
553
|
+
@awesome_print_available = defined?(AwesomePrint)
|
554
|
+
end
|
555
|
+
|
556
|
+
# Default implementation for the on_format hook.
|
557
|
+
#
|
558
|
+
# @param string [String] The formatted exception message.
|
559
|
+
# @return [String] The unmodified exception message.
|
560
|
+
def default_on_format(string)
|
561
|
+
string
|
562
|
+
end
|
563
|
+
|
564
|
+
# Default implementation for the on_capture hook, applying the skip list.
|
565
|
+
#
|
566
|
+
# @param binding_info [Hash] The captured binding information.
|
567
|
+
# @return [Hash] The filtered binding information.
|
568
|
+
def default_on_capture(binding_info)
|
569
|
+
# Use this to clean up the captured bindings
|
570
|
+
EnhancedErrors.apply_skip_list(binding_info)
|
571
|
+
end
|
572
|
+
|
573
|
+
# Default eligibility check for capturing exceptions.
|
574
|
+
#
|
575
|
+
# @param exception [Exception] The exception to evaluate.
|
576
|
+
# @return [Boolean] `true` if the exception should be captured, otherwise `false`.
|
577
|
+
def default_eligible_for_capture(exception)
|
578
|
+
true
|
579
|
+
end
|
580
|
+
|
581
|
+
@enabled = false
|
582
|
+
end
|
583
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module ErrorEnhancements
|
2
|
+
def message
|
3
|
+
original_message = super()
|
4
|
+
"#{original_message}#{variables_message}"
|
5
|
+
rescue => e
|
6
|
+
puts "Error in message method: #{e.message}"
|
7
|
+
original_message
|
8
|
+
end
|
9
|
+
|
10
|
+
def variables_message
|
11
|
+
@variables_message ||= begin
|
12
|
+
bindings_of_interest = []
|
13
|
+
if defined?(@binding_infos) && @binding_infos && !@binding_infos.empty?
|
14
|
+
bindings_of_interest = select_binding_infos(@binding_infos)
|
15
|
+
end
|
16
|
+
EnhancedErrors.format(bindings_of_interest)
|
17
|
+
rescue => e
|
18
|
+
puts "Error in variables_message: #{e.message}"
|
19
|
+
""
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def select_binding_infos(binding_infos)
|
26
|
+
# Preference:
|
27
|
+
# Grab the first raise binding that isn't a library (gem) binding.
|
28
|
+
# If there are only library bindings, grab the first one.
|
29
|
+
# Grab the last rescue binding if we have one
|
30
|
+
|
31
|
+
bindings_of_interest = []
|
32
|
+
binding_infos.each do |info|
|
33
|
+
if info[:capture_type] == 'raise' && !info[:library]
|
34
|
+
bindings_of_interest << info
|
35
|
+
break
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
if bindings_of_interest.empty?
|
40
|
+
bindings_of_interest << binding_infos.first if binding_infos.first
|
41
|
+
end
|
42
|
+
|
43
|
+
# find the last rescue binding if there is one
|
44
|
+
binding_infos.reverse.each do |info|
|
45
|
+
if info[:capture_type] == 'rescue'
|
46
|
+
bindings_of_interest << info
|
47
|
+
break
|
48
|
+
end
|
49
|
+
end
|
50
|
+
bindings_of_interest
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
metadata
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: enhanced_errors
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Eric Beland
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-10-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.4.0
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.4.0
|
27
|
+
description: With no extra dependencies, and using only Ruby's built-in TracePoint,
|
28
|
+
EnhancedErrors will automatically enhance your errors with messages containing variable
|
29
|
+
values from the moment they were raised.
|
30
|
+
email:
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- LICENSE
|
36
|
+
- README.md
|
37
|
+
- benchmark/benchmark.rb
|
38
|
+
- benchmark/stackprofile.rb
|
39
|
+
- doc/images/enhance.png
|
40
|
+
- doc/images/enhanced-error.png
|
41
|
+
- doc/images/enhanced-spec.png
|
42
|
+
- enhanced_errors.gemspec
|
43
|
+
- examples/division_by_zero_example.rb
|
44
|
+
- examples/example_spec.rb
|
45
|
+
- lib/binding.rb
|
46
|
+
- lib/colors.rb
|
47
|
+
- lib/enhanced_errors.rb
|
48
|
+
- lib/error_enhancements.rb
|
49
|
+
homepage: https://github.com/ericbeland/enhanced_errors.
|
50
|
+
licenses: []
|
51
|
+
metadata:
|
52
|
+
homepage_uri: https://github.com/ericbeland/enhanced_errors.
|
53
|
+
source_code_uri: https://github.com/ericbeland/enhanced_errors
|
54
|
+
post_install_message:
|
55
|
+
rdoc_options: []
|
56
|
+
require_paths:
|
57
|
+
- lib
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: 3.0.0
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '0'
|
68
|
+
requirements: []
|
69
|
+
rubygems_version: 3.3.26
|
70
|
+
signing_key:
|
71
|
+
specification_version: 4
|
72
|
+
summary: Automatically enhance your errors with messages containing variable values
|
73
|
+
from the moment they were raised.
|
74
|
+
test_files: []
|