semantic_logger 4.18.0 → 5.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +42 -81
  3. data/Rakefile +10 -3
  4. data/lib/semantic_logger/appender/async.rb +86 -173
  5. data/lib/semantic_logger/appender/cloudwatch_logs.rb +4 -4
  6. data/lib/semantic_logger/appender/elasticsearch.rb +6 -182
  7. data/lib/semantic_logger/appender/elasticsearch_base.rb +212 -0
  8. data/lib/semantic_logger/appender/elasticsearch_http.rb +2 -2
  9. data/lib/semantic_logger/appender/file.rb +20 -4
  10. data/lib/semantic_logger/appender/graylog.rb +2 -2
  11. data/lib/semantic_logger/appender/http.rb +27 -2
  12. data/lib/semantic_logger/appender/io.rb +8 -4
  13. data/lib/semantic_logger/appender/kafka.rb +2 -2
  14. data/lib/semantic_logger/appender/loki.rb +2 -4
  15. data/lib/semantic_logger/appender/mongodb.rb +3 -6
  16. data/lib/semantic_logger/appender/new_relic_logs.rb +2 -2
  17. data/lib/semantic_logger/appender/open_telemetry.rb +8 -6
  18. data/lib/semantic_logger/appender/opensearch.rb +35 -0
  19. data/lib/semantic_logger/appender/rabbitmq.rb +3 -3
  20. data/lib/semantic_logger/appender/sentry_ruby.rb +2 -2
  21. data/lib/semantic_logger/appender/splunk.rb +2 -2
  22. data/lib/semantic_logger/appender/splunk_http.rb +3 -3
  23. data/lib/semantic_logger/appender/syslog.rb +5 -4
  24. data/lib/semantic_logger/appender/tcp.rb +2 -2
  25. data/lib/semantic_logger/appender/udp.rb +2 -2
  26. data/lib/semantic_logger/appender/wrapper.rb +4 -4
  27. data/lib/semantic_logger/appender.rb +30 -19
  28. data/lib/semantic_logger/appenders.rb +26 -5
  29. data/lib/semantic_logger/base.rb +100 -21
  30. data/lib/semantic_logger/concerns/compatibility.rb +2 -2
  31. data/lib/semantic_logger/core_ext/process.rb +34 -0
  32. data/lib/semantic_logger/formatters/base.rb +46 -7
  33. data/lib/semantic_logger/formatters/color.rb +6 -3
  34. data/lib/semantic_logger/formatters/default.rb +6 -4
  35. data/lib/semantic_logger/formatters/ecs.rb +151 -0
  36. data/lib/semantic_logger/formatters/fluentd.rb +15 -4
  37. data/lib/semantic_logger/formatters/json.rb +6 -1
  38. data/lib/semantic_logger/formatters/logfmt.rb +2 -2
  39. data/lib/semantic_logger/formatters/loki.rb +4 -4
  40. data/lib/semantic_logger/formatters/open_telemetry.rb +15 -5
  41. data/lib/semantic_logger/formatters/pattern.rb +235 -0
  42. data/lib/semantic_logger/formatters/raw.rb +2 -2
  43. data/lib/semantic_logger/formatters/signalfx.rb +2 -2
  44. data/lib/semantic_logger/formatters/syslog.rb +14 -3
  45. data/lib/semantic_logger/formatters/syslog_cee.rb +1 -1
  46. data/lib/semantic_logger/formatters.rb +2 -0
  47. data/lib/semantic_logger/log.rb +18 -4
  48. data/lib/semantic_logger/logger.rb +2 -2
  49. data/lib/semantic_logger/metric/new_relic.rb +2 -2
  50. data/lib/semantic_logger/metric/signalfx.rb +2 -2
  51. data/lib/semantic_logger/metric/statsd.rb +2 -2
  52. data/lib/semantic_logger/processor.rb +21 -0
  53. data/lib/semantic_logger/queue_processor.rb +369 -0
  54. data/lib/semantic_logger/reporters/minitest.rb +4 -4
  55. data/lib/semantic_logger/semantic_logger.rb +103 -11
  56. data/lib/semantic_logger/subscriber.rb +15 -2
  57. data/lib/semantic_logger/sync_processor.rb +25 -3
  58. data/lib/semantic_logger/test/capture_log_events.rb +2 -2
  59. data/lib/semantic_logger/test/minitest.rb +8 -4
  60. data/lib/semantic_logger/test/rspec.rb +249 -0
  61. data/lib/semantic_logger/utils.rb +83 -4
  62. data/lib/semantic_logger/version.rb +1 -1
  63. data/lib/semantic_logger.rb +9 -0
  64. metadata +17 -8
  65. data/lib/semantic_logger/appender/async_batch.rb +0 -93
