lumberjack_capture_device 1.1.1 → 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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +19 -1
- data/VERSION +1 -1
- data/lib/lumberjack/capture_device/entry_score.rb +220 -0
- data/lib/lumberjack/capture_device/include_log_entry_matcher.rb +81 -0
- data/lib/lumberjack/capture_device/rspec.rb +18 -0
- data/lib/lumberjack/capture_device.rb +262 -0
- data/lib/lumberjack_capture_device.rb +1 -199
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 633b68181377b77f1940712d6a48261c5c74ed569fdf7d008636691d35add3d2
|
4
|
+
data.tar.gz: c3b73c48ba4d513ef3416676fd38f5ce7877d1e820ba5f4a45ac8b527c468c05
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1656511eb767a622dec5a5b88500a82471e6c21d1976e7a2124429b117586cec570d27490d44ebdeeb539a99a2ab6dc8b786d886c7cffc46c0705c126412d320
|
7
|
+
data.tar.gz: 6539f93f7a7d9b9d8c4d142dd89385c8143622f281beab396f51dea1706aa9e446653fc07811ae4fa1ade61213fbdb7a7234b7e004add5646c9e373bd583d699
|
data/CHANGELOG.md
CHANGED
@@ -4,6 +4,13 @@ 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
|
+
|
7
14
|
## 1.1.1
|
8
15
|
|
9
16
|
### Fixed
|
@@ -20,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
20
27
|
|
21
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}`.
|
22
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
|
+
|
23
34
|
## 1.0.1
|
24
35
|
|
25
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
|
+
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,201 +1,3 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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
|
-
def inspect
|
122
|
-
message = +"<##{self.class.name} #{@buffer.size} #{(@buffer.size == 1) ? "entry" : "entries"} captured:"
|
123
|
-
@buffer.each do |entry|
|
124
|
-
message << "\n #{formatted_entry(entry)}"
|
125
|
-
end
|
126
|
-
message << "\n>"
|
127
|
-
message
|
128
|
-
end
|
129
|
-
|
130
|
-
private
|
131
|
-
|
132
|
-
def matched?(entry, message_filter, level_filter, tags_filter)
|
133
|
-
return false unless match?(entry.message, message_filter)
|
134
|
-
return false unless match?(entry.severity, level_filter)
|
135
|
-
|
136
|
-
if tags_filter.is_a?(Hash)
|
137
|
-
tags_filter = deep_stringify_keys(Lumberjack::Utils.expand_tags(tags_filter))
|
138
|
-
end
|
139
|
-
tags = deep_stringify_keys(Lumberjack::Utils.expand_tags(entry.tags))
|
140
|
-
|
141
|
-
return false unless match_tags?(tags, tags_filter)
|
142
|
-
|
143
|
-
true
|
144
|
-
end
|
145
|
-
|
146
|
-
def match?(value, filter)
|
147
|
-
return true unless filter
|
148
|
-
filter === value
|
149
|
-
end
|
150
|
-
|
151
|
-
def match_tags?(tags, filter)
|
152
|
-
return true unless filter
|
153
|
-
return false unless tags
|
154
|
-
|
155
|
-
filter.all? do |name, value_filter|
|
156
|
-
name = name.to_s
|
157
|
-
tag_values = tags[name]
|
158
|
-
if tag_values.is_a?(Hash)
|
159
|
-
if value_filter.is_a?(Hash)
|
160
|
-
match_tags?(tag_values, value_filter)
|
161
|
-
else
|
162
|
-
false
|
163
|
-
end
|
164
|
-
elsif value_filter.nil? || (value_filter.is_a?(Enumerable) && value_filter.empty?)
|
165
|
-
tag_values.nil? || (tag_values.is_a?(Array) && tag_values.empty?)
|
166
|
-
elsif tags.include?(name)
|
167
|
-
match?(tag_values, value_filter)
|
168
|
-
else
|
169
|
-
false
|
170
|
-
end
|
171
|
-
end
|
172
|
-
end
|
173
|
-
|
174
|
-
def deep_stringify_keys(hash)
|
175
|
-
if hash.is_a?(Hash)
|
176
|
-
hash.each_with_object({}) do |(key, value), result|
|
177
|
-
new_key = key.to_s
|
178
|
-
new_value = deep_stringify_keys(value)
|
179
|
-
result[new_key] = new_value
|
180
|
-
end
|
181
|
-
elsif hash.is_a?(Enumerable)
|
182
|
-
hash.collect { |item| deep_stringify_keys(item) }
|
183
|
-
else
|
184
|
-
hash
|
185
|
-
end
|
186
|
-
end
|
187
|
-
|
188
|
-
def formatted_entry(entry)
|
189
|
-
timestamp = entry.time.strftime("%Y-%m-%d %H:%M:%S")
|
190
|
-
formatted = +"#{timestamp} #{entry.severity_label}: #{entry.message}"
|
191
|
-
formatted << "\n progname: #{entry.progname}" if entry.progname.to_s != ""
|
192
|
-
formatted << "\n pid: #{entry.pid}" if entry.pid
|
193
|
-
if entry.tags && !entry.tags.empty?
|
194
|
-
Lumberjack::Utils.flatten_tags(entry.tags).to_a.sort_by(&:first).each do |name, value|
|
195
|
-
formatted << "\n #{name}: #{value}"
|
196
|
-
end
|
197
|
-
end
|
198
|
-
formatted
|
199
|
-
end
|
200
|
-
end
|
201
|
-
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.
|
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-
|
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
|