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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +52 -12
- data/VERSION +1 -1
- data/lib/lumberjack/capture_device/include_log_entry_matcher.rb +232 -37
- data/lib/lumberjack/capture_device/rspec.rb +57 -2
- data/lib/lumberjack/capture_device.rb +136 -227
- data/lumberjack_capture_device.gemspec +2 -3
- metadata +6 -25
- data/lib/lumberjack/capture_device/entry_score.rb +0 -220
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3de3ac7d3bf941c8f6283c70f8bf55163eb36f91bea583836f296e7c5fa2cc3a
|
|
4
|
+
data.tar.gz: df6a39ef765975ff2f029d99e46aaef435c7675be707ea8effa08fc6a7369308
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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(
|
|
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?(
|
|
44
|
+
assert(logs.include?(severity: :info, message: "Something happened"))
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
You can filter the logs on
|
|
47
|
+
You can filter the logs on severity, message, progname, and attributes.
|
|
48
48
|
|
|
49
|
-
- The
|
|
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
|
|
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(
|
|
55
|
-
expect(logs).to include(
|
|
56
|
-
expect(logs).to include(
|
|
57
|
-
expect(logs).to include(
|
|
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#
|
|
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.
|
|
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
|
-
@
|
|
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
|
-
@
|
|
12
|
-
return false unless
|
|
25
|
+
@logger = actual
|
|
26
|
+
return false unless valid_logger?
|
|
13
27
|
|
|
14
|
-
|
|
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
|
|
19
|
-
formatted_failure_message(@
|
|
35
|
+
if valid_logger?
|
|
36
|
+
formatted_failure_message(@expected_hash)
|
|
20
37
|
else
|
|
21
|
-
wrong_object_type_message(@
|
|
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
|
|
27
|
-
formatted_negated_failure_message(@
|
|
46
|
+
if valid_logger?
|
|
47
|
+
formatted_negated_failure_message(@expected_hash)
|
|
28
48
|
else
|
|
29
|
-
wrong_object_type_message(@
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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::
|
|
142
|
+
"#{Lumberjack::Device::Test.formatted_expectation(expected_hash, indent: 2)}"
|
|
71
143
|
|
|
72
|
-
match =
|
|
144
|
+
match = device.match(**expected_hash)
|
|
73
145
|
if match
|
|
74
146
|
message = "#{message}\n\nFound entry:\n" \
|
|
75
|
-
"#{Lumberjack::
|
|
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 << "
|
|
84
|
-
info << "message: #{expected_hash[:message]
|
|
85
|
-
info << "progname: #{expected_hash[:progname]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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,
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
|
122
|
-
# to perform a partial or pattern match. You can also supply any matcher value
|
|
123
|
-
# in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`,
|
|
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
|
|
152
|
-
# @param
|
|
153
|
-
# will match nested
|
|
154
|
-
#
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
179
|
-
#
|
|
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
|
-
# @
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
# @param
|
|
196
|
-
# @
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
143
|
+
!!match(**filters)
|
|
144
|
+
end
|
|
210
145
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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} #{
|
|
224
|
-
|
|
225
|
-
|
|
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 << "
|
|
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} #{
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
34
|
+
spec.required_ruby_version = ">= 2.7"
|
|
35
35
|
|
|
36
|
-
spec.add_dependency "lumberjack", ">=1.
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|