lumberjack_capture_device 1.1.0 → 1.2.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: 66cd8d5b6e75a04f7c7d0ef12469c999138d102e414be22a6f97b08bb9bf1cbd
4
- data.tar.gz: 0b783ca27c07301ac2c16dbf56f181260f68ae08aa8b6ee3afed33f5f680cca4
3
+ metadata.gz: 633b68181377b77f1940712d6a48261c5c74ed569fdf7d008636691d35add3d2
4
+ data.tar.gz: c3b73c48ba4d513ef3416676fd38f5ce7877d1e820ba5f4a45ac8b527c468c05
5
5
  SHA512:
6
- metadata.gz: 82c1cac6a0b4c075a464b3abe5bb7b0ce4ebb8d6083f50d9efd12a966b45105fed43b330f15a4a7da6d455107af6853c67713f95792222a156435539b0dbd661
7
- data.tar.gz: a5e43d1232499898e019db99581dce01e7e1ca15919a0b0c23fb1e45a4b6a28714177d5418c6b1fa882057544ed2c69067ca80130690bd564be2c9409e0d73fc
6
+ metadata.gz: 1656511eb767a622dec5a5b88500a82471e6c21d1976e7a2124429b117586cec570d27490d44ebdeeb539a99a2ab6dc8b786d886c7cffc46c0705c126412d320
7
+ data.tar.gz: 6539f93f7a7d9b9d8c4d142dd89385c8143622f281beab396f51dea1706aa9e446653fc07811ae4fa1ade61213fbdb7a7234b7e004add5646c9e373bd583d699
data/CHANGELOG.md CHANGED
@@ -4,12 +4,33 @@ 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
+ ## 1.2.0
8
+
9
+ ### Added
10
+
11
+ - Added support for matching the `progname` of log entries.
12
+ - Added custom RSpec matchers that outputs cleaner messages for failed tests.
13
+
14
+ ## 1.1.1
15
+
16
+ ### Fixed
17
+
18
+ - Handle tag array comparison when an array contains hashes to consistently convert the hash keys to strings. Otherwise array hashes were being treated differently that other tag structures.
19
+
20
+ ### Changed
21
+
22
+ - Improved the `inspect` method to provide a clearer representation of captured log entries to make debugging tests easier.
23
+
7
24
  ## 1.1.0
8
25
 
9
26
  ### Added
10
27
 
11
28
  - Support for matching tag structures in log entries regardless of if they are specified with dot notation or nested tags. So "foo.bar" will match a tag with the structure `{foo: {bar: "value"}}` or `{"foo.bar" => value}`.
12
29
 
30
+ ### Changed
31
+
32
+ - Specifying a tag to match on nil will now match log entries missing the tag rather than matching any value for that tag.
33
+
13
34
  ## 1.0.1
14
35
 
15
36
  ### Added
data/README.md CHANGED
@@ -44,7 +44,6 @@ logs = Lumberjack::CaptureDevice.capture(Rails.logger) { do_something }
44
44
  assert(logs.include?(level: :info, message: "Something happened"))
45
45
  ```
46
46
 
47
-
48
47
  You can filter the logs on level, message, and tags.
49
48
 
50
49
  - The level option can take either a label (i.e. `:warn`) or a constant (i.e. `Logger::WARN`).
@@ -60,6 +59,25 @@ expect(logs).to include(tags: {foo: anything, "count.one" => 1})
60
59
 
61
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`.
62
61
 
62
+ ### RSpec Support
63
+
64
+ You can include some RSpec syntactic sugar by requiring the rspec file in your test helper.
65
+
66
+ ```ruby
67
+ require "lumberjack/capture_device/rspec"
68
+ ```
69
+
70
+ This will give you a `capture_logger` method and `include_log_entry` matcher. The `include_log_entry` matcher provides a bit cleaner output which can make debugging failing tests a bit easier.
71
+
72
+ ```ruby
73
+ describe MyClass do
74
+ it "logs information" do
75
+ logs = capture_logger { MyClass.do_something }
76
+ expect(logs).to include_log_entry(message: "Something")
77
+ end
78
+ end
79
+ ```
80
+
63
81
  ## Installation
64
82
 