@@ -4,9 +4,42 @@ module SemanticLogger
4
4
  # Logging levels in order of most detailed to most severe
5
5
  LEVELS = Levels::LEVELS
6
6
 
7
- # Return a logger for the supplied class or class_name
7
+ # Return a logger for the supplied class or class_name.
8
+ #
9
+ # When `SemanticLogger.cache_loggers` is enabled (opt-in, default off) and a
10
+ # Class or Module is supplied, the same Logger instance is returned for every
11
+ # call with that class. This makes it possible to obtain a logger once and
12
+ # later change its level (or filter) and have every holder of that logger see
13
+ # the change.
14
+ #
15
+ # A String is always given its own new Logger instance, even when caching is
16
+ # enabled: callers that pass a string typically want an independent logger
17
+ # (for example to set a different level per call site). Anonymous classes
18
+ # (those with no `name`) are never cached, to avoid pinning short-lived
19
+ # dynamically created classes in memory.
8
20
  def self.[](klass)
9
- Logger.new(klass)
21
+ return Logger.new(klass) if !@cache_loggers || klass.is_a?(String) || klass.name.nil?
22
+
23
+ logger_cache.compute_if_absent(klass) { Logger.new(klass) }
24
+ end
25
+
26
+ # Whether `SemanticLogger[Class]` returns a shared, cached Logger instance per
27
+ # class. Disabled by default. Strings are never cached (see #[]).
28
+ def self.cache_loggers=(cache_loggers)
29
+ @cache_loggers = cache_loggers
30
+ clear_logger_cache unless cache_loggers
31
+ end
32
+
33
+ # Returns whether logger caching is enabled.
34
+ def self.cache_loggers?
35
+ @cache_loggers
36
+ end
37
+
38
+ # Discard all cached loggers so that subsequent `SemanticLogger[Class]` calls
39
+ # build fresh instances. Primarily useful in tests, or after redefining a
40
+ # class that was previously cached.
41
+ def self.clear_logger_cache
42
+ @logger_cache&.clear
10
43
  end
11
44
 
12
45
  # Sets the global default log level
@@ -162,8 +195,8 @@ module SemanticLogger
162
195
  # logger = SemanticLogger['Example']
163
196
  # logger.info "Hello World"
164
197
  # logger.debug("Login time", user: 'Joe', duration: 100, ip_address: '127.0.0.1')
165
- def self.add_appender(**args, &block)
166
- appender = appenders.add(**args, &block)
198
+ def self.add_appender(**args, &)
199
+ appender = appenders.add(**args, &)
167
200
  # Start appender thread if it is not already running
168
201
  Logger.processor.start
169
202
  appender
@@ -202,14 +235,43 @@ module SemanticLogger
202
235
  Logger.processor.close
203
236
  end
204
237
 
205
- # After forking an active process call SemanticLogger.reopen to re-open
206
- # any open file handles etc to resources.
238
+ # Re-open any open file handles etc. to resources.
239
+ #
240
+ # Called automatically in the child process after a fork (see reopen_on_fork?),
241
+ # and may also be called manually.
242
+ #
243
+ # To avoid reopening twice after a single fork (for example when the automatic
244
+ # fork hook and a framework's `after_fork` callback both fire), reopen is a no-op
245
+ # if it has already run in the current process. Pass `force: true` to override
246
+ # this guard and reopen unconditionally, for example when re-opening file handles
247
+ # in the same process after an external log rotation.
207
248
  #
