lumberjack_capture_device 1.2.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9147e66264c0eeebb01f7c04df1b6e18ab64bc4db3337e968d6cd1d2201fef7f
4
- data.tar.gz: f01bf2e332024e9ef26ef703b374956e5f17c6bb4f351cb4518b1591b62dd992
3
+ metadata.gz: 3de3ac7d3bf941c8f6283c70f8bf55163eb36f91bea583836f296e7c5fa2cc3a
4
+ data.tar.gz: df6a39ef765975ff2f029d99e46aaef435c7675be707ea8effa08fc6a7369308
5
5
  SHA512:
6
- metadata.gz: 5e5ae9f94313a18f793b299998c9760aaa8d7bf5d8f462f48b4519f83c8844893ff894e5b570e31b15d55dc1ad9d25ee723529dc72e51db83d5060a4bcf1597c
7
- data.tar.gz: 06a11ff5e4c5f3afed7cda12fa2f5f9bf94369f6c5b382d7118622136b7f1507d67c1e68ca472c6c38d1bf38cd3aa6db66f4859a145f78b9b69a8f9f4024a634
6
+ metadata.gz: 8c08cdc46f06da187f942b449a0a15551aefd2c55891cbb93d17ab3b0c8f281e169bddc340bb43a9a6fdfb11aeb6cc43a4a639f3417ab3819bea002d91515e3c
7
+ data.tar.gz: dfa277bb83a2710a9df87f893a35eaf58c266c8eb1d73e00be6a4f8fb1cf6233f7f552a86329b84e85459de8b87963143197d46e07d47f4136a605f793f0136b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.1.0
8
+
9
+ ### Changed
10
+
11
+ - `include?` now symbolizes filter keys and raises an `ArgumentError` on unrecognized filter keys. Previously filters with string keys or misspelled keys were silently ignored, causing the method to match any captured entry.
12
+ - `write_to_underlying_device` accepts an optional `attributes` keyword argument to add attributes to each entry as it is written.
13
+ - The `include_log_entry` matcher failure message now shows a diff of the closest matching entry against the expectation instead of just printing the entry. Severity, message, progname, and each attribute are shown with the expected value on a `-` line and the logged value on a `+` line so it is obvious which ones prevented the match. The comparison is done with `Lumberjack::LogEntryMatcher#diff` using the device's entry formatter, so the diff always agrees with the match. The diff is also available directly from `Lumberjack::CaptureDevice::IncludeLogEntryMatcher#entry_diff`.
14
+
15
+ ### Fixed
16
+
17
+ - `extract` now matches filters with the device `entry_formatter` like `include?`, `match`, and `closest_match` do. Previously filter values were only compared to the formatted values captured on the entries, so unformatted values in the filters did not match.
18
+ - `capture_logger_around_example` now correctly adds the rspec metadata attributes to entries written to the underlying device when an example fails. Previously the attributes were silently dropped and building them raised a `NoMethodError` since `RSpec::Core::Example` does not have a `source_location` method; the example location is now recorded in the `rspec.location` attribute.
19
+ - The `max_entries` option is no longer ignored when initializing a `Lumberjack::CaptureDevice`.
20
+ - Fixed missing line break after "Closest match found:" in RSpec matcher failure messages.
21
+ - The `include_log_entry` matcher description no longer omits the expected attributes when a matcher (i.e. RSpec's `hash_including`) is passed as the `attributes` option instead of a hash. Matcher objects are now rendered with their description rather than by inspecting them.
22
+ - `each` and `length` now read from a thread safe copy of the entries buffer.
23
+
24
+ ### Removed
25
+
26
+ - Removed the deprecated `:level` and `:tags` options from the matching methods (`include?`, `match`, `closest_match`, and `extract`) and from the `include_log_entry` RSpec matcher. Use `:severity` and `:attributes` instead.
27
+ - Removed the `match` and `closest_match` methods. They only existed to translate the deprecated options and are now inherited unchanged from `Lumberjack::Device::Test`.
28
+ - Removed unused internal `Lumberjack::CaptureDevice::EntryScore` class. It was never loaded and was replaced by the scoring logic in the lumberjack gem.
29
+
30
+ ## 2.0.0
31
+
32
+ ### Added
33
+
34
+ - Captured log entries are now passed through to the underlying logging device when the capture block is finished. This can be disabled by passing `write_to_original: false` to the `capture` method. You can then write the captured entries to the underlying device manually by calling `write_to_underlying_device`.
35
+ - Added `capture_logger_around_example` RSpec helper method to simplify capturing log entries in an `around` hook.
36
+
37
+ ### Changed
38
+
39
+ - Depends on `lumberjack` 2.0 or greater.
40
+
41
+ ### Deprecated
42
+
43
+ - The `:level` and `:tags` options on the matching methods (`include?`, `match`, `closest_match`, and `extract`) have been deprecated in favor of `:severity` and `:attributes`.
44
+
7
45
  ## 1.2.2
8
46
 
9
47
  ### Changed
data/README.md CHANGED
@@ -26,7 +26,7 @@ You can use the `include?` method on the log device to determine if specific log
26
26
  ```ruby
27
27
  Lumberjack::CaptureDevice.capture(Rails.logger) do |logs|
28
28
  do_something
29
- expect(logs).to include(level: :info, message: "Something happened")
29
+ expect(logs).to include(severity: :info, message: "Something happened")
30
30
  end
31
31
  ```
32
32
 
@@ -34,30 +34,30 @@ You can also write that same test as:
34
34
 
35
35
  ```ruby
36
36
  logs = Lumberjack::CaptureDevice.capture(Rails.logger) { do_something }
37
- expect(logs).to include(level: :info, message: "Something happened")
37
+ expect(logs).to include(severity: :info, message: "Something happened")
38
38
  ```
39
39
 
40
40
  For MiniTest, you could assert:
41
41
 
42
42
  ```ruby
43
43
  logs = Lumberjack::CaptureDevice.capture(Rails.logger) { do_something }
44
- assert(logs.include?(level: :info, message: "Something happened"))
44
+ assert(logs.include?(severity: :info, message: "Something happened"))
45
45
  ```
46
46
 
47
- You can filter the logs on level, message, and tags.
47
+ You can filter the logs on severity, message, progname, and attributes.
48
48
 
49
- - The level option can take either a label (i.e. `:warn`) or a constant (i.e. `Logger::WARN`).
49
+ - The severity option can take either a label (i.e. `:warn`) or a constant (i.e. `Logger::WARN`).
50
50
  - The message filter can be either an exact string or a regular expression, or any matcher supported by your test library.
51
- - The tags argument can match tags with a Hash mapping tag names to the matcher values. If tags are nested, you can use dot notation on tag names to reference nested tags.
51
+ - The attributes argument can match attributes with a Hash mapping attribute names to the matcher values. If attributes are nested, you can use dot notation on attribute names to reference nested attributes.
52
52
 
53
53
  ```ruby
54
- expect(logs).to include(level: :info, message: /something/i)
55
- expect(logs).to include(level: Logger::INFO, tags: {foo: "bar"})
56
- expect(logs).to include(tags: {foo: anything, count: {one: 1}})
57
- expect(logs).to include(tags: {foo: anything, "count.one" => 1})
54
+ expect(logs).to include(severity: :info, message: /something/i)
55
+ expect(logs).to include(severity: Logger::INFO, attributes: {foo: "bar"})
56
+ expect(logs).to include(attributes: {foo: anything, count: {one: 1}})
57
+ expect(logs).to include(attributes: {foo: anything, "count.one" => 1})
58
58
  ```
59
59
 
60
- You can also use the `Lumberjack::CaptureDevice#extract` method with the same arguments as used by `include?` to extract all log entries that match the filters. You can get all of the log entries with `Lumberjack::CaptureDevice#buffer`.
60
+ You can also use the `Lumberjack::CaptureDevice#extract` method with the same arguments as used by `include?` to extract all log entries that match the filters. You can get all of the log entries with `Lumberjack::CaptureDevice#entries`.
61
61
 
62
62
  ### RSpec Support
63
63
 
@@ -72,12 +72,52 @@ This will give you a `capture_logger` method and `include_log_entry` matcher. Th
72
72
  ```ruby
73
73
  describe MyClass do
74
74
  it "logs information" do
75
- logs = capture_logger { MyClass.do_something }
75
+ logs = capture_logger(Rails.logger) { MyClass.do_something }
76
76
  expect(logs).to include_log_entry(message: "Something")
77
77
  end
78
78
  end
79
79
  ```
80
80
 
81
+ When the matcher fails, the failure message includes a diff between the expectation and the closest matching entry so you can see exactly which fields and attributes are off. Lines prefixed with `-` show what was expected and lines prefixed with `+` show what was actually logged.
82
+
83
+ ```
84
+ expected logs to include entry:
85
+ severity: WARN
86
+ message: User logged out
87
+ attributes: request_id: "abc"
88
+ user.role: "admin"
89
+
90
+ Closest match found (- expected, + actual):
91
+ - severity: WARN
92
+ + severity: INFO
93
+ message: "User logged out"
94
+ - attributes.user.role: "admin"
95
+ + attributes.user.role: "guest"
96
+ attributes.duration: 1.5
97
+ - attributes.request_id: "abc"
98
+ + attributes.request_id: (not set)
99
+ ```
100
+
101
+ You can also set up log capturing around each example with the `capture_logger_around_example` method.
102
+
103
+ ```ruby
104
+ describe MyClass do
105
+ around do |example|
106
+ capture_logger_around_example(Rails.logger, example)
107
+ end
108
+
109
+ it "logs information" do
110
+ MyClass.do_something
111
+ expect(Rails.logger).to include_log_entry(message: "Something")
112
+ end
113
+ end
114
+ ```
115
+
116
+ > [!TIP]
117
+ > Add `capture_logger_around_example` as a global `around` hook in your RSpec configuration to automatically capture log entries for every example.
118
+ >
119
+ > This will also suppress all log output during tests unless an example fails which can reduce noise in the logs from tests that don't fail. This is especially useful in CI environments where you can save the logs as an artifact for failed test runs.
120
+
81
121
  ## Installation
82
122
 
83
123
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.2
1
+ 2.1.0
@@ -2,92 +2,287 @@
2
2
 
3
3
  # RSpec matcher for checking captured logs for specific entries.
4
4
  class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
5
+ # Displayed as the actual value for an expected attribute that the log entry does not have.
6
+ MISSING_VALUE = "(not set)"
7
+
8
+ private_constant :MISSING_VALUE
9
+
10
+ # Initialize the matcher with expected log entry attributes.
11
+ #
12
+ # @param expected_hash [Hash] Expected log entry attributes to match against.
5
13
  def initialize(expected_hash)
14
+ # The keys are symbolized so the hash can be passed to any Lumberjack::Device::Test device.
6
15
  @expected_hash = expected_hash.transform_keys(&:to_sym)
7
- @captured_logger = nil
16
+ @logger = nil
8
17
  end
9
18
 
19
+ # Check if the logger contains a log entry matching the expected attributes.
20
+ #
21
+ # @param actual [Lumberjack::Logger, Lumberjack::ForkedLogger] The logger to check. The logger must be using
22
+ # a Lumberjack::Device::Test device.
23
+ # @return [Boolean] True if a matching log entry is found.
10
24
  def matches?(actual)
11
- @captured_logger = actual
12
- return false unless valid_captured_logger?
25
+ @logger = actual
26
+ return false unless valid_logger?
13
27
 
14
- @captured_logger.include?(@expected_hash)
28
+ device.include?(@expected_hash)
15
29
  end
16
30
 
31
+ # Generate a failure message when the matcher fails.
32
+ #
33
+ # @return [String] A formatted failure message.
17
34
  def failure_message
18
- if valid_captured_logger?
19
- formatted_failure_message(@captured_logger, @expected_hash)
35
+ if valid_logger?
36
+ formatted_failure_message(@expected_hash)
20
37
  else
21
- wrong_object_type_message(@captured_logger)
38
+ wrong_object_type_message(@logger)
22
39
  end
23
40
  end
24
41
 
42
+ # Generate a failure message when the negated matcher fails.
43
+ #
44
+ # @return [String] A formatted failure message for negated expectations.
25
45
  def failure_message_when_negated
26
- if valid_captured_logger?
27
- formatted_negated_failure_message(@captured_logger, @expected_hash)
46
+ if valid_logger?
47
+ formatted_negated_failure_message(@expected_hash)
28
48
  else
29
- wrong_object_type_message(@captured_logger)
49
+ wrong_object_type_message(@logger)
30
50
  end
31
51
  end
32
52
 
53
+ # Provide a description of what this matcher checks.
54
+ #
55
+ # @return [String] A human-readable description of the matcher.
33
56
  def description
34
57
  "have logged entry with #{expectation_description(@expected_hash)}"
35
58
  end
36
59
 
60
+ # Generate a diff between a log entry and the expected log entry values. The severity,
61
+ # message, progname, and attributes of the entry are listed. Values that don't match the
62
+ # expectation are shown on a pair of lines with the expected value prefixed with "-" and
63
+ # the value from the log entry prefixed with "+". This is intended to make it easy to spot
64
+ # exactly which fields kept an entry from matching.
65
+ #
66
+ # The comparison is done by Lumberjack::LogEntryMatcher#diff so the diff always agrees
67
+ # with the result of the match.
68
+ #
69
+ # @param entry [Lumberjack::LogEntry] The log entry to compare to the expectation.
70
+ # @param indent [Integer] The number of spaces to indent each line.
71
+ # @return [String] A formatted diff of the entry and the expectation.
72
+ def entry_diff(entry, indent: 2)
73
+ indent_str = " " * indent
74
+
75
+ entry_differences(entry).collect do |name, expected, actual, matched|
76
+ if matched
77
+ "#{indent_str} #{name}: #{actual}"
78
+ else
79
+ "#{indent_str}- #{name}: #{expected}#{Lumberjack::LINE_SEPARATOR}#{indent_str}+ #{name}: #{actual}"
80
+ end
81
+ end.join(Lumberjack::LINE_SEPARATOR)
82
+ end
83
+
37
84
  private
38
85
 
39
- def valid_captured_logger?
40
- @captured_logger.is_a?(Lumberjack::CaptureDevice)
86
+ # Check if the logger is using a valid Lumberjack::Device::Test device.
87
+ #
88
+ # @return [Boolean] True if the logger is a Lumberjack::Device::Test.
89
+ def valid_logger?
90
+ return true if @logger.is_a?(Lumberjack::Device::Test)
91
+ return false unless @logger.respond_to?(:device)
92
+
93
+ @logger.device.is_a?(Lumberjack::Device::Test)
41
94
  end
42
95
 
43
- def wrong_object_type_message(captured_logger)
44
- "Expected a Lumberjack::CaptureDevice object, but received a #{captured_logger.class}."
96
+ # Generate an error message for wrong object type.
97
+ #
98
+ # @param logger [Object] The object that was passed instead of a Lumberjack::Device::Test.
99
+ # @return [String] An error message describing the type mismatch.
100
+ def wrong_object_type_message(logger)
101
+ unless logger.respond_to?(:device)
102
+ return "Expected a Lumberjack::Logger object, but received a #{logger.class}."
103
+ end
104
+
105
+ device = logger.device
106
+ "Expected logger device to be a Lumberjack::Device::Test, but it is a #{device.class}."
45
107
  end
46
108
 
47
- def formatted_failure_message(captured_logger, expected_hash)
109
+ # Generate a detailed failure message showing expected vs actual logs.
110
+ #
111
+ # @param expected_hash [Hash] The expected log entry attributes.
112
+ # @return [String] A formatted failure message with context.
113
+ def formatted_failure_message(expected_hash)
48
114
  message = +"expected logs to include entry:\n" \
49
- "#{Lumberjack::CaptureDevice.formatted_expectation(expected_hash, indent: 2)}\n\n" \
50
- "Captured #{captured_logger.length} log #{(captured_logger.length == 1) ? "entry" : "entries"}"
115
+ "#{Lumberjack::Device::Test.formatted_expectation(expected_hash, indent: 2)}"
51
116
 
52
- if captured_logger.length > 0
53
- message << "\n----------------------\n"
54
- captured_logger.each do |entry|
55
- message << "#{Lumberjack::CaptureDevice.formatted_entry(entry)}\n"
56
- end
117
+ closest_match = device.closest_match(**expected_hash)
118
+ if closest_match
119
+ message << "\n\nClosest match found (- expected, + actual):\n" \
120
+ "#{entry_diff(closest_match, indent: 2)}"
57
121
  end
58
122
 
59
- closest_match = captured_logger.closest_match(**expected_hash)
60
- if closest_match
61
- message = "#{message}\n\nClosest match found:" \
62
- "#{Lumberjack::CaptureDevice.formatted_expectation(closest_match, indent: 2)}"
123
+ entries = device.entries
124
+ message << "\n\nLogged #{entries.length} #{(entries.length == 1) ? "entry" : "entries"}"
125
+ if entries.length > 0
126
+ message << "\n----------------------\n"
127
+ template = Lumberjack::LocalLogTemplate.new
128
+ entries.each do |entry|
129
+ message << "#{template.call(entry)}\n"
130
+ end
63
131
  end
64
132
 
65
133
  message
66
134
  end
67
135
 
68
- def formatted_negated_failure_message(captured_logger, expected_hash)
136
+ # Generate a failure message for negated expectations.
137
+ #
138
+ # @param expected_hash [Hash] The expected log entry attributes that should not be present.
139
+ # @return [String] A formatted failure message for negated expectations.
140
+ def formatted_negated_failure_message(expected_hash)
69
141
  message = "expected logs not to include entry:\n" \
70
- "#{Lumberjack::CaptureDevice.formatted_expectation(expected_hash, indent: 2)}"
142
+ "#{Lumberjack::Device::Test.formatted_expectation(expected_hash, indent: 2)}"
71
143
 
72
- match = captured_logger.match(**expected_hash)
144
+ match = device.match(**expected_hash)
73
145
  if match
74
146
  message = "#{message}\n\nFound entry:\n" \
75
- "#{Lumberjack::CaptureDevice.formatted_expectation(match, indent: 2)}"
147
+ "#{Lumberjack::Device::Test.formatted_expectation(match, indent: 2)}"
76
148
  end
77
149
 
78
150
  message
79
151
  end
80
152
 
153
+ # Create a human-readable description of the expected log entry attributes.
154
+ #
155
+ # @param expected_hash [Hash] The expected log entry attributes.
156
+ # @return [String] A formatted description of the expected attributes.
81
157
  def expectation_description(expected_hash)
82
158
  info = []
83
- info << "level: #{expected_hash[:level].inspect}" unless expected_hash[:level].nil?
84
- info << "message: #{expected_hash[:message].inspect}" unless expected_hash[:message].nil?
85
- info << "progname: #{expected_hash[:progname].inspect}" unless expected_hash[:progname].nil?
86
- if expected_hash[:tags].is_a?(Hash) && !expected_hash[:tags].empty?
87
- tags = Lumberjack::Utils.flatten_tags(expected_hash[:tags])
88
- tags_info = tags.collect { |name, value| "#{name}=#{value.inspect}" }.join(", ")
89
- info << "tags: #{tags_info}"
159
+ info << "severity: #{formatted_value(expected_hash[:severity])}" unless expected_hash[:severity].nil?
160
+ info << "message: #{formatted_value(expected_hash[:message])}" unless expected_hash[:message].nil?
161
+ info << "progname: #{formatted_value(expected_hash[:progname])}" unless expected_hash[:progname].nil?
162
+
163
+ expected_attributes = expected_hash[:attributes]
164
+ if expected_attributes.is_a?(Hash)
165
+ unless expected_attributes.empty?
166
+ attributes = Lumberjack::Utils.flatten_attributes(expected_attributes)
167
+ attributes_info = attributes.collect { |name, value| "#{name}=#{formatted_value(value)}" }.join(", ")
168
+ info << "attributes: #{attributes_info}"
169
+ end
170
+ elsif expected_attributes
171
+ # Matchers like RSpec's hash_including are matched against the attributes hash as a whole.
172
+ info << "attributes: #{formatted_value(expected_attributes)}"
90
173
  end
174
+
91
175
  info.join(", ")
92
176
  end
177
+
178
+ # Build the matcher used to compare log entries to the expectation. The entry formatter
179
+ # from the device is used so filter values are matched the same way they are by the
180
+ # device itself.
181
+ #
182
+ # @return [Lumberjack::LogEntryMatcher] The matcher for the expected values.
183
+ def log_entry_matcher
184
+ Lumberjack::LogEntryMatcher.new(
185
+ message: @expected_hash[:message],
186
+ severity: @expected_hash[:severity],
187
+ progname: @expected_hash[:progname],
188
+ attributes: @expected_hash[:attributes],
189
+ formatter: device&.entry_formatter
190
+ )
191
+ end
192
+
193
+ # Compare a log entry to the expected values one field at a time. The comparison itself is
194
+ # done by Lumberjack::LogEntryMatcher#diff which reports only the mismatched fields; the
195
+ # rest of the entry is filled in from the entry so it can be seen in full.
196
+ #
197
+ # @param entry [Lumberjack::LogEntry] The log entry to compare to the expectation.
198
+ # @return [Array<Array>] An array of [name, expected value, actual value, matched] tuples
199
+ # in the order they should be displayed. The values are already formatted for display.
200
+ def entry_differences(entry)
201
+ diff = log_entry_matcher.diff(entry)
202
+ differences = []
203
+
204
+ mismatch = diff["severity"]
205
+ differences << if mismatch
206
+ # Severities are already reported as labels by the diff.
207
+ ["severity", mismatch[:expected].to_s, mismatch[:actual].to_s, false]
208
+ else
209
+ ["severity", nil, entry.severity_label, true]
210
+ end
211
+
212
+ differences << field_difference("message", diff["message"], entry.message)
213
+
214
+ unless diff["progname"].nil? && entry.progname.nil?
215
+ differences << field_difference("progname", diff["progname"], entry.progname)
216
+ end
217
+
218
+ differences.concat(attribute_differences(entry, diff["attributes"]))
219
+ end
220
+
221
+ # Build the display tuple for a single log entry field.
222
+ #
223
+ # @param name [String] The name of the field.
224
+ # @param mismatch [Hash, nil] The expected and actual values reported by the diff, or nil
225
+ # if the field matched the expectation.
226
+ # @param value [Object] The value from the log entry, used when the field matched.
227
+ # @return [Array] A [name, expected value, actual value, matched] tuple.
228
+ def field_difference(name, mismatch, value)
229
+ if mismatch
230
+ [name, formatted_value(mismatch[:expected]), formatted_value(mismatch[:actual]), false]
231
+ else
232
+ [name, nil, formatted_value(value), true]
233
+ end
234
+ end
235
+
236
+ # Build the display tuples for the attributes of a log entry. Mismatches are reported by
237
+ # the diff per attribute using dot notation names. The remaining attributes on the entry
238
+ # are listed as well, followed by any expected attributes the entry does not have.
239
+ #
240
+ # @param entry [Lumberjack::LogEntry] The log entry being compared to the expectation.
241
+ # @param mismatches [Hash, nil] The attribute mismatches reported by the diff.
242
+ # @return [Array<Array>] An array of [name, expected value, actual value, matched] tuples.
243
+ def attribute_differences(entry, mismatches)
244
+ mismatches ||= {}
245
+
246
+ if mismatches.include?(:expected)
247
+ # Matchers like RSpec's hash_including are matched against the attributes hash as a whole.
248
+ return [field_difference("attributes", mismatches, nil)]
249
+ end
250
+
251
+ entry_attributes = Lumberjack::Utils.flatten_attributes(entry.attributes || {})
252
+
253
+ differences = entry_attributes.collect do |name, value|
254
+ field_difference("attributes.#{name}", mismatches[name], value)
255
+ end
256
+
257
+ (mismatches.keys - entry_attributes.keys).each do |name|
258
+ mismatch = mismatches[name]
259
+ actual = mismatch[:actual].nil? ? MISSING_VALUE : formatted_value(mismatch[:actual])
260
+ differences << ["attributes.#{name}", formatted_value(mismatch[:expected]), actual, false]
261
+ end
262
+
263
+ differences
264
+ end
265
+
266
+ # The Lumberjack::Device::Test the entries are being matched against.
267
+ #
268
+ # @return [Lumberjack::Device::Test, nil] The device, or nil if the logger is not valid.
269
+ def device
270
+ return nil unless valid_logger?
271
+
272
+ @logger.is_a?(Lumberjack::Device::Test) ? @logger : @logger.device
273
+ end
274
+
275
+ # Format a value for display in a description. Matcher objects (i.e. RSpec matchers)
276
+ # that implement a +description+ method are displayed using that description since
277
+ # inspecting them is not very informative.
278
+ #
279
+ # @param value [Object] The value to format.
280
+ # @return [String] The formatted value.
281
+ def formatted_value(value)
282
+ if value.respond_to?(:description) && !value.is_a?(Module)
283
+ value.description.to_s
284
+ else
285
+ value.inspect
286
+ end
287
+ end
93
288
  end
@@ -3,13 +3,68 @@
3
3
  require_relative "../capture_device"
4
4
  require "rspec"
5
5
 
6
+ # RSpec helper methods for working with CaptureDevice.
6
7
  module Lumberjack::CaptureDevice::RSpec
8
+ # Create a matcher for checking if a log entry is included in the captured logs.
9
+ # This matcher provides better error messages than using the include? method directly.
10
+ #
11
+ # @param expected_hash [Hash] The expected log entry attributes to match.
12
+ # @option expected_hash [String, Symbol, Integer] :severity The expected log severity.
13
+ # @option expected_hash [String, Regexp] :message The expected message content.
14
+ # @option expected_hash [Hash] :attributes Expected log entry attributes.
15
+ # @option expected_hash [String] :progname Expected program name.
16
+ # @return [Lumberjack::CaptureDevice::IncludeLogEntryMatcher] A matcher for the expected log entry.
17
+ # @example
18
+ # expect(logs).to include_log_entry(severity: :info, message: "User logged in")
19
+ # @example
20
+ # expect(logs).to include_log_entry(message: /error/i, attributes: {user_id: 123})
7
21
  def include_log_entry(expected_hash)
8
22
  Lumberjack::CaptureDevice::IncludeLogEntryMatcher.new(expected_hash)
9
23
  end
10
24
 
11
- def capture_logger(logger, &block)
12
- Lumberjack::CaptureDevice.capture(logger, &block)
25
+ # Capture log entries from a logger within a block. This method temporarily
26
+ # replaces the logger's device with a CaptureDevice and sets the log level to debug.
27
+ # The logger's formatters remain active, so captured entries contain the same
28
+ # formatted values that would have been logged.
29
+ #
30
+ # @param logger [Lumberjack::Logger] The logger to capture entries from.
31
+ # @yield [device] The block to execute while capturing log entries.
32
+ # @yieldparam device [Lumberjack::CaptureDevice] The device that will capture the log entries.
33
+ # @return [Lumberjack::CaptureDevice] The device that captured the log entries.
34
+ # @example
35
+ # logs = capture_logger(Rails.logger) do
36
+ # Rails.logger.info("Test message")
37
+ # end
38
+ # expect(logs).to include_log_entry(severity: :info, message: "Test message")
39
+ def capture_logger(logger, write_to_original: true, &block)
40
+ Lumberjack::CaptureDevice.capture(logger, write_to_original: write_to_original, &block)
41
+ end
42
+
43
+ # RSpec around hook to automatically capture logs for each example. The captured logs are only
44
+ # written to the original logger if the example fails. This helps keep the logs more usable for
45
+ # debugging test failures since it removes all the noise from passing tests.
46
+ #
47
+ # This is designed for CI environments where you can save the logs as artifacts of the test run.
48
+ #
49
+ # @param logger [Lumberjack::Logger] The logger to capture entries for.
50
+ # @param example [RSpec::Core::Example] The current RSpec example.
51
+ #
52
+ # @example Capture logs for a Rails application
53
+ # # In your spec_helper.rb or rails_helper.rb
54
+ # RSpec.configure do |config|
55
+ # config.around do |example|
56
+ # capture_logger_around_example(Rails.logger, example)
57
+ # end
58
+ # end
59
+ def capture_logger_around_example(logger, example)
60
+ capture_logger(logger, write_to_original: false) do |captured_device|
61
+ example.run
62
+
63
+ if example.exception
64
+ rspec_attributes = {rspec: {location: example.location, description: example.metadata[:description]}}
65
+ captured_device.write_to_underlying_device(attributes: rspec_attributes)
66
+ end
67
+ end
13
68
  end
14
69
  end
15
70
 
@@ -5,304 +5,213 @@ require "lumberjack"
5
5
  module Lumberjack
6
6
  # Lumberjack device for capturing log entries into memory to allow them to be inspected
7
7
  # for testing purposes.
8
- class CaptureDevice < Lumberjack::Device
9
- VERSION = File.read(File.join(__dir__, "..", "..", "VERSION"))
8
+ class CaptureDevice < Lumberjack::Device::Test
9
+ VERSION = ::File.read(::File.join(__dir__, "..", "..", "VERSION")).strip.freeze
10
+
11
+ require_relative "capture_device/include_log_entry_matcher"
10
12
 
11
13
  include Enumerable
12
14
 
13
- attr_reader :buffer
14
15
  class << self
15
16
  # Capture the entries written by the logger within a block. Within the block all log
16
17
  # entries will be written to a CaptureDevice rather than to the normal output for
17
- # the logger. In addition, all formatters will be removed and the log level will be set
18
- # to debug. The device being written to be both yielded to the block as well as returned
19
- # by the method call.
18
+ # the logger. In addition, the log level will be set to debug. The logger's formatters
19
+ # remain active, so captured entries contain the same formatted values that would have
20
+ # been logged. The device being written to be both yielded to the block as well as
21
+ # returned by the method call.
22
+ #
23
+ # This method is not thread safe. It swaps the device and log level on the logger
24
+ # itself, so concurrent calls on the same logger will interfere with each other and
25
+ # entries logged by other threads during the block will be captured as well. The log
26
+ # level is set on the current context, so threads spawned within the block may not
27
+ # log at the debug level.
20
28
  #
21
29
  # @param logger [Lumberjack::Logger] The logger to capture entries from.
30
+ # @param write_to_original [Boolean] If true (the default) the captured entries will be written
31
+ # back to the original device when the block completes. If false, the captured entries
32
+ # will not be written back.
22
33
  # @yield [device] The block to execute while capturing log entries.
23
34
  # @return [Lumberjack::CaptureDevice] The device that captured the log entries.
24
35
  # @yieldparam device [Lumberjack::CaptureDevice] The device that will capture the log entries.
25
36
  # @example
26
37
  # Lumberjack::CaptureDevice.capture(logger) do |logs|
27
38
  # logger.info("This will be captured")
28
- # expect(logs).to include(level: :info, message: "This will be captured")
39
+ # expect(logs).to include(severity: :info, message: "This will be captured")
29
40
  # end
30
41
  #
31
42
  # @example
32
43
  # logs = Lumberjack::CaptureDevice.capture(logger) { logger.info("This will be captured") }
33
- # expect(logs).to include(level: :info, message: "This will be captured")
34
- def capture(logger)
35
- device = new
44
+ # expect(logs).to include(severity: :info, message: "This will be captured")
45
+ def capture(logger, write_to_original: true)
36
46
  save_device = logger.device
37
47
  save_level = logger.level
38
- save_formatter = logger.formatter
48
+ device = new(underlying_device: save_device)
49
+
39
50
  begin
40
51
  logger.device = device
41
52
  logger.level = :debug
42
- logger.formatter = Lumberjack::Formatter.empty
43
53
  yield device
44
54
  ensure
45
55
  logger.device = save_device
46
56
  logger.level = save_level
47
- logger.formatter = save_formatter
48
- end
49
- device
50
- end
51
-
52
- # Helper method to format a log entry for display.
53
- #
54
- # @param entry [Lumberjack::LogEntry] The log entry to format.
55
- # @param indent [Integer] The indentation to prefix on every line.
56
- # @return [String] The formatted log entry.
57
- def formatted_entry(entry, indent: 0)
58
- indent_str = " " * indent
59
- timestamp = entry.time.strftime("%Y-%m-%d %H:%M:%S")
60
- formatted = +"#{indent_str}#{timestamp} #{entry.severity_label}: #{entry.message}"
61
- formatted << "\n#{indent_str} progname: #{entry.progname}" if entry.progname.to_s != ""
62
- if entry.tags && !entry.tags.empty?
63
- Lumberjack::Utils.flatten_tags(entry.tags).to_a.sort_by(&:first).each do |name, value|
64
- formatted << "\n#{indent_str} #{name}: #{value}"
65
- end
57
+ device.write_to_underlying_device if write_to_original
66
58
  end
67
- formatted
68
- end
69
59
 
70
- # Format a log entry or expectation hash into a more human readable format.
71
- #
72
- # @param expectation [Hash, Lumberjack::LogEntry] The expectation or log entry to format.
73
- # @return [String] A formatted string representation of the expectation or log entry.
74
- def formatted_expectation(expectation, indent: 0)
75
- if expectation.is_a?(Lumberjack::LogEntry)
76
- expectation = {
77
- "level" => expectation.severity_label,
78
- "message" => expectation.message,
79
- "progname" => expectation.progname,
80
- "tags" => expectation.tags
81
- }
82
- end
83
-
84
- expectation = expectation.transform_keys(&:to_s).compact
85
-
86
- message = []
87
- indent_str = " " * indent
88
- message << "#{indent_str}level: #{expectation["level"].inspect}" if expectation.include?("level")
89
- message << "#{indent_str}message: #{expectation["message"].inspect}" if expectation.include?("message")
90
- message << "#{indent_str}progname: #{expectation["progname"].inspect}" if expectation.include?("progname")
91
- if expectation["tags"].is_a?(Hash) && !expectation["tags"].empty?
92
- tags = Lumberjack::Utils.flatten_tags(expectation["tags"])
93
- prefix = "tags: "
94
- tags.sort_by(&:first).each do |name, value|
95
- message << "#{prefix} #{name}: #{value.inspect}"
96
- prefix = "#{indent_str} "
97
- end
98
- end
99
- message.join("\n")
60
+ device
100
61
  end
101
62
  end
102
63
 
103
- def initialize
104
- @buffer = []
105
- end
106
-
107
- def write(entry)
108
- @buffer << entry
109
- end
64
+ # The original device from the logger before capture started.
65
+ #
66
+ # @return [Lumberjack::Device, nil] The original device, or nil if none was set.
67
+ attr_reader :underlying_device
110
68
 
111
- # Clear all entries that have been written to the buffer.
112
- def clear
113
- @buffer.clear
69
+ # Initialize a new CaptureDevice.
70
+ #
71
+ # @param options [Hash] Options to pass to the parent Test device.
72
+ def initialize(options = {})
73
+ @underlying_device = options[:underlying_device]
74
+ super({max_entries: 1_000_000}.merge(options))
114
75
  end
115
76
 
116
- # Return true if the captured log entries match the specified level, message, and tags.
77
+ # Return all the captured entries that match the specified filters. The device
78
+ # `entry_formatter` is used to match the filters, so unformatted values can be used in
79
+ # the filters if it is set.
117
80
  #
118
- # For level, you can specified either a numeric constant (i.e. `Logger::WARN`) or a symbol
81
+ # For severity, you can specify either a numeric constant (i.e. `Logger::WARN`) or a symbol
119
82
  # (i.e. `:warn`).
120
83
  #
121
- # For message you can specify a string to perform an exact match or a regular expression
122
- # to perform a partial or pattern match. You can also supply any matcher value available
123
- # in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`, etc.).
124
- #
125
- # For tags, you can specify a hash of tag names to values to match. You can use
126
- # regular expression or matchers as the values here as well. Tags can also be nested to match
127
- # nested tags.
128
- #
129
- # Example:
130
- #
131
- # ```
132
- # logs.include(level: :warn, message: /something happened/, tags: {duration: instance_of(Float)})
133
- # ```
134
- #
135
- # @param args [Hash] The filters to apply to the captured entries.
136
- # @option args [String, Regexp] :message The message to match against the log entries.
137
- # @option args [String, Symbol, Integer] :level The log level to match against the log entries.
138
- # @option args [Hash] :tags A hash of tag names to values to match against the log entries. The tags
139
- # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
140
- # `{foo: {bar: "value"}}`).
141
- # @option args [String] :progname The program name to match against the log entries.
142
- # @return [Boolean] True if any entries match the specified filters, false otherwise.
143
- def include?(args)
144
- !!match(**args)
145
- end
146
-
147
- # Return all the captured entries that match the specified filters. These filters are
148
- # the same as described in the `include?` method.
84
+ # For message and progname you can specify a string to perform an exact match or a regular
85
+ # expression to perform a partial or pattern match. You can also supply any matcher value
86
+ # available in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`,
87
+ # etc.).
149
88
  #
150
89
  # @param message [String, Regexp, nil] The message to match against the log entries.
151
- # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
152
- # @param tags [Hash, nil] A hash of tag names to values to match against the log entries. The tags
153
- # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
154
- # `{foo: {bar: "value"}}`).
90
+ # @param severity [String, Symbol, Integer, nil] The severity to match against the log entries.
91
+ # @param attributes [Hash, nil] A hash of attribute names to values to match against the log entries. The attributes
92
+ # will match nested attributes using dot notation (e.g. `foo.bar` will match an attribute with the structure
93
+ # +{foo: {bar: "value"}}+).
94
+ # @param progname [String, nil] The program name to match against the log entries.
155
95
  # @param limit [Integer, nil] The maximum number of entries to return. If nil, all matching entries
156
96
  # will be returned.
157
97
  # @return [Array<Lumberjack::LogEntry>] An array of log entries that match the specified filters.
158
- def extract(message: nil, level: nil, tags: nil, progname: nil, limit: nil)
159
- matches = []
160
-
161
- if level
162
- # Normalize the level filter to numeric values.
163
- level = (level.is_a?(Integer) ? level : Lumberjack::Severity.label_to_level(level))
98
+ # @example
99
+ # logs.extract(severity: :warn, message: /something happened/, attributes: {user: "john"})
100
+ def extract(message: nil, severity: nil, attributes: nil, progname: nil, limit: nil)
101
+ matched = []
102
+
103
+ matcher = LogEntryMatcher.new(
104
+ message: message,
105
+ severity: severity,
106
+ attributes: attributes,
107
+ progname: progname,
108
+ formatter: entry_formatter
109
+ )
110
+
111
+ entries.each do |entry|
112
+ matched << entry if matcher.match?(entry)
113
+ break if limit && matched.size >= limit
164
114
  end
165
115
 
166
- @buffer.each do |entry|
167
- if matched?(entry, message, level, tags, progname)
168
- matches << entry
169
- break if limit && matches.size >= limit
170
- end
171
- end
172
-
173
- matches
116
+ matched
174
117
  end
175
118
 
176
- # Return the first entry that matches the specified filters.
119
+ # Return true if the captured log entries match the specified filters. The filters are the
120
+ # same as the ones used by the `extract` method.
177
121
  #
178
- # @param message [String, Regexp, nil] The message to match against the log entries.
179
- # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
180
- # @param tags [Hash, nil] A hash of tag names to values to match against the log entries.
181
- # @param progname [String, nil] The program name to match against the log entries.
182
- # @return [Lumberjack::LogEntry, nil] The log entry that most closely matches the filters, or nil if no entry meets minimum criteria.
183
- def match(message: nil, level: nil, tags: nil, progname: nil)
184
- extract(message: message, level: level, tags: tags, progname: progname, limit: 1).first
185
- end
186
-
187
- # Return the log entry that most closely matches the specified filters. This method
188
- # uses fuzzy matching logic to find the best match when no exact match exists.
189
- # The matching score is calculated based on how many criteria are met and how closely
190
- # they match. Returns nil if no entry meets the minimum matching criteria.
122
+ # This must be redefined here because Enumerable#include? would otherwise shadow the
123
+ # implementation inherited from Lumberjack::Device::Test.
191
124
  #
192
- # @param message [String, Regexp, nil] The message to match against the log entries.
193
- # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
194
- # @param tags [Hash, nil] A hash of tag names to values to match against the log entries.
195
- # @param progname [String, nil] The program name to match against the log entries.
196
- # @return [Lumberjack::LogEntry, nil] The log entry that most closely matches the filters, or nil if no entry meets minimum criteria.
197
- def closest_match(message: nil, level: nil, tags: nil, progname: nil)
198
- return nil if @buffer.empty?
199
-
200
- exact_match = match(message: message, level: level, tags: tags, progname: progname)
201
- return exact_match if exact_match
202
-
203
- # Normalize level filter
204
- if level
205
- level = (level.is_a?(Integer) ? level : Lumberjack::Severity.label_to_level(level))
125
+ # @example
126
+ # logs.include?(severity: :warn, message: /something happened/, attributes: {user: "john"})
127
+ #
128
+ # @param filters [Hash] The filters to apply to the captured entries.
129
+ # @option filters [String, Regexp] :message The message to match against the log entries.
130
+ # @option filters [String, Symbol, Integer] :severity The severity to match against the log entries.
131
+ # @option filters [Hash] :attributes A hash of attribute names to values to match against the log entries. The attributes
132
+ # will match nested attributes using dot notation (e.g. `foo.bar` will match an attribute with the structure
133
+ # +{foo: {bar: "value"}}+).
134
+ # @option filters [String] :progname The program name to match against the log entries.
135
+ # @return [Boolean] True if any entries match the specified filters, false otherwise.
136
+ def include?(filters)
137
+ filters = filters.transform_keys(&:to_sym)
138
+ unknown_keys = filters.keys - [:message, :severity, :attributes, :progname]
139
+ unless unknown_keys.empty?
140
+ raise ArgumentError, "unknown log filters: #{unknown_keys.map(&:inspect).join(", ")}"
206
141
  end
207
142
 
208
- best_entry = nil
209
- best_score = 0
143
+ !!match(**filters)
144
+ end
210
145
 
211
- @buffer.each do |entry|
212
- score = Lumberjack::CaptureDevice::EntryScore.calculate_match_score(entry, message, level, tags, progname)
213
- if score > best_score && score >= Lumberjack::CaptureDevice::EntryScore::MIN_SCORE_THRESHOLD
214
- best_score = score
215
- best_entry = entry
146
+ # Write the captured log entries to the underlying device.
147
+ #
148
+ # @param attributes [Hash, nil] Additional attributes to add to each entry as it is
149
+ # written. Attributes already set on an entry take precedence over these values.
150
+ # @return [void]
151
+ def write_to_underlying_device(attributes: nil)
152
+ return unless @underlying_device
153
+
154
+ if attributes.nil? || attributes.empty?
155
+ write_to(@underlying_device)
156
+ else
157
+ extra_attributes = Lumberjack::Utils.expand_attributes(attributes)
158
+ entries.each do |entry|
159
+ copy = entry.dup
160
+ copy.attributes = extra_attributes.merge(Lumberjack::Utils.expand_attributes(entry.attributes || {}))
161
+ @underlying_device.write(copy)
216
162
  end
217
163
  end
218
164
 
219
- best_entry
165
+ nil
220
166
  end
221
167
 
168
+ # Provide a detailed string representation showing all captured entries.
169
+ #
170
+ # @return [String] A formatted string showing all captured log entries.
222
171
  def inspect
223
- message = +"<##{self.class.name} #{@buffer.size} #{(@buffer.size == 1) ? "entry" : "entries"} captured:"
224
- @buffer.each do |entry|
225
- message << "\n #{Lumberjack::CaptureDevice.formatted_entry(entry)}"
172
+ message = +"<##{self.class.name} #{length} #{(length == 1) ? "entry" : "entries"} captured:\n"
173
+ template = Lumberjack::LocalLogTemplate.new
174
+ entries.each do |entry|
175
+ formatted = template.call(entry).split("\n").collect { |line| " #{line}" }.join("\n")
176
+ message << formatted
177
+ message << "\n"
226
178
  end
227
- message << "\n>"
179
+ message << ">"
228
180
  message
229
181
  end
230
182
 
183
+ # Provide a simple string representation showing the count of captured entries.
184
+ #
185
+ # @return [String] A brief description of the captured entries count.
231
186
  def to_s
232
- "<##{self.class.name} #{@buffer.size} #{(@buffer.size == 1) ? "entry" : "entries"} captured>"
187
+ "<##{self.class.name} #{length} #{(length == 1) ? "entry" : "entries"} captured>"
233
188
  end
234
189
 
190
+ # Return a thread-safe copy of all captured log entries. This must be redefined here
191
+ # because Enumerable#entries would otherwise shadow the thread-safe implementation
192
+ # inherited from Lumberjack::Device::Test.
193
+ #
194
+ # @return [Array<Lumberjack::LogEntry>] A copy of all captured log entries.
195
+ def entries
196
+ @lock.synchronize { @buffer.dup }
197
+ end
198
+
199
+ # Return the number of captured log entries.
200
+ #
201
+ # @return [Integer] The number of captured entries.
235
202
  def length
236
- @buffer.length
203
+ entries.length
237
204
  end
238
205
 
239
206
  alias_method :size, :length
240
207
 
208
+ # Iterate over each captured log entry.
209
+ #
210
+ # @yield [entry] Block to execute for each captured entry.
211
+ # @yieldparam entry [Lumberjack::LogEntry] A captured log entry.
212
+ # @return [Array<Lumberjack::LogEntry>] The captured entries (when no block given).
241
213
  def each(&block)
242
- @buffer.each(&block)
243
- end
244
-
245
- private
246
-
247
- def matched?(entry, message_filter, level_filter, tags_filter, progname_filter)
248
- return false unless match?(entry.message, message_filter)
249
- return false unless match?(entry.severity, level_filter)
250
- return false unless match?(entry.progname, progname_filter)
251
-
252
- if tags_filter.is_a?(Hash)
253
- tags_filter = deep_stringify_keys(Lumberjack::Utils.expand_tags(tags_filter))
254
- end
255
- tags = deep_stringify_keys(Lumberjack::Utils.expand_tags(entry.tags))
256
-
257
- return false unless match_tags?(tags, tags_filter)
258
-
259
- true
260
- end
261
-
262
- def match?(value, filter)
263
- return true unless filter
264
-
265
- filter === value
266
- end
267
-
268
- def match_tags?(tags, filter)
269
- return true unless filter
270
- return false unless tags
271
-
272
- filter.all? do |name, value_filter|
273
- name = name.to_s
274
- tag_values = tags[name]
275
- if tag_values.is_a?(Hash)
276
- if value_filter.is_a?(Hash)
277
- match_tags?(tag_values, value_filter)
278
- else
279
- false
280
- end
281
- elsif value_filter.nil? || (value_filter.is_a?(Enumerable) && value_filter.empty?)
282
- tag_values.nil? || (tag_values.is_a?(Array) && tag_values.empty?)
283
- elsif tags.include?(name)
284
- match?(tag_values, value_filter)
285
- else
286
- false
287
- end
288
- end
289
- end
290
-
291
- def deep_stringify_keys(hash)
292
- if hash.is_a?(Hash)
293
- hash.each_with_object({}) do |(key, value), result|
294
- new_key = key.to_s
295
- new_value = deep_stringify_keys(value)
296
- result[new_key] = new_value
297
- end
298
- elsif hash.is_a?(Enumerable)
299
- hash.collect { |item| deep_stringify_keys(item) }
300
- else
301
- hash
302
- end
214
+ entries.each(&block)
303
215
  end
304
216
  end
305
217
  end
306
-
307
- require_relative "capture_device/entry_score"
308
- require_relative "capture_device/include_log_entry_matcher"
@@ -31,8 +31,7 @@ Gem::Specification.new do |spec|
31
31
 
32
32
  spec.require_paths = ["lib"]
33
33
 
34
- spec.required_ruby_version = ">= 2.5"
34
+ spec.required_ruby_version = ">= 2.7"
35
35
 
36
- spec.add_dependency "lumberjack", ">=1.3.3"
37
- spec.add_development_dependency "bundler"
36
+ spec.add_dependency "lumberjack", ">= 2.1.0"
38
37
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lumberjack_capture_device
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-08-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: lumberjack
@@ -16,29 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: 1.3.3
18
+ version: 2.1.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: 1.3.3
27
- - !ruby/object:Gem::Dependency
28
- name: bundler
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
- description:
25
+ version: 2.1.0
42
26
  email:
43
27
  - bbdurand@gmail.com
44
28
  executables: []
@@ -50,7 +34,6 @@ files:
50
34
  - README.md
51
35
  - VERSION
52
36
  - lib/lumberjack/capture_device.rb
53
- - lib/lumberjack/capture_device/entry_score.rb
54
37
  - lib/lumberjack/capture_device/include_log_entry_matcher.rb
55
38
  - lib/lumberjack/capture_device/rspec.rb
56
39
  - lib/lumberjack_capture_device.rb
@@ -62,7 +45,6 @@ metadata:
62
45
  homepage_uri: https://github.com/bdurand/lumberjack_capture_device
63
46
  source_code_uri: https://github.com/bdurand/lumberjack_capture_device
64
47
  changelog_uri: https://github.com/bdurand/lumberjack_capture_device/blob/main/CHANGELOG.md
65
- post_install_message:
66
48
  rdoc_options: []
67
49
  require_paths:
68
50
  - lib
@@ -70,15 +52,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
70
52
  requirements:
71
53
  - - ">="
72
54
  - !ruby/object:Gem::Version
73
- version: '2.5'
55
+ version: '2.7'
74
56
  required_rubygems_version: !ruby/object:Gem::Requirement
75
57
  requirements:
76
58
  - - ">="
77
59
  - !ruby/object:Gem::Version
78
60
  version: '0'
79
61
  requirements: []
80
- rubygems_version: 3.4.10
81
- signing_key:
62
+ rubygems_version: 4.0.3
82
63
  specification_version: 4
83
64
  summary: Testing device for the lumberjack gem that can be used for asserting messages
84
65
  have been logged in a test suite.
@@ -1,220 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Class responsible for scoring and matching log entries against filters
4
- class Lumberjack::CaptureDevice::EntryScore
5
- # Minimum score threshold for considering a match (30% match)
6
- MIN_SCORE_THRESHOLD = 0.3
7
-
8
- class << self
9
- # Calculate the overall match score for an entry against all provided filters
10
- # Returns a score between 0.0 and 1.0
11
- def calculate_match_score(entry, message_filter, level_filter, tags_filter, progname_filter)
12
- scores = []
13
- weights = []
14
-
15
- # Check message match
16
- if message_filter
17
- message_score = calculate_field_score(entry.message, message_filter)
18
- scores << message_score
19
- weights << 0.4 # Weight message matching highly
20
- end
21
-
22
- # Check level match
23
- if level_filter
24
- level_score = if entry.severity == level_filter
25
- 1.0 # Exact level match
26
- else
27
- level_proximity_score(entry.severity, level_filter) # Partial level match
28
- end
29
- scores << level_score
30
- weights << 0.3
31
- end
32
-
33
- # Check progname match
34
- if progname_filter
35
- progname_score = calculate_field_score(entry.progname, progname_filter)
36
- scores << progname_score
37
- weights << 0.2
38
- end
39
-
40
- # Check tags match
41
- if tags_filter.is_a?(Hash) && !tags_filter.empty?
42
- tags_score = calculate_tags_score(entry.tags, tags_filter)
43
- scores << tags_score
44
- weights << 0.3
45
- end
46
-
47
- # Return 0 if no criteria were provided
48
- return 0.0 if scores.empty?
49
-
50
- # Calculate weighted average, but apply a penalty if any score is 0
51
- # This ensures that completely failed criteria significantly impact the result
52
- total_weighted_score = scores.zip(weights).map { |score, weight| score * weight }.sum
53
- total_weight = weights.sum
54
- base_score = total_weighted_score / total_weight
55
-
56
- # Apply penalty for zero scores: reduce the score based on how many criteria completely failed
57
- zero_scores = scores.count(0.0)
58
- if zero_scores > 0
59
- penalty_factor = 1.0 - (zero_scores.to_f / scores.length * 0.5) # Up to 50% penalty
60
- base_score *= penalty_factor
61
- end
62
-
63
- base_score
64
- end
65
-
66
- # Calculate score for any field value against a filter
67
- # Returns a score between 0.0 and 1.0 based on how well the value matches the filter
68
- def calculate_field_score(value, filter)
69
- return 0.0 unless value && filter
70
-
71
- case filter
72
- when String
73
- value_str = value.to_s
74
- if value_str == filter
75
- 1.0
76
- elsif value_str.include?(filter)
77
- 0.7
78
- else
79
- # Use string similarity for partial matching
80
- similarity = string_similarity(value_str, filter)
81
- (similarity > 0.5) ? similarity * 0.6 : 0.0
82
- end
83
- when Regexp
84
- filter.match?(value.to_s) ? 1.0 : 0.0
85
- else
86
- # For other matchers (like RSpec matchers), try to use === operator
87
- begin
88
- (filter === value) ? 1.0 : 0.0
89
- rescue
90
- 0.0
91
- end
92
- end
93
- end
94
-
95
- # Calculate proximity score based on log level distance
96
- def level_proximity_score(entry_level, filter_level)
97
- level_diff = (entry_level - filter_level).abs
98
- case level_diff
99
- when 0 then 1.0
100
- when 1 then 0.7
101
- when 2 then 0.4
102
- else 0.0
103
- end
104
- end
105
-
106
- # Calculate score for tag matching
107
- def calculate_tags_score(entry_tags, tags_filter)
108
- return 0.0 unless entry_tags && tags_filter.is_a?(Hash)
109
-
110
- tags_filter = deep_stringify_keys(Lumberjack::Utils.expand_tags(tags_filter))
111
- tags = deep_stringify_keys(Lumberjack::Utils.expand_tags(entry_tags))
112
-
113
- total_tag_filters = count_tag_filters(tags_filter)
114
- return 0.0 if total_tag_filters == 0
115
-
116
- matched_tags = count_matched_tags(tags, tags_filter)
117
- matched_tags.to_f / total_tag_filters
118
- end
119
-
120
- private
121
-
122
- # Calculate string similarity using a simple Levenshtein distance-based approach
123
- # Returns a score between 0.0 and 1.0 where 1.0 is an exact match
124
- def string_similarity(str1, str2)
125
- return 1.0 if str1 == str2
126
- return 0.0 if str1.nil? || str2.nil? || str1.empty? || str2.empty?
127
-
128
- # Convert to lowercase for case-insensitive comparison
129
- s1 = str1.downcase
130
- s2 = str2.downcase
131
-
132
- # If one string contains the other, give it a good score
133
- if s1.include?(s2) || s2.include?(s1)
134
- shorter = [s1.length, s2.length].min
135
- longer = [s1.length, s2.length].max
136
- return shorter.to_f / longer * 0.8 + 0.2 # Boost score for containment
137
- end
138
-
139
- # Calculate Levenshtein distance
140
- distance = levenshtein_distance(s1, s2)
141
- max_length = [s1.length, s2.length].max
142
-
143
- # Convert distance to similarity score
144
- return 0.0 if max_length == 0
145
- 1.0 - (distance.to_f / max_length)
146
- end
147
-
148
- # Simple Levenshtein distance implementation
149
- def levenshtein_distance(str1, str2)
150
- return str2.length if str1.empty?
151
- return str1.length if str2.empty?
152
-
153
- matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
154
-
155
- # Initialize first row and column
156
- (0..str1.length).each { |i| matrix[i][0] = i }
157
- (0..str2.length).each { |j| matrix[0][j] = j }
158
-
159
- # Fill the matrix
160
- (1..str1.length).each do |i|
161
- (1..str2.length).each do |j|
162
- cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
163
- matrix[i][j] = [
164
- matrix[i - 1][j] + 1, # deletion
165
- matrix[i][j - 1] + 1, # insertion
166
- matrix[i - 1][j - 1] + cost # substitution
167
- ].min
168
- end
169
- end
170
-
171
- matrix[str1.length][str2.length]
172
- end
173
-
174
- def count_tag_filters(tags_filter, count = 0)
175
- tags_filter.each do |_name, value_filter|
176
- if value_filter.is_a?(Hash)
177
- count = count_tag_filters(value_filter, count)
178
- else
179
- count += 1
180
- end
181
- end
182
- count
183
- end
184
-
185
- def count_matched_tags(tags, tags_filter, count = 0)
186
- return count unless tags && tags_filter
187
-
188
- tags_filter.each do |name, value_filter|
189
- name = name.to_s
190
- tag_values = tags[name]
191
-
192
- if value_filter.is_a?(Hash) && tag_values.is_a?(Hash)
193
- count = count_matched_tags(tag_values, value_filter, count)
194
- elsif tags.include?(name) && exact_match?(tag_values, value_filter)
195
- count += 1
196
- end
197
- end
198
- count
199
- end
200
-
201
- def exact_match?(value, filter)
202
- return true unless filter
203
- filter === value
204
- end
205
-
206
- def deep_stringify_keys(hash)
207
- if hash.is_a?(Hash)
208
- hash.each_with_object({}) do |(key, value), result|
209
- new_key = key.to_s
210
- new_value = deep_stringify_keys(value)
211
- result[new_key] = new_value
212
- end
213
- elsif hash.is_a?(Enumerable)
214
- hash.collect { |item| deep_stringify_keys(item) }
215
- else
216
- hash
217
- end
218
- end
219
- end
220
- end