lumberjack 1.4.2 → 2.0.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +524 -176
  3. data/CHANGELOG.md +89 -0
  4. data/README.md +604 -211
  5. data/UPGRADE_GUIDE.md +80 -0
  6. data/VERSION +1 -1
  7. data/lib/lumberjack/attribute_formatter.rb +451 -0
  8. data/lib/lumberjack/attributes_helper.rb +100 -0
  9. data/lib/lumberjack/context.rb +120 -23
  10. data/lib/lumberjack/context_logger.rb +620 -0
  11. data/lib/lumberjack/device/buffer.rb +209 -0
  12. data/lib/lumberjack/device/date_rolling_log_file.rb +10 -62
  13. data/lib/lumberjack/device/log_file.rb +76 -29
  14. data/lib/lumberjack/device/logger_wrapper.rb +137 -0
  15. data/lib/lumberjack/device/multi.rb +92 -30
  16. data/lib/lumberjack/device/null.rb +26 -8
  17. data/lib/lumberjack/device/size_rolling_log_file.rb +13 -54
  18. data/lib/lumberjack/device/test.rb +337 -0
  19. data/lib/lumberjack/device/writer.rb +184 -176
  20. data/lib/lumberjack/device.rb +134 -15
  21. data/lib/lumberjack/device_registry.rb +90 -0
  22. data/lib/lumberjack/entry_formatter.rb +357 -0
  23. data/lib/lumberjack/fiber_locals.rb +55 -0
  24. data/lib/lumberjack/forked_logger.rb +143 -0
  25. data/lib/lumberjack/formatter/date_time_formatter.rb +14 -3
  26. data/lib/lumberjack/formatter/exception_formatter.rb +12 -2
  27. data/lib/lumberjack/formatter/id_formatter.rb +13 -1
  28. data/lib/lumberjack/formatter/inspect_formatter.rb +14 -1
  29. data/lib/lumberjack/formatter/multiply_formatter.rb +10 -0
  30. data/lib/lumberjack/formatter/object_formatter.rb +13 -1
  31. data/lib/lumberjack/formatter/pretty_print_formatter.rb +15 -2
  32. data/lib/lumberjack/formatter/redact_formatter.rb +18 -3
  33. data/lib/lumberjack/formatter/round_formatter.rb +12 -0
  34. data/lib/lumberjack/formatter/string_formatter.rb +9 -1
  35. data/lib/lumberjack/formatter/strip_formatter.rb +13 -1
  36. data/lib/lumberjack/formatter/structured_formatter.rb +18 -2
  37. data/lib/lumberjack/formatter/tagged_message.rb +10 -32
  38. data/lib/lumberjack/formatter/tags_formatter.rb +32 -0
  39. data/lib/lumberjack/formatter/truncate_formatter.rb +8 -1
  40. data/lib/lumberjack/formatter.rb +271 -141
  41. data/lib/lumberjack/formatter_registry.rb +84 -0
  42. data/lib/lumberjack/io_compatibility.rb +133 -0
  43. data/lib/lumberjack/local_log_template.rb +209 -0
  44. data/lib/lumberjack/log_entry.rb +154 -79
  45. data/lib/lumberjack/log_entry_matcher/score.rb +276 -0
  46. data/lib/lumberjack/log_entry_matcher.rb +126 -0
  47. data/lib/lumberjack/logger.rb +328 -556
  48. data/lib/lumberjack/message_attributes.rb +38 -0
  49. data/lib/lumberjack/rack/context.rb +66 -15
  50. data/lib/lumberjack/rack.rb +0 -2
  51. data/lib/lumberjack/remap_attribute.rb +24 -0
  52. data/lib/lumberjack/severity.rb +52 -15
  53. data/lib/lumberjack/tag_context.rb +8 -71
  54. data/lib/lumberjack/tag_formatter.rb +22 -188
  55. data/lib/lumberjack/tags.rb +15 -21
  56. data/lib/lumberjack/template.rb +252 -62
  57. data/lib/lumberjack/template_registry.rb +60 -0
  58. data/lib/lumberjack/utils.rb +198 -48
  59. data/lib/lumberjack.rb +167 -59
  60. data/lumberjack.gemspec +4 -2
  61. metadata +41 -15
  62. data/lib/lumberjack/device/rolling_log_file.rb +0 -145
  63. data/lib/lumberjack/rack/request_id.rb +0 -31
  64. data/lib/lumberjack/rack/unit_of_work.rb +0 -21
  65. data/lib/lumberjack/tagged_logger_support.rb +0 -81
  66. data/lib/lumberjack/tagged_logging.rb +0 -29
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Class responsible for scoring and matching log entries against filters.
4
+ # This class provides fuzzy matching capabilities to find the best matching
5
+ # log entry when exact matches are not available.
6
+ class Lumberjack::LogEntryMatcher::Score
7
+ # Minimum score threshold for considering a match (30% match)
8
+ MIN_SCORE_THRESHOLD = 0.3
9
+
10
+ class << self
11
+ # Calculate the overall match score for an entry against all provided filters.
12
+ # Returns a score between 0.0 and 1.0, where 1.0 represents a perfect match.
13
+ #
14
+ # @param entry [Lumberjack::LogEntry] The log entry to score.
15
+ # @param message [String, Regexp, nil] The message filter to match against.
16
+ # @param severity [Integer, nil] The severity level to match against.
17
+ # @param attributes [Hash, nil] The attributes hash to match against.
18
+ # @param progname [String, nil] The program name to match against.
19
+ # @return [Float] A score between 0.0 and 1.0 indicating match quality.
20
+ def calculate_match_score(entry, message: nil, severity: nil, attributes: nil, progname: nil)
21
+ scores = []
22
+ weights = []
23
+
24
+ # Check message match
25
+ if message
26
+ message_score = calculate_field_score(entry.message, message)
27
+ scores << message_score
28
+ weights << 0.5 # Weight message matching highly
29
+ end
30
+
31
+ # Check severity match
32
+ if severity
33
+ severity_score = if entry.severity == severity
34
+ 1.0 # Exact severity match
35
+ else
36
+ severity_proximity_score(entry.severity, severity) # Partial severity match
37
+ end
38
+ scores << severity_score
39
+ weights << 0.2
40
+ end
41
+
42
+ # Check progname match
43
+ if progname
44
+ progname_score = calculate_field_score(entry.progname, progname)
45
+ scores << progname_score
46
+ weights << 0.2
47
+ end
48
+
49
+ # Check attributes match
50
+ if attributes.is_a?(Hash) && !attributes.empty?
51
+ attributes_score = calculate_attributes_score(entry.attributes, attributes)
52
+ scores << attributes_score
53
+ weights << 0.3
54
+ end
55
+
56
+ # Return 0 if no criteria were provided
57
+ return 0.0 if scores.empty?
58
+
59
+ # Calculate weighted average, but apply a penalty if any score is 0
60
+ # This ensures that completely failed criteria significantly impact the result
61
+ total_weighted_score = scores.zip(weights).map { |score, weight| score * weight }.sum
62
+ total_weight = weights.sum
63
+ base_score = total_weighted_score / total_weight
64
+
65
+ # Apply penalty for zero scores: reduce the score based on how many criteria completely failed
66
+ zero_scores = scores.count(0.0)
67
+ if zero_scores > 0
68
+ penalty_factor = 1.0 - (zero_scores.to_f / scores.length * 0.5) # Up to 50% penalty
69
+ base_score *= penalty_factor
70
+ end
71
+
72
+ base_score
73
+ end
74
+
75
+ # Calculate score for any field value against a filter.
76
+ # Returns a score between 0.0 and 1.0 based on how well the value matches the filter.
77
+ #
78
+ # @param value [Object] The value to match against the filter.
79
+ # @param filter [String, Regexp, Object] The filter to match the value against.
80
+ # @return [Float] A score between 0.0 and 1.0 indicating match quality.
81
+ def calculate_field_score(value, filter)
82
+ return 0.0 unless value && filter
83
+
84
+ case filter
85
+ when String
86
+ value_str = value.to_s
87
+ if value_str == filter
88
+ 1.0
89
+ elsif value_str.include?(filter)
90
+ 0.7
91
+ else
92
+ # Use string similarity for partial matching
93
+ similarity = string_similarity(value_str, filter)
94
+ (similarity > 0.5) ? similarity * 0.6 : 0.0
95
+ end
96
+ when Regexp
97
+ filter.match?(value.to_s) ? 1.0 : 0.0
98
+ else
99
+ # For other matchers (like RSpec matchers), try to use === operator
100
+ begin
101
+ (filter === value) ? 1.0 : 0.0
102
+ rescue
103
+ 0.0
104
+ end
105
+ end
106
+ end
107
+
108
+ # Calculate proximity score based on log severity distance.
109
+ # Provides partial scoring for severities that are close to the target.
110
+ #
111
+ # @param entry_severity [Integer] The severity level of the log entry.
112
+ # @param filter_severity [Integer] The target severity level to match.
113
+ # @return [Float] A score between 0.0 and 1.0 based on severity proximity.
114
+ def severity_proximity_score(entry_severity, filter_severity)
115
+ severity_diff = (entry_severity - filter_severity).abs
116
+ case severity_diff
117
+ when 0 then 1.0
118
+ when 1 then 0.7
119
+ when 2 then 0.4
120
+ else 0.0
121
+ end
122
+ end
123
+
124
+ # Calculate score for attribute matching.
125
+ # Compares entry attributes against filter attributes and returns a score
126
+ # based on how many attributes match.
127
+ #
128
+ # @param entry_attributes [Hash] The attributes from the log entry.
129
+ # @param attributes_filter [Hash] The attributes filter to match against.
130
+ # @return [Float] A score between 0.0 and 1.0 based on attribute matches.
131
+ def calculate_attributes_score(entry_attributes, attributes_filter)
132
+ return 0.0 unless entry_attributes && attributes_filter.is_a?(Hash)
133
+
134
+ attributes_filter = deep_stringify_keys(Lumberjack::Utils.expand_attributes(attributes_filter))
135
+ attributes = deep_stringify_keys(Lumberjack::Utils.expand_attributes(entry_attributes))
136
+
137
+ total_attribute_filters = count_attribute_filters(attributes_filter)
138
+ return 0.0 if total_attribute_filters == 0
139
+
140
+ matched_attributes = count_matched_attributes(attributes, attributes_filter)
141
+ matched_attributes.to_f / total_attribute_filters
142
+ end
143
+
144
+ private
145
+
146
+ # Calculate string similarity using a simple Levenshtein distance-based approach.
147
+ # Returns a score between 0.0 and 1.0 where 1.0 is an exact match.
148
+ #
149
+ # @param str1 [String] The first string to compare.
150
+ # @param str2 [String] The second string to compare.
151
+ # @return [Float] A similarity score between 0.0 and 1.0.
152
+ def string_similarity(str1, str2)
153
+ return 1.0 if str1 == str2
154
+ return 0.0 if str1.nil? || str2.nil? || str1.empty? || str2.empty?
155
+
156
+ # Convert to lowercase for case-insensitive comparison
157
+ s1 = str1.downcase
158
+ s2 = str2.downcase
159
+
160
+ # If one string contains the other, give it a good score
161
+ if s1.include?(s2) || s2.include?(s1)
162
+ shorter = [s1.length, s2.length].min
163
+ longer = [s1.length, s2.length].max
164
+ return shorter.to_f / longer * 0.8 + 0.2 # Boost score for containment
165
+ end
166
+
167
+ # Calculate Levenshtein distance
168
+ distance = levenshtein_distance(s1, s2)
169
+ max_length = [s1.length, s2.length].max
170
+
171
+ # Convert distance to similarity score
172
+ return 0.0 if max_length == 0
173
+
174
+ 1.0 - (distance.to_f / max_length)
175
+ end
176
+
177
+ # Simple Levenshtein distance implementation.
178
+ # Calculates the minimum number of single-character edits needed
179
+ # to change one string into another.
180
+ #
181
+ # @param str1 [String] The first string.
182
+ # @param str2 [String] The second string.
183
+ # @return [Integer] The Levenshtein distance between the strings.
184
+ def levenshtein_distance(str1, str2)
185
+ return str2.length if str1.empty?
186
+ return str1.length if str2.empty?
187
+
188
+ matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
189
+
190
+ # Initialize first row and column
191
+ (0..str1.length).each { |i| matrix[i][0] = i }
192
+ (0..str2.length).each { |j| matrix[0][j] = j }
193
+
194
+ # Fill the matrix
195
+ (1..str1.length).each do |i|
196
+ (1..str2.length).each do |j|
197
+ cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
198
+ matrix[i][j] = [
199
+ matrix[i - 1][j] + 1, # deletion
200
+ matrix[i][j - 1] + 1, # insertion
201
+ matrix[i - 1][j - 1] + cost # substitution
202
+ ].min
203
+ end
204
+ end
205
+
206
+ matrix[str1.length][str2.length]
207
+ end
208
+
209
+ # Count the total number of attribute filters in a nested hash structure.
210
+ #
211
+ # @param attributes_filter [Hash] The attributes filter hash to count.
212
+ # @param count [Integer] The current count (used for recursion).
213
+ # @return [Integer] The total number of filters.
214
+ def count_attribute_filters(attributes_filter, count = 0)
215
+ attributes_filter.each do |_name, value_filter|
216
+ if value_filter.is_a?(Hash)
217
+ count = count_attribute_filters(value_filter, count)
218
+ else
219
+ count += 1
220
+ end
221
+ end
222
+ count
223
+ end
224
+
225
+ # Count the number of matched attributes in a nested structure.
226
+ #
227
+ # @param attributes [Hash] The log entry attributes to check.
228
+ # @param attributes_filter [Hash] The filter attributes to match against.
229
+ # @param count [Integer] The current count (used for recursion).
230
+ # @return [Integer] The number of matched attributes.
231
+ def count_matched_attributes(attributes, attributes_filter, count = 0)
232
+ return count unless attributes && attributes_filter
233
+
234
+ attributes_filter.each do |name, value_filter|
235
+ name = name.to_s
236
+ attribute_values = attributes[name]
237
+
238
+ if value_filter.is_a?(Hash) && attribute_values.is_a?(Hash)
239
+ count = count_matched_attributes(attribute_values, value_filter, count)
240
+ elsif attributes.include?(name) && exact_match?(attribute_values, value_filter)
241
+ count += 1
242
+ end
243
+ end
244
+ count
245
+ end
246
+
247
+ # Check if a value exactly matches the filter using the === operator.
248
+ #
249
+ # @param value [Object] The value to match.
250
+ # @param filter [Object] The filter to match against.
251
+ # @return [Boolean] True if the value matches the filter.
252
+ def exact_match?(value, filter)
253
+ return true unless filter
254
+
255
+ filter === value
256
+ end
257
+
258
+ # Recursively convert all keys in a hash structure to strings.
259
+ #
260
+ # @param hash [Hash, Object] The hash to stringify or other object to return as-is.
261
+ # @return [Hash, Object] The hash with string keys or the original object.
262
+ def deep_stringify_keys(hash)
263
+ if hash.is_a?(Hash)
264
+ hash.each_with_object({}) do |(key, value), result|
265
+ new_key = key.to_s
266
+ new_value = deep_stringify_keys(value)
267
+ result[new_key] = new_value
268
+ end
269
+ elsif hash.is_a?(Enumerable)
270
+ hash.collect { |item| deep_stringify_keys(item) }
271
+ else
272
+ hash
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lumberjack
4
+ # A flexible matching utility for testing and filtering log entries based on
5
+ # multiple criteria. This class provides pattern-based matching against log
6
+ # entry components including message content, severity levels, program names,
7
+ # and custom attributes with support for nested attribute structures.
8
+ #
9
+ # The matcher uses Ruby's case equality operator (===) for flexible matching,
10
+ # supporting exact values, regular expressions, ranges, classes, and other
11
+ # pattern matching constructs. It's primarily designed for use with the Test
12
+ # device in testing scenarios but can be used anywhere log entry filtering
13
+ # is needed.
14
+ #
15
+ # @see Lumberjack::Device::Test
16
+ class LogEntryMatcher
17
+ require_relative "log_entry_matcher/score"
18
+
19
+ # Create a new log entry matcher with optional filtering criteria. All
20
+ # parameters are optional and nil values indicate no filtering for that
21
+ # component. The matcher uses case equality (===) for flexible pattern
22
+ # matching against each specified criterion.
23
+ #
24
+ # @param message [Object, nil] Pattern to match against log entry messages.
25
+ # Supports strings, regular expressions, or any object responding to ===
26
+ # @param severity [Integer, String, Symbol, nil] Severity level to match.
27
+ # Accepts numeric levels or symbolic names (:debug, :info, etc.)
28
+ # @param progname [Object, nil] Pattern to match against program names.
29
+ # Supports strings, regular expressions, or any object responding to ===
30
+ # @param attributes [Hash, nil] Hash of attribute patterns to match against
31
+ # log entry attributes. Supports nested attribute matching and dot notation
32
+ def initialize(message: nil, severity: nil, progname: nil, attributes: nil)
33
+ message = message.strip if message.is_a?(String)
34
+ @message_filter = message
35
+ @severity_filter = Severity.coerce(severity) if severity
36
+ @progname_filter = progname
37
+ @attributes_filter = Utils.expand_attributes(attributes) if attributes
38
+ end
39
+
40
+ # Test whether a log entry matches all specified criteria. The entry must
41
+ # satisfy all non-nil filter conditions to be considered a match. Uses
42
+ # case equality (===) for flexible pattern matching.
43
+ #
44
+ # @param entry [Lumberjack::LogEntry] The log entry to test against the matcher
45
+ # @return [Boolean] True if the entry matches all specified criteria, false otherwise
46
+ def match?(entry)
47
+ return false unless match_filter?(entry.message, @message_filter)
48
+ return false unless match_filter?(entry.severity, @severity_filter)
49
+ return false unless match_filter?(entry.progname, @progname_filter)
50
+
51
+ if @attributes_filter
52
+ attributes = Utils.expand_attributes(entry.attributes)
53
+ return false unless match_attributes?(attributes, @attributes_filter)
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ # Find the closest matching log entry from a list of candidates. This method
60
+ # scores each entry based on how well it matches the specified criteria and
61
+ # returns the entry with the highest score, provided it meets a minimum
62
+ # threshold. If no entries meet the threshold, nil is returned.
63
+ #
64
+ # @param entries [Array<Lumberjack::LogEntry>] The list of log entries to evaluate
65
+ # @return [Lumberjack::LogEntry, nil] The closest matching log entry or nil if none match
66
+ def closest(entries)
67
+ scored_entries = entries.map { |entry| [entry, entry_score(entry)] }
68
+ best_score = scored_entries.max_by { |_, score| score }
69
+ (best_score&.last.to_f >= Score::MIN_SCORE_THRESHOLD) ? best_score.first : nil
70
+ end
71
+
72
+ private
73
+
74
+ def entry_score(entry)
75
+ Score.calculate_match_score(
76
+ entry,
77
+ message: @message_filter,
78
+ severity: @severity_filter,
79
+ attributes: @attributes_filter,
80
+ progname: @progname_filter
81
+ )
82
+ end
83
+
84
+ # Apply a filter pattern against a value using case equality. Returns true
85
+ # if no filter is specified (nil) or if the filter matches the value.
86
+ #
87
+ # @param value [Object] The value to test against the filter
88
+ # @param filter [Object, nil] The filter pattern, nil means no filtering
89
+ # @return [Boolean] True if the filter matches or is nil, false otherwise
90
+ def match_filter?(value, filter)
91
+ return true if filter.nil?
92
+
93
+ filter === value
94
+ end
95
+
96
+ # Recursively match nested attribute structures against filter patterns.
97
+ # Handles both simple attribute matching and complex nested hash structures
98
+ # with support for partial matching and empty value detection.
99
+ #
100
+ # @param attributes [Hash] The expanded attributes hash from the log entry
101
+ # @param filter [Hash] The filter patterns to match against attributes
102
+ # @return [Boolean] True if all filter patterns match their corresponding attributes
103
+ def match_attributes?(attributes, filter)
104
+ return true unless filter
105
+ return false unless attributes
106
+
107
+ filter.all? do |name, value_filter|
108
+ name = name.to_s
109
+ attribute_values = attributes[name]
110
+ if attribute_values.is_a?(Hash)
111
+ if value_filter.is_a?(Hash)
112
+ match_attributes?(attribute_values, value_filter)
113
+ else
114
+ match_filter?(attribute_values, value_filter)
115
+ end
116
+ elsif value_filter.nil? || (value_filter.is_a?(Enumerable) && value_filter.empty?)
117
+ attribute_values.nil? || (attribute_values.is_a?(Array) && attribute_values.empty?)
118
+ elsif attributes.include?(name)
119
+ match_filter?(attribute_values, value_filter)
120
+ else
121
+ false
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end