208
249
  # Note:
209
250
  # Not all appender's implement reopen.
210
251
  # Check the code for each appender you are using before relying on this behavior.
211
- def self.reopen
252
+ def self.reopen(force: false)
253
+ return if !force && @reopened_pid == ::Process.pid
254
+
212
255
  Logger.processor.reopen
256
+ @reopened_pid = ::Process.pid
257
+ end
258
+
259
+ # Whether appenders are automatically reopened in the child process after a fork.
260
+ #
261
+ # Enabled by default. A `Process._fork` hook (Ruby 3.1+) calls SemanticLogger.reopen
262
+ # in the child after `fork`, `Process.daemon`, `IO.popen`, `Kernel#system`, and
263
+ # backticks, so framework specific `after_fork` hooks (Puma, Unicorn, Resque,
264
+ # Spring, etc.) are no longer required.
265
+ def self.reopen_on_fork?
266
+ @reopen_on_fork != false
267
+ end
268
+
269
+ # Enable or disable automatic reopening of appenders after a fork.
270
+ #
271
+ # # Opt out of the automatic behavior and manage reopen manually:
272
+ # SemanticLogger.reopen_on_fork = false
273
+ def self.reopen_on_fork=(reopen_on_fork)
274
+ @reopen_on_fork = reopen_on_fork
213
275
  end
214
276
 
215
277
  # Supply a callback to be called whenever a log entry is created.
@@ -236,8 +298,8 @@ module SemanticLogger
236
298
  # Note:
237
299
  # * This callback is called within the thread of the application making the logging call.
238
300
  # * If these callbacks are slow they will slow down the application.
239
- def self.on_log(object = nil, &block)
240
- Logger.subscribe(object, &block)
301
+ def self.on_log(object = nil, &)
302
+ Logger.subscribe(object, &)
241
303
  end
242
304
 
243
305
  # Add signal handlers for Semantic Logger
@@ -342,13 +404,13 @@ module SemanticLogger
342
404
  # `logger.tagged([['first', nil], nil, ['more'], 'other'])`
343
405
  # to the equivalent of:
344
406
  # `logger.tagged('first', 'more', 'other')`
345
- def self.tagged(*tags, &block)
407
+ def self.tagged(*tags, &)
346
408
  return yield if tags.empty?
347
409
 
348
410
  # Allow named tags to be passed into the logger
349
411
  if tags.size == 1
350
412
  tag = tags[0]
351
- return tag.is_a?(Hash) ? named_tagged(tag, &block) : fast_tag(tag, &block)
413
+ return tag.is_a?(Hash) ? named_tagged(tag, &) : fast_tag(tag, &)
352
414
  end
353
415
 
354
416
  begin
@@ -475,6 +537,27 @@ module SemanticLogger
475
537
  Logger.processor.queue.size
476
538
  end
477
539
 
540
+ # Returns [Hash] operational statistics for the logging pipeline.
541
+ #
542
+ # Useful for exporting Semantic Logger's own health to a monitoring system such as
543
+ # Prometheus, statsd, etc. The returned Hash contains:
544
+ #
545
+ # queue_size: [Integer] Number of log messages waiting on the main pipeline queue.
546
+ # capped: [Boolean] Whether the main queue has a maximum size.
547
+ # max_queue_size: [Integer] Maximum queue size, or nil when uncapped.
548
+ # thread_active: [Boolean] Whether the main pipeline thread is running.
549
+ # processed: [Integer] Cumulative number of log messages processed since startup.
550
+ # dropped: [Integer] Cumulative number of log messages dropped at the main queue.
551
+ # appenders: [Array<Hash>] Per-appender statistics. Appenders that run their own
552
+ # async thread report their queue_size and processed/dropped
553
+ # counts; appenders that log inline report `async: false`.
554
+ #
555
+ # All counters are cumulative since process startup. They are thread-safe to read and
556
+ # are maintained without adding any locking to the logging hot path.
557
+ def self.stats
558
+ Logger.processor.stats
559
+ end
560
+
478
561
  # Returns the check_interval which is the number of messages between checks