65
83
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0
@@ -0,0 +1,220 @@
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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matcher for checking captured logs for specific entries.
4
+ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
5
+ def initialize(expected_hash)
6
+ @expected_hash = expected_hash.transform_keys(&:to_sym)
7
+ @captured_logger = nil
8
+ end
9
+
10
+ def matches?(actual)
11
+ @captured_logger = actual
12
+ return false unless valid_captured_logger?
13
+
14
+ @captured_logger.include?(@expected_hash)
15
+ end
16
+
17
+ def failure_message
18
+ if valid_captured_logger?
19
+ formatted_failure_message(@captured_logger, @expected_hash, negated: false)
20
+ else
21
+ wrong_object_type_message(@captured_logger)
22
+ end
23
+ end
24
+
25
+ def failure_message_when_negated
26
+ if valid_captured_logger?
27
+ formatted_failure_message(@captured_logger, @expected_hash, negated: true)
28
+ else
29
+ wrong_object_type_message(@captured_logger)
30
+ end
31
+ end
32
+
33
+ def description
34
+ "have logged entry with #{expectation_description(@expected_hash)}"
35
+ end
36
+
37
+ private
38
+
39
+ def valid_captured_logger?
40
+ @captured_logger.is_a?(Lumberjack::CaptureDevice)
41
+ end
42
+
43
+ def wrong_object_type_message(captured_logger)
44
+ "Expected a Lumberjack::CaptureDevice object, but received a #{captured_logger.class}."
45
+ end
46
+
47
+ def formatted_failure_message(captured_logger, expected_hash, negated:)
48
+ closest_match = captured_logger.closest_match(**expected_hash)
49
+ message = "expected logs did not include expected entry.\n\n" \
50
+ "Expected entry:\n-----------\n#{Lumberjack::CaptureDevice.formatted_expectation(expected_hash)}\n\n" \
51
+ "Captured logs:\n-----------\n#{captured_logger.inspect}"
52
+
53
+ if closest_match
54
+ # Convert the entry to a hash format for formatted_expectation
55
+ closest_match_hash = {
56
+ level: closest_match.severity_label,
57
+ message: closest_match.message,
58
+ progname: closest_match.progname,
59
+ tags: closest_match.tags
60
+ }.compact
61
+
62
+ message = "#{message}\n\nClosest match found:\n-----------\n" \
63
+ "#{Lumberjack::CaptureDevice.formatted_expectation(closest_match_hash)}"
64
+ end
65
+
66
+ message
67
+ end
68
+
69
+ def expectation_description(expected_hash)
70
+ info = []
71
+ info << "level: #{expected_hash[:level].inspect}" unless expected_hash[:level].nil?
72
+ info << "message: #{expected_hash[:message].inspect}" unless expected_hash[:message].nil?
73
+ info << "progname: #{expected_hash[:progname].inspect}" unless expected_hash[:progname].nil?
74
+ if expected_hash[:tags].is_a?(Hash) && !expected_hash[:tags].empty?
75
+ tags = Lumberjack::Utils.flatten_tags(expected_hash[:tags])
76
+ tags_info = tags.collect { |name, value| "#{name}=#{value.inspect}" }.join(", ")
77
+ info << "tags: #{tags_info}"
78
+ end
79
+ info.join(", ")
80
+ end
81
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../capture_device"
4
+ require "rspec"
5
+
6
+ module Lumberjack::CaptureDevice::RSpec
7
+ def include_log_entry(expected_hash)
8
+ Lumberjack::CaptureDevice::IncludeLogEntryMatcher.new(expected_hash)
9
+ end
10
+
11
+ def capture_logger(logger, &block)
12
+ Lumberjack::CaptureDevice.capture(logger, &block)
13
+ end
14
+ end
15
+
16
+ RSpec.configure do |config|
17
+ config.include Lumberjack::CaptureDevice::RSpec
18
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lumberjack"
4
+
5
+ module Lumberjack
6
+ # Lumberjack device for capturing log entries into memory to allow them to be inspected
7
+ # for testing purposes.
8
+ class CaptureDevice < Lumberjack::Device
9
+ VERSION = File.read(File.join(__dir__, "..", "..", "VERSION"))
10
+
11
+ attr_reader :buffer
12
+
13
+ class << self
14
+ # Capture the entries written by the logger within a block. Within the block all log
15
+ # entries will be written to a CaptureDevice rather than to the normal output for
16
+ # the logger. In addition, all formatters will be removed and the log level will be set
17
+ # to debug. The device being written to be both yielded to the block as well as returned
18
+ # by the method call.
19
+ #
20
+ # @param logger [Lumberjack::Logger] The logger to capture entries from.
21
+ # @yield [device] The block to execute while capturing log entries.
22
+ # @return [Lumberjack::CaptureDevice] The device that captured the log entries.
23
+ # @yieldparam device [Lumberjack::CaptureDevice] The device that will capture the log entries.
24
+ # @example
25
+ # Lumberjack::CaptureDevice.capture(logger) do |logs|
26
+ # logger.info("This will be captured")
27
+ # expect(logs).to include(level: :info, message: "This will be captured")
28
+ # end
29
+ #
30
+ # @example
31
+ # logs = Lumberjack::CaptureDevice.capture(logger) { logger.info("This will be captured") }
32
+ # expect(logs).to include(level: :info, message: "This will be captured")
33
+ def capture(logger)
34
+ device = new
35
+ save_device = logger.device
36
+ save_level = logger.level
37
+ save_formatter = logger.formatter
38
+ begin
39
+ logger.device = device
40
+ logger.level = :debug
41
+ logger.formatter = Lumberjack::Formatter.empty
42
+ yield device
43
+ ensure
44
+ logger.device = save_device
45
+ logger.level = save_level
46
+ logger.formatter = save_formatter
47
+ end
48
+ device
49
+ end
50
+
51
+ def formatted_expectation(expectation)
52
+ expectation = expectation.transform_keys(&:to_s).compact
53
+ message = []
54
+ message << "level: #{expectation["level"].inspect}"
55
+ message << "message: #{expectation["message"].inspect}"
56
+ message << "progname: #{expectation["progname"].inspect}"
57
+ if expectation["tags"].is_a?(Hash) && !expectation["tags"].empty?
58
+ tags = Lumberjack::Utils.flatten_tags(expectation["tags"])
59
+ prefix = "tags: "
60
+ tags.each do |name, value|
61
+ message << "#{prefix} #{name}: #{value.inspect}"
62
+ prefix = " "
63
+ end
64
+ end
65
+ message.join("\n")
66
+ end
67
+ end
68
+
69
+ def initialize
70
+ @buffer = []
71
+ end
72
+
73
+ def write(entry)
74
+ @buffer << entry
75
+ end
76
+
77
+ # Clear all entries that have been written to the buffer.
78
+ def clear
79
+ @buffer.clear
80
+ end
81
+
82
+ # Return true if the captured log entries match the specified level, message, and tags.
83
+ #
84
+ # For level, you can specified either a numeric constant (i.e. `Logger::WARN`) or a symbol
85
+ # (i.e. `:warn`).
86
+ #
87
+ # For message you can specify a string to perform an exact match or a regular expression
88
+ # to perform a partial or pattern match. You can also supply any matcher value available
89
+ # in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`, etc.).
90
+ #
91
+ # For tags, you can specify a hash of tag names to values to match. You can use
92
+ # regular expression or matchers as the values here as well. Tags can also be nested to match
93
+ # nested tags.
94
+ #
95
+ # Example:
96
+ #
97
+ # ```
98
+ # logs.include(level: :warn, message: /something happened/, tags: {duration: instance_of(Float)})
99
+ # ```
100
+ #
101
+ # @param args [Hash] The filters to apply to the captured entries.
102
+ # @option args [String, Regexp] :message The message to match against the log entries.
103
+ # @option args [String, Symbol, Integer] :level The log level to match against the log entries.
104
+ # @option args [Hash] :tags A hash of tag names to values to match against the log entries. The tags
105
+ # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
106
+ # `{foo: {bar: "value"}}`).
107
+ # @option args [String] :progname The program name to match against the log entries.
108
+ # @return [Boolean] True if any entries match the specified filters, false otherwise.
109
+ def include?(args)
110
+ !extract(**args.merge(limit: 1)).empty?
111
+ end
112
+
113
+ # Return all the captured entries that match the specified filters. These filters are
114
+ # the same as described in the `include?` method.
115
+ #
116
+ # @param message [String, Regexp, nil] The message to match against the log entries.
117
+ # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
118
+ # @param tags [Hash, nil] A hash of tag names to values to match against the log entries. The tags
119
+ # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
120
+ # `{foo: {bar: "value"}}`).
121
+ # @param limit [Integer, nil] The maximum number of entries to return. If nil, all matching entries
122
+ # will be returned.
123
+ # @return [Array<Lumberjack::Entry>] An array of log entries that match the specified filters.
124
+ def extract(message: nil, level: nil, tags: nil, limit: nil, progname: nil)
125
+ matches = []
126
+
127
+ if level
128
+ # Normalize the level filter to numeric values.
129
+ level = (level.is_a?(Integer) ? level : Lumberjack::Severity.label_to_level(level))
130
+ end
131
+
132
+ @buffer.each do |entry|
133
+ if matched?(entry, message, level, tags, progname)
134
+ matches << entry
135
+ break if limit && matches.size >= limit
136
+ end
137
+ end
138
+
139
+ matches
140
+ end
141
+
142
+ # Return the log entry that most closely matches the specified filters. This method
143
+ # uses fuzzy matching logic to find the best match when no exact match exists.
144
+ # The matching score is calculated based on how many criteria are met and how closely
145
+ # they match. Returns nil if no entry meets the minimum matching criteria.
146
+ #
147
+ # @param message [String, Regexp, nil] The message to match against the log entries.
148
+ # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
149
+ # @param tags [Hash, nil] A hash of tag names to values to match against the log entries.
150
+ # @param progname [String, nil] The program name to match against the log entries.
151
+ # @return [Lumberjack::Entry, nil] The log entry that most closely matches the filters, or nil if no entry meets minimum criteria.
152
+ def closest_match(message: nil, level: nil, tags: nil, progname: nil)
153
+ return nil if @buffer.empty?
154
+
155
+ # Normalize level filter
156
+ if level
157
+ level = (level.is_a?(Integer) ? level : Lumberjack::Severity.label_to_level(level))
158
+ end
159
+
160
+ best_entry = nil
161
+ best_score = 0
162
+
163
+ @buffer.each do |entry|
164
+ score = Lumberjack::CaptureDevice::EntryScore.calculate_match_score(entry, message, level, tags, progname)
165
+ if score > best_score && score >= Lumberjack::CaptureDevice::EntryScore::MIN_SCORE_THRESHOLD
166
+ best_score = score
167
+ best_entry = entry
168
+ end
169
+ end
170
+
171
+ best_entry
172
+ end
173
+
174
+ def inspect
175
+ message = +"<##{self.class.name} #{@buffer.size} #{(@buffer.size == 1) ? "entry" : "entries"} captured:"
176
+ @buffer.each do |entry|
177
+ message << "\n #{formatted_entry(entry)}"
178
+ end
179
+ message << "\n>"
180
+ message
181
+ end
182
+
183
+ def to_s
184
+ "<##{self.class.name} #{@buffer.size} #{(@buffer.size == 1) ? "entry" : "entries"} captured>"
185
+ end
186
+
187
+ private
188
+
189
+ def matched?(entry, message_filter, level_filter, tags_filter, progname_filter)
190
+ return false unless match?(entry.message, message_filter)
191
+ return false unless match?(entry.severity, level_filter)
192
+ return false unless match?(entry.progname, progname_filter)
193
+
194
+ if tags_filter.is_a?(Hash)
195
+ tags_filter = deep_stringify_keys(Lumberjack::Utils.expand_tags(tags_filter))
196
+ end
197
+ tags = deep_stringify_keys(Lumberjack::Utils.expand_tags(entry.tags))
198
+
199
+ return false unless match_tags?(tags, tags_filter)
200
+
201
+ true
202
+ end
203
+
204
+ def match?(value, filter)
205
+ return true unless filter
206
+
207
+ filter === value
208
+ end
209
+
210
+ def match_tags?(tags, filter)
211
+ return true unless filter
212
+ return false unless tags
213
+
214
+ filter.all? do |name, value_filter|
215
+ name = name.to_s
216
+ tag_values = tags[name]
217
+ if tag_values.is_a?(Hash)
218
+ if value_filter.is_a?(Hash)
219
+ match_tags?(tag_values, value_filter)
220
+ else
221
+ false
222
+ end
223
+ elsif value_filter.nil? || (value_filter.is_a?(Enumerable) && value_filter.empty?)
224
+ tag_values.nil? || (tag_values.is_a?(Array) && tag_values.empty?)
225
+ elsif tags.include?(name)
226
+ match?(tag_values, value_filter)
227
+ else
228
+ false
229
+ end
230
+ end
231
+ end
232
+
233
+ def deep_stringify_keys(hash)
234
+ if hash.is_a?(Hash)
235
+ hash.each_with_object({}) do |(key, value), result|
236
+ new_key = key.to_s
237
+ new_value = deep_stringify_keys(value)
238
+ result[new_key] = new_value
239
+ end
240
+ elsif hash.is_a?(Enumerable)
241
+ hash.collect { |item| deep_stringify_keys(item) }
242
+ else
243
+ hash
244
+ end
245
+ end
246
+
247
+ def formatted_entry(entry)
248
+ timestamp = entry.time.strftime("%Y-%m-%d %H:%M:%S")
249
+ formatted = +"#{timestamp} #{entry.severity_label}: #{entry.message}"
250
+ formatted << "\n progname: #{entry.progname}" if entry.progname.to_s != ""
251
+ if entry.tags && !entry.tags.empty?
252
+ Lumberjack::Utils.flatten_tags(entry.tags).to_a.sort_by(&:first).each do |name, value|
253
+ formatted << "\n #{name}: #{value}"
254
+ end
255
+ end
256
+ formatted
257
+ end
258
+ end
259
+ end
260
+
261
+ require_relative "capture_device/entry_score"
262
+ require_relative "capture_device/include_log_entry_matcher"
@@ -1,163 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lumberjack"
4
-
5
- module Lumberjack
6
- # Lumberjack device for capturing log entries into memory to allow them to be inspected
7
- # for testing purposes.
8
- class CaptureDevice < Lumberjack::Device
9
- attr_reader :buffer
10
-
11
- class << self
12
- # Capture the entries written by the logger within a block. Within the block all log
13
- # entries will be written to a CaptureDevice rather than to the normal output for
14
- # the logger. In addition, all formatters will be removed and the log level will be set
15
- # to debug. The device being written to be both yielded to the block as well as returned
16
- # by the method call.
17
- #
18
- # @param logger [Lumberjack::Logger] The logger to capture entries from.
19
- # @yield [device] The block to execute while capturing log entries.
20
- # @return [Lumberjack::CaptureDevice] The device that captured the log entries.
21
- # @yieldparam device [Lumberjack::CaptureDevice] The device that will capture the log entries.
22
- # @example
23
- # Lumberjack::CaptureDevice.capture(logger) do |logs|
24
- # logger.info("This will be captured")
25
- # expect(logs).to include(level: :info, message: "This will be captured")
26
- # end
27
- #
28
- # @example
29
- # logs = Lumberjack::CaptureDevice.capture(logger) { logger.info("This will be captured") }
30
- # expect(logs).to include(level: :info, message: "This will be captured")
31
- def capture(logger)
32
- device = new
33
- save_device = logger.device
34
- save_level = logger.level
35
- save_formatter = logger.formatter
36
- begin
37
- logger.device = device
38
- logger.level = :debug
39
- logger.formatter = Lumberjack::Formatter.empty
40
- yield device
41
- ensure
42
- logger.device = save_device
43
- logger.level = save_level
44
- logger.formatter = save_formatter
45
- end
46
- device
47
- end
48
- end
49
-
50
- def initialize
51
- @buffer = []
52
- end
53
-
54
- def write(entry)
55
- @buffer << entry
56
- end
57
-
58
- # Clear all entries that have been written to the buffer.
59
- def clear
60
- @buffer.clear
61
- end
62
-
63
- # Return true if the captured log entries match the specified level, message, and tags.
64
- #
65
- # For level, you can specified either a numeric constant (i.e. `Logger::WARN`) or a symbol
66
- # (i.e. `:warn`).
67
- #
68
- # For message you can specify a string to perform an exact match or a regular expression
69
- # to perform a partial or pattern match. You can also supply any matcher value available
70
- # in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`, etc.).
71
- #
72
- # For tags, you can specify a hash of tag names to values to match. You can use
73
- # regular expression or matchers as the values here as well. Tags can also be nested to match
74
- # nested tags.
75
- #
76
- # Example:
77
- #
78
- # ```
79
- # logs.include(level: :warn, message: /something happened/, tags: {duration: instance_of(Float)})
80
- # ```
81
- #
82
- # @param args [Hash] The filters to apply to the captured entries.
83
- # @option args [String, Regexp] :message The message to match against the log entries.
84
- # @option args [String, Symbol, Integer] :level The log level to match against the log entries.
85
- # @option args [Hash] :tags A hash of tag names to values to match against the log entries. The tags
86
- # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
87
- # `{foo: {bar: "value"}}`).
88
- # @return [Boolean] True if any entries match the specified filters, false otherwise.
89
- def include?(args)
90
- !extract(**args.merge(limit: 1)).empty?
91
- end
92
-
93
- # Return all the captured entries that match the specified filters. These filters are
94
- # the same as described in the `include?` method.
95
- #
96
- # @param message [String, Regexp, nil] The message to match against the log entries.
97
- # @param level [String, Symbol, Integer, nil] The log level to match against the log entries.
98
- # @param tags [Hash, nil] A hash of tag names to values to match against the log entries. The tags
99
- # will match nested tags using dot notation (e.g. `foo.bar` will match a tag with the structure
100
- # `{foo: {bar: "value"}}`).
101
- # @param limit [Integer, nil] The maximum number of entries to return. If nil, all matching entries
102
- # will be returned.
103
- # @return [Array<Lumberjack::Entry>] An array of log entries that match the specified filters.
104
- def extract(message: nil, level: nil, tags: nil, limit: nil)
105
- matches = []
106
-
107
- if level
108
- # Normalize the level filter to numeric values.
109
- level = (level.is_a?(Integer) ? level : Lumberjack::Severity.label_to_level(level))
110
- end
111
- @buffer.each do |entry|
112
- if matched?(entry, message, level, tags)
113
- matches << entry
114
- break if limit && matches.size >= limit
115
- end
116
- end
117
-
118
- matches
119
- end
120
-
121
- private
122
-
123
- def matched?(entry, message_filter, level_filter, tags_filter)
124
- return false unless match?(entry.message, message_filter)
125
- return false unless match?(entry.severity, level_filter)
126
-
127
- tags_filter = Lumberjack::Utils.expand_tags(tags_filter) if tags_filter.is_a?(Hash)
128
- tags = Lumberjack::Utils.expand_tags(entry.tags)
129
-
130
- return false unless match_tags?(tags, tags_filter)
131
-
132
- true
133
- end
134
-
135
- def match?(value, filter)
136
- return true unless filter
137
- filter === value
138
- end
139
-
140
- def match_tags?(tags, filter)
141
- return true unless filter
142
- return false unless tags
143
-
144
- filter.all? do |name, value_filter|
145
- name = name.to_s
146
- tag_values = tags[name]
147
- if tag_values.is_a?(Hash)
148
- if value_filter.is_a?(Hash)
149
- match_tags?(tag_values, value_filter)
150
- else
151
- false
152
- end
153
- elsif value_filter.nil? || (value_filter.is_a?(Array) && value_filter.empty?)
154
- tag_values.nil? || (tag_values.is_a?(Array) && tag_values.empty?)
155
- elsif tags.include?(name)
156
- match?(tag_values, value_filter)
157
- else
158
- false
159
- end
160
- end
161
- end
162
- end
163
- end
3
+ require_relative "lumberjack/capture_device"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lumberjack_capture_device
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-23 00:00:00.000000000 Z
11
+ date: 2025-07-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lumberjack
@@ -49,6 +49,10 @@ files:
49
49
  - MIT_LICENSE.txt
50
50
  - README.md
51
51
  - VERSION
52
+ - lib/lumberjack/capture_device.rb
53
+ - lib/lumberjack/capture_device/entry_score.rb
54
+ - lib/lumberjack/capture_device/include_log_entry_matcher.rb
55
+ - lib/lumberjack/capture_device/rspec.rb
52
56
  - lib/lumberjack_capture_device.rb
53
57
  - lumberjack_capture_device.gemspec
54
58
  homepage: https://github.com/bdurand/lumberjack_capture_device