479
562
  # to determine if the appender thread is falling behind.
480
563
  def self.lag_check_interval
@@ -516,6 +599,14 @@ module SemanticLogger
516
599
  @backtrace_level = :error
517
600
  @backtrace_level_index = Levels.index(@backtrace_level)
518
601
  @sync = false
602
+ @cache_loggers = false
603
+ @logger_cache = nil
604
+
605
+ # Lazily initialized thread-safe cache of one Logger per Class/Module.
606
+ def self.logger_cache
607
+ @logger_cache ||= Concurrent::Map.new
608
+ end
609
+ private_class_method :logger_cache
519
610
 
520
611
  # @formatter:off
521
612
  module Metric
@@ -531,6 +622,7 @@ module SemanticLogger
531
622
  module Test
532
623
  autoload :CaptureLogEvents, "semantic_logger/test/capture_log_events"
533
624
  autoload :Minitest, "semantic_logger/test/minitest"
625
+ autoload :RSpec, "semantic_logger/test/rspec"
534
626
  end
535
627
 
536
628
  if defined?(JRuby)
@@ -73,9 +73,22 @@ module SemanticLogger
73
73
  super && (log.metric_only? ? metrics? : true)
74
74
  end
75
75
 
76
- # Whether this appender is logging to stdout or stderror
76
+ # The console stream this appender writes to, if any.
77
+ # Returns one of :stdout, :stderr, or nil when not writing to a console stream.
78
+ def console_stream
79
+ nil
80
+ end
81
+
82
+ # Whether this appender is logging to stdout or stderr.
77
83
  def console_output?
78
- false
84
+ !console_stream.nil?
85
+ end
86
+
87
+ # Whether an appender that implements #batch should be wrapped in a batch
88
+ # proxy automatically. Appenders that support batching but should only batch
89
+ # when explicitly requested (via `batch: true`) override this to return false.
90
+ def batch_by_default?
91
+ true
79
92
  end
80
93
 
81
94
  private
@@ -9,7 +9,28 @@ module SemanticLogger
9
9
  end
10
10
 
11
11
  def log(...)
12
- @monitor.synchronize { @appenders.log(...) }
12
+ @monitor.synchronize do
13
+ @processed_count += 1
14
+ @appenders.log(...)
15
+ end
16
+ end
17
+
18
+ # Returns [Hash] operational statistics for the logging pipeline.
19
+ #
20
+ # In synchronous mode there is no queue: messages are written inline on the calling
21
+ # thread, so queue_size is always 0 and no messages can be dropped.
22
+ def stats
23
+ @monitor.synchronize do
24
+ {
25
+ queue_size: 0,
26
+ capped: false,
27
+ max_queue_size: nil,
28
+ thread_active: false,
29
+ processed: @processed_count,
30
+ dropped: 0,
31
+ appenders: @appenders.stats
32
+ }
33
+ end
13
34
  end
14
35
 
15
36
  def flush
@@ -47,8 +68,9 @@ module SemanticLogger
47
68
  attr_reader :appenders
48
69
 
49
70
  def initialize(appenders = nil)
50
- @monitor = Monitor.new
51
- @appenders = appenders || Appenders.new(self.class.logger.dup)
71
+ @monitor = Monitor.new
72
+ @appenders = appenders || Appenders.new(self.class.logger.dup)
73
+ @processed_count = 0
52
74
  end
53
75
 
54
76
  def start
@@ -34,8 +34,8 @@ module SemanticLogger
34
34
  end
35
35
 
36
36
  # Supports batching of log events
37
- def batch(_logs)
38
- @events += log
37
+ def batch(logs)
38
+ logs.each { |log| self.log(log) }
39
39
  end
40
40
 
41
41
  def clear
@@ -57,15 +57,17 @@ module SemanticLogger
57
57
  if payload_includes
58
58
  payload_includes.each_pair do |key, expected|
59
59
  actual = event.payload[key]
60
- assert_semantic_logger_entry(event, "payload #{name}", expected, actual)
60
+
61
+ assert_semantic_logger_entry(event, "payload #{key}", expected, actual)
61
62
  end
62
63
  end
63
64
 
64
65
  return unless exception_includes
65
66
 
66
- payload_includes.each_pair do |key, expected|
67
+ exception_includes.each_pair do |key, expected|
67
68
  actual = event.exception.send(key)
68
- assert_semantic_logger_entry(event, "Exception #{name}", expected, actual)
69
+
70
+ assert_semantic_logger_entry(event, "exception #{key}", expected, actual)
69
71
  end
70
72
  end
71
73
 
@@ -76,9 +78,11 @@ module SemanticLogger
76
78
 
77
79
  case expected
78
80
  when :nil
81
+
79
82
  assert_nil actual, "Expected nil #{name} for log event: #{event.to_h.inspect}"
80
83
  when Class
81
- assert actual.is_a?(expected), lambda {
84
+
85
+ assert_kind_of expected, actual, lambda {
82
86
  "Type #{expected} expected for #{name} in log event: #{event.to_h.inspect}"
83
87
  }
84
88
  else
@@ -0,0 +1,249 @@
1
+ require "rspec/expectations"
2
+
3
+ module SemanticLogger
4
+ module Test
5
+ # RSpec matchers and helpers for asserting on Semantic Logger events.
6
+ #
7
+ # These mirror the Minitest helpers in SemanticLogger::Test::Minitest.
8
+ #
9
+ # Enable them once, in spec_helper.rb:
10
+ #
11
+ # require "semantic_logger/test/rspec"
12
+ #
13
+ # RSpec.configure do |config|
14
+ # config.include SemanticLogger::Test::RSpec
15
+ # end
16
+ #
17
+ # Capture the events emitted whilst running a block, then assert on them:
18
+ #
19
+ # events = capture_semantic_logger_events { User.new.enable! }
20
+ # expect(events.first).to be_a_semantic_logger_event(level: :info, message: "User enabled")
21
+ #
22
+ # Or assert directly that a block logs a matching event:
23
+ #
24
+ # expect { User.new.enable! }.to(
25
+ # log_semantic_logger_event(level: :info, message: "User enabled")
26
+ # )
27
+ module RSpec
28
+ # Attributes that may be asserted on a single log event. The keys map
29
+ # directly onto SemanticLogger::Log attributes, except for the
30
+ # *_includes variants which assert a partial (substring / subset) match.
31
+ EVENT_ATTRIBUTES = %i[
32
+ level name message thread_name tags named_tags context
33
+ metric metric_amount dimensions level_index duration time
34
+ exception backtrace payload
35
+ ].freeze
36
+
37
+ # Matches a single SemanticLogger::Log against a set of expectations.
38
+ #
39
+ # An expected value may be:
40
+ # * a Class - the actual value must be a kind_of that class
41
+ # * :nil - the actual value must be nil
42
+ # * any value - compared with ==
43
+ class EventMatcher
44
+ include ::RSpec::Matchers::Composable
45
+
46
+ def initialize(message_includes: nil, payload_includes: nil, exception_includes: nil, **expected)
47
+ unknown = expected.keys - EVENT_ATTRIBUTES
48
+ raise ArgumentError, "Unknown log event attribute(s): #{unknown.join(', ')}" unless unknown.empty?
49
+
50
+ @expected = expected
51
+ @message_includes = message_includes
52
+ @payload_includes = payload_includes
53
+ @exception_includes = exception_includes
54
+ end
55
+
56
+ def matches?(event)
57
+ @event = event
58
+ return failed("no log event") unless event
59
+
60
+ @expected.each_pair do |name, expected|
61
+ actual = event.public_send(name)
62
+ return failed(mismatch(name, expected, actual)) unless attribute_matches?(expected, actual)
63
+ end
64
+
65
+ return failed(includes_failure(:message, @message_includes)) unless message_includes_matches?
66
+ return failed(includes_failure(:payload, @payload_includes)) unless payload_includes_matches?
67
+ return failed(includes_failure(:exception, @exception_includes)) unless exception_includes_matches?
68
+
69
+ true
70
+ end
71
+
72
+ def description
73
+ "be a semantic logger event matching #{full_expectations.inspect}"
74
+ end
75
+
76
+ def failure_message
77
+ "expected log event to #{description}, but #{@failure}.\n" \
78
+ "Log event: #{event_inspect}"
79
+ end
80
+
81
+ def failure_message_when_negated
82
+ "expected log event not to #{description}.\nLog event: #{event_inspect}"
83
+ end
84
+
85
+ private
86
+
87
+ def failed(reason)
88
+ @failure = reason
89
+ false
90
+ end
91
+
92
+ def attribute_matches?(expected, actual)
93
+ case expected
94
+ when :nil
95
+ actual.nil?
96
+ when Class
97
+ actual.is_a?(expected)
98
+ else
99
+ expected == actual
100
+ end
101
+ end
102
+
103
+ def message_includes_matches?
104
+ return true unless @message_includes
105
+
106
+ @event.message&.include?(@message_includes)
107
+ end
108
+
109
+ def payload_includes_matches?
110
+ return true unless @payload_includes
111
+
112
+ payload = @event.payload || {}
113
+ @payload_includes.all? { |key, value| payload[key] == value }
114
+ end
115
+
116
+ def exception_includes_matches?
117
+ return true unless @exception_includes
118
+
119
+ exception = @event.exception
120
+ return false unless exception
121
+
122
+ @exception_includes.all? { |key, value| exception.public_send(key) == value }
123
+ end
124
+
125
+ def mismatch(name, expected, actual)
126
+ "#{name} was #{actual.inspect} (expected #{expected.inspect})"
127
+ end
128
+
129
+ def includes_failure(name, expected)
130
+ "#{name} did not include #{expected.inspect}"
131
+ end
132
+
133
+ def full_expectations
134
+ @expected.merge(
135
+ {
136
+ message_includes: @message_includes,
137
+ payload_includes: @payload_includes,
138
+ exception_includes: @exception_includes
139
+ }.compact
140
+ )
141
+ end
142
+
143
+ def event_inspect
144
+ @event.respond_to?(:to_h) ? @event.to_h.inspect : @event.inspect
145
+ end
146
+ end
147
+
148
+ # Matches a block, asserting that it emits at least one log event that
149
+ # matches the supplied expectations.
150
+ class LogEventMatcher
151
+ include ::RSpec::Matchers::Composable
152
+
153
+ def initialize(capture, on:, expected:)
154
+ @capture = capture
155
+ @on = on
156
+ @matcher = EventMatcher.new(**expected)
157
+ end
158
+
159
+ def matches?(block)
160
+ @events = @capture.call(@on, &block)
161
+ @events.any? { |event| @matcher.matches?(event) }
162
+ end
163
+
164
+ def supports_block_expectations?
165
+ true
166
+ end
167
+
168
+ def description
169
+ "log a semantic logger event matching #{@matcher.description}"
170
+ end
171
+
172
+ def failure_message
173
+ "expected the block to #{description}.\n" \
174
+ "Captured #{@events.size} event(s):\n#{captured_inspect}"
175
+ end
176
+
177
+ def failure_message_when_negated
178
+ "expected the block not to #{description}, but it did.\n" \
179
+ "Captured #{@events.size} event(s):\n#{captured_inspect}"
180
+ end
181
+
182
+ private
183
+
184
+ def captured_inspect
185
+ @events.map { |event| " #{event.to_h.inspect}" }.join("\n")
186
+ end
187
+ end
188
+
189
+ # Returns [Array<SemanticLogger::Log>] the log events captured whilst
190
+ # running the supplied block.
191
+ #
192
+ # Notes:
193
+ # - All log events are captured regardless of the global default log level.
194
+ # - Pass a class to capture only events logged through that class's logger.
195
+ # Otherwise every log event in the process is captured for the duration
196
+ # of the block.
197
+ def capture_semantic_logger_events(klass = nil, silence: :trace, &block)
198
+ logger = SemanticLogger::Test::CaptureLogEvents.new
199
+
200
+ if klass
201
+ allow(klass).to receive(:logger).and_return(logger)
202
+ block.call
203
+ elsif silence
204
+ SemanticLogger.silence(silence) do
205
+ stub_processor(logger, &block)
206
+ end
207
+ else
208
+ stub_processor(logger, &block)
209
+ end
210
+
211
+ logger.events
212
+ end
213
+
214
+ # Matcher for a single captured log event.
215
+ #
216
+ # expect(events.first).to be_a_semantic_logger_event(level: :info, message: "Hi")
217
+ def be_a_semantic_logger_event(**expected)
218
+ EventMatcher.new(**expected)
219
+ end
220
+
221
+ # Composable alias, for use inside other matchers:
222
+ #
223
+ # expect(events).to include(a_semantic_logger_event(message: "Hi"))
224
+ def a_semantic_logger_event(**expected)
225
+ EventMatcher.new(**expected)
226
+ end
227
+
228
+ # Block matcher asserting that the block logs a matching event.
229
+ #
230
+ # expect { User.new.enable! }.to(
231
+ # log_semantic_logger_event(level: :info, message: "User enabled")
232
+ # )
233
+ #
234
+ # Pass `on:` to capture only one class's events.
235
+ def log_semantic_logger_event(on: nil, **expected)
236
+ LogEventMatcher.new(method(:capture_semantic_logger_events), on: on, expected: expected)
237
+ end
238
+
239
+ private
240
+
241
+ def stub_processor(logger)
242
+ allow(SemanticLogger::Logger).to receive(:processor).and_return(logger)
243
+ yield
244
+ ensure
245
+ allow(SemanticLogger::Logger).to receive(:processor).and_call_original
246
+ end
247
+ end
248
+ end
249
+ end
@@ -1,15 +1,28 @@
1
+ require "json"
2
+
1
3
  module SemanticLogger
2
4
  # Internal-use only utility functions for Semantic Logger.
3
5
  # Not intended for public use.
4
6
  module Utils
5
7
  def self.constantize_symbol(symbol, namespace = "SemanticLogger::Appender")
6
8
  klass = "#{namespace}::#{camelize(symbol.to_s)}"
7
- begin
8
- Object.const_get(klass)
9
- rescue NameError
9
+ constant =
10
+ begin
11
+ Object.const_get(klass)
12
+ rescue NameError
13
+ raise(ArgumentError,
14
+ "Could not convert symbol: #{symbol.inspect} to a class in: #{namespace}. Looking for: #{klass}")
15
+ end
16
+
17
+ # The resolved constant is instantiated by the caller, so ensure it is
18
+ # actually a class within the expected namespace rather than some other
19
+ # constant that happens to share the name.
20
+ unless constant.is_a?(Class)
10
21
  raise(ArgumentError,
11
- "Could not convert symbol: #{symbol.inspect} to a class in: #{namespace}. Looking for: #{klass}")
22
+ "Could not convert symbol: #{symbol.inspect} to a class in: #{namespace}. #{klass} is not a class.")
12
23
  end
24
+
25
+ constant
13
26
  end
14
27
 
15
28
  # Borrow from Rails, when not running Rails
@@ -76,5 +89,71 @@ module SemanticLogger
76
89
  def self.strip_path?(path)
77
90
  strip_paths.any? { |exclude| path.start_with?(exclude) }
78
91
  end
92
+
93
+ # Serializes the value to JSON, repairing invalid UTF-8 only when necessary.
94
+ #
95
+ # Non UTF-8 data appears in well under 1% of log events, so it is wasteful to
96
+ # walk and reallocate the entire structure (see .encode_utf8) on every call.
97
+ # Instead this attempts `.to_json` directly and only falls back to cleansing
98
+ # when serialization fails because of an encoding problem.
99
+ #
100
+ # The exception raised for non UTF-8 data depends on the json gem version:
101
+ # older versions raise Encoding::UndefinedConversionError (an EncodingError),
102
+ # newer versions wrap it as JSON::GeneratorError, so both are rescued. The
103
+ # retry is attempted only once: if it still fails (for example a
104
+ # JSON::GeneratorError caused by something other than encoding, such as NaN),
105
+ # the error propagates unchanged rather than being swallowed.
106
+ def self.to_json(value)
107
+ value.to_json
108
+ rescue JSON::GeneratorError, EncodingError
109
+ encode_utf8(value).to_json
110
+ end
111
+
112
+ # Returns a copy of the supplied value with every String converted to valid UTF-8.
113
+ #
114
+ # Recurses through Hash and Array structures, cleansing both keys and values.
115
+ # Strings that are already valid UTF-8 are returned unchanged (the common case),
116
+ # so the fast path allocates nothing. Any other value (Symbol, Numeric, Time, nil,
117
+ # ...) is returned as-is.
118
+ #
119
+ # Used by .to_json on the rare failing path, and directly by formatters that
120
+ # serialize per value or emit to a non-JSON sink (where a single `.to_json`
121
+ # rescue boundary cannot catch an intermediate failure).
122
+ def self.encode_utf8(value)
123
+ case value
124
+ when String
125
+ encode_utf8_string(value)
126
+ when Hash
127
+ value.each_with_object({}) do |(key, val), hash|
128
+ hash[encode_utf8(key)] = encode_utf8(val)
129
+ end
130
+ when Array
131
+ value.map { |element| encode_utf8(element) }
132
+ else
133
+ value
134
+ end
135
+ end
136
+
137
+ # Options used when transcoding a string to UTF-8.
138
+ # Invalid byte sequences and characters that cannot be represented in UTF-8 are
139
+ # dropped rather than substituted, matching the preference in issue #180.
140
+ ENCODE_UTF8_OPTIONS = {invalid: :replace, undef: :replace, replace: "".freeze}.freeze
141
+
142
+ # Returns the string converted to valid UTF-8, dropping any invalid bytes.
143
+ def self.encode_utf8_string(string)
144
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
145
+
146
+ if string.encoding == Encoding::UTF_8
147
+ # Correctly tagged as UTF-8 but contains invalid byte sequences.
148
+ string.scrub("")
149
+ else
150
+ # Different encoding (e.g. ASCII-8BIT / Latin-1): transcode into UTF-8.
151
+ string.encode(Encoding::UTF_8, **ENCODE_UTF8_OPTIONS)
152
+ end
153
+ rescue EncodingError
154
+ # Last resort for encodings without a converter to UTF-8: reinterpret the
155
+ # raw bytes as UTF-8 and drop anything invalid. Logging must never raise.
156
+ string.dup.force_encoding(Encoding::UTF_8).scrub("")
157
+ end
79
158
  end
80
159
  end
@@ -1,3 +1,3 @@
1
1
  module SemanticLogger
2
- VERSION = "4.18.0".freeze
2
+ VERSION = "5.0.0".freeze
3
3
  end
@@ -12,12 +12,21 @@ require "semantic_logger/loggable"
12
12
  require "semantic_logger/concerns/compatibility"
13
13
  require "semantic_logger/appender"
14
14
  require "semantic_logger/appenders"
15
+ require "semantic_logger/queue_processor"
15
16
  require "semantic_logger/processor"
16
17
  require "semantic_logger/sync_processor"
17
18
  require "semantic_logger/logger"
18
19
  require "semantic_logger/debug_as_trace_logger"
19
20
  require "semantic_logger/semantic_logger"
20
21
 
22
+ # Automatically reopen appenders in the child process after a fork.
23
+ # Enabled by default; opt out with `SemanticLogger.reopen_on_fork = false`.
24
+ # Skipped on platforms without `Process._fork` (e.g. JRuby), which cannot fork.
25
+ if Process.respond_to?(:_fork)
26
+ require "semantic_logger/core_ext/process"
27
+ Process.singleton_class.prepend(SemanticLogger::CoreExt::Process)
28
+ end
29
+
21
30
  # Flush all appenders at exit, waiting for outstanding messages on the queue
22
31
  # to be written first.
23
32
  at_exit do