semantic_logger 4.17.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +42 -81
  3. data/Rakefile +8 -1
  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/honeybadger_insights.rb +1 -1
  12. data/lib/semantic_logger/appender/http.rb +27 -2
  13. data/lib/semantic_logger/appender/io.rb +8 -4
  14. data/lib/semantic_logger/appender/kafka.rb +2 -2
  15. data/lib/semantic_logger/appender/loki.rb +2 -4
  16. data/lib/semantic_logger/appender/mongodb.rb +3 -6
  17. data/lib/semantic_logger/appender/new_relic_logs.rb +12 -2
  18. data/lib/semantic_logger/appender/open_telemetry.rb +25 -11
  19. data/lib/semantic_logger/appender/opensearch.rb +35 -0
  20. data/lib/semantic_logger/appender/rabbitmq.rb +3 -3
  21. data/lib/semantic_logger/appender/sentry_ruby.rb +8 -3
  22. data/lib/semantic_logger/appender/splunk.rb +2 -2
  23. data/lib/semantic_logger/appender/splunk_http.rb +3 -3
  24. data/lib/semantic_logger/appender/syslog.rb +5 -4
  25. data/lib/semantic_logger/appender/tcp.rb +2 -2
  26. data/lib/semantic_logger/appender/udp.rb +2 -2
  27. data/lib/semantic_logger/appender/wrapper.rb +4 -4
  28. data/lib/semantic_logger/appender.rb +30 -19
  29. data/lib/semantic_logger/appenders.rb +26 -5
  30. data/lib/semantic_logger/base.rb +113 -21
  31. data/lib/semantic_logger/concerns/compatibility.rb +2 -2
  32. data/lib/semantic_logger/core_ext/process.rb +34 -0
  33. data/lib/semantic_logger/formatters/base.rb +46 -7
  34. data/lib/semantic_logger/formatters/color.rb +6 -3
  35. data/lib/semantic_logger/formatters/default.rb +6 -4
  36. data/lib/semantic_logger/formatters/ecs.rb +151 -0
  37. data/lib/semantic_logger/formatters/fluentd.rb +15 -4
  38. data/lib/semantic_logger/formatters/json.rb +6 -1
  39. data/lib/semantic_logger/formatters/logfmt.rb +2 -2
  40. data/lib/semantic_logger/formatters/loki.rb +4 -4
  41. data/lib/semantic_logger/formatters/new_relic_logs.rb +4 -6
  42. data/lib/semantic_logger/formatters/open_telemetry.rb +40 -2
  43. data/lib/semantic_logger/formatters/pattern.rb +235 -0
  44. data/lib/semantic_logger/formatters/raw.rb +2 -2
  45. data/lib/semantic_logger/formatters/signalfx.rb +2 -2
  46. data/lib/semantic_logger/formatters/syslog.rb +14 -3
  47. data/lib/semantic_logger/formatters/syslog_cee.rb +1 -1
  48. data/lib/semantic_logger/formatters.rb +2 -0
  49. data/lib/semantic_logger/log.rb +23 -4
  50. data/lib/semantic_logger/logger.rb +2 -2
  51. data/lib/semantic_logger/metric/new_relic.rb +2 -2
  52. data/lib/semantic_logger/metric/signalfx.rb +2 -2
  53. data/lib/semantic_logger/metric/statsd.rb +2 -2
  54. data/lib/semantic_logger/processor.rb +22 -1
  55. data/lib/semantic_logger/queue_processor.rb +369 -0
  56. data/lib/semantic_logger/reporters/minitest.rb +4 -4
  57. data/lib/semantic_logger/semantic_logger.rb +103 -11
  58. data/lib/semantic_logger/subscriber.rb +15 -2
  59. data/lib/semantic_logger/sync_processor.rb +25 -3
  60. data/lib/semantic_logger/test/capture_log_events.rb +2 -2
  61. data/lib/semantic_logger/test/minitest.rb +8 -4
  62. data/lib/semantic_logger/test/rspec.rb +249 -0
  63. data/lib/semantic_logger/utils.rb +83 -4
  64. data/lib/semantic_logger/version.rb +1 -1
  65. data/lib/semantic_logger.rb +9 -0
  66. metadata +15 -6
  67. data/lib/semantic_logger/appender/async_batch.rb +0 -93
@@ -0,0 +1,369 @@
1
+ module SemanticLogger
2
+ # Internal class that processes log messages from a queue on a separate thread.
3
+ #
4
+ # Internal use only: it owns the worker thread and the in-memory queue that back the
5
+ # asynchronous appender proxy (SemanticLogger::Appender::Async). It is never returned to
6
+ # application code.
7
+ #
8
+ # Supports two processing modes, selected by the `batch:` option:
9
+ # * Streaming (batch: false): each log message is written to the appender as it is dequeued.
10
+ # * Batching (batch: true): log messages are grouped and written via the appender's #batch
11
+ # method, either once batch_size messages have accumulated, or
12
+ # batch_seconds have elapsed since the previous batch.
13
+ class QueueProcessor
14
+ attr_accessor :lag_check_interval, :lag_threshold_s, :dropped_message_report_seconds,
15
+ :batch_size, :batch_seconds
16
+ attr_reader :appender, :queue, :max_queue_size, :non_blocking, :signal,
17
+ :processed_count, :dropped_count, :async_max_retries, :retry_count
18
+
19
+ # Create a new processor and start its worker thread.
20
+ def self.start(**args)
21
+ processor = new(**args)
22
+ processor.thread
23
+ processor
24
+ end
25
+
26
+ # Parameters:
27
+ # appender: [SemanticLogger::Subscriber]
28
+ # The appender to forward log messages to from the worker thread.
29
+ #
30
+ # max_queue_size: [Integer]
31
+ # The maximum number of log messages to hold on the queue before blocking attempts to add to the queue.
32
+ # -1: The queue size is uncapped and will never block no matter how long the queue is.
33
+ # Default: 10,000
34
+ #
35
+ # lag_threshold_s: [Float]
36
+ # Log a warning when a log message has been on the queue for longer than this period in seconds.
37
+ # Default: 30
38
+ #
39
+ # lag_check_interval: [Integer]
40
+ # Number of messages to process before checking for slow logging.
41
+ # Default: 1,000
42
+ # Note: Not applicable when batch: true.
43
+ #
44
+ # batch: [true|false]
45
+ # Process log messages in batches via the appender's #batch method.
46
+ # Default: false
47
+ # Note: The appender must implement #batch.
48
+ #
49
+ # batch_size: [Integer]
50
+ # Maximum number of messages to batch up before sending.
51
+ # Default: 300
52
+ # Note: Only applicable when batch: true.
53
+ #
54
+ # batch_seconds: [Integer]
55
+ # Maximum number of seconds between sending batches.
56
+ # Default: 5
57
+ # Note: Only applicable when batch: true.
58
+ #
59
+ # non_blocking: [true|false]
60
+ # Whether to drop log messages instead of blocking the calling thread when the queue is full.
61
+ # Only applies to a capped queue.
62
+ # Default: false
63
+ #
64
+ # dropped_message_report_seconds: [Integer]
65
+ # When non_blocking is enabled, log the count of dropped messages to the internal logger
66
+ # at most once every this number of seconds.
67
+ # Default: 30
68
+ #
69
+ # async_max_retries: [Integer]
70
+ # Maximum number of consecutive times to restart the worker thread after it raises an
71
+ # exception while processing messages. Each restart first sleeps for `retry_count` seconds
72
+ # (1s, then 2s, ...) as a back-off. Once this many consecutive retries are exhausted the
73
+ # thread stops instead of restarting. The counter resets to zero whenever a message is
74
+ # processed successfully.
75
+ # -1: Retry indefinitely and never stop the thread (the pre-v5 behaviour). The back-off
76
+ # still applies, and still resets after any successful message.
77
+ # Default: 100
78
+ def initialize(appender:,
79
+ max_queue_size: 10_000,
80
+ lag_check_interval: 1_000,
81
+ lag_threshold_s: 30,
82
+ batch: false,
83
+ batch_size: 300,
84
+ batch_seconds: 5,
85
+ non_blocking: false,
86
+ dropped_message_report_seconds: 30,
87
+ async_max_retries: 100)
88
+ @appender = appender
89
+ @max_queue_size = max_queue_size
90
+ @lag_check_interval = lag_check_interval
91
+ @lag_threshold_s = lag_threshold_s
92
+ @batch = batch
93
+ @batch_size = batch_size
94
+ @batch_seconds = batch_seconds
95
+ @non_blocking = non_blocking
96
+ @dropped_message_report_seconds = dropped_message_report_seconds
97
+ @async_max_retries = async_max_retries
98
+ @retry_count = 0
99
+ @thread = nil
100
+ # Only batch mode parks the worker on the signal; streaming mode never touches it.
101
+ @signal = Concurrent::Event.new if batch
102
+ @dropped_message_count = 0
103
+ @dropped_message_reported_at = Time.now
104
+ @dropped_message_mutex = Mutex.new
105
+ @processed_count = 0
106
+ @dropped_count = 0
107
+ create_queue
108
+
109
+ return unless batch? && !appender.respond_to?(:batch)
110
+
111
+ raise(ArgumentError, "#{appender.class.name} does not support batching. It must implement #batch")
112
+ end
113
+
114
+ # Internal logger used to report problems encountered while processing log messages.
115
+ def logger
116
+ appender.logger
117
+ end
118
+
119
+ # Returns [Thread] the worker thread.
120
+ #
121
+ # Starts the worker thread if it is not currently running.
122
+ def thread
123
+ return @thread if @thread&.alive?
124
+
125
+ @thread = spawn_worker
126
+ end
127
+
128
+ # Returns [true|false] whether the worker thread is running.
129
+ def active?
130
+ @thread&.alive?
131
+ end
132
+
133
+ # Returns [true|false] whether the queue has a capped size.
134
+ def capped?
135
+ @capped
136
+ end
137
+
138
+ # Returns [true|false] whether messages are dropped instead of blocking when the queue is full.
139
+ # Only a capped queue can drop messages.
140
+ def non_blocking?
141
+ @non_blocking && capped?
142
+ end
143
+
144
+ # Returns [true|false] whether messages are processed in batches.
145
+ def batch?
146
+ @batch
147
+ end
148
+
149
+ # Add a log message to the queue for processing.
150
+ #
151
+ # When non-blocking and the queue is full, the message is dropped instead of blocking the
152
+ # calling thread, and the count of dropped messages is reported periodically.
153
+ def log(log)
154
+ enqueued = true
155
+ if non_blocking?
156
+ begin
157
+ queue.push(log, true)
158
+ rescue ThreadError
159
+ message_dropped
160
+ enqueued = false
161
+ end
162
+ else
163
+ queue << log
164
+ end
165
+
166
+ # For batches wake up the processing thread once the number of queued messages has been
167
+ # exceeded. Also wake it on a drop so it drains the full queue rather than waiting out the
168
+ # batch interval.
169
+ signal.set if batch? && (queue.size >= batch_size)
170
+
171
+ enqueued
172
+ end
173
+
174
+ # Flush all queued log entries to the appender.
175
+ # All queued log messages are written and then the appender is flushed.
176
+ def flush
177
+ submit_request(:flush)
178
+ end
179
+
180
+ # Flush any outstanding messages and close the appender.
181
+ def close
182
+ submit_request(:close)
183
+ end
184
+
185
+ # Re-open the queue and worker thread after a fork.
186
+ def reopen
187
+ # Workaround CRuby crash on fork by recreating queue on reopen
188
+ # https://github.com/reidmorrison/semantic_logger/issues/103
189
+ @queue&.close
190
+ create_queue
191
+
192
+ @thread&.kill if @thread&.alive?
193
+ @thread = spawn_worker
194
+ end
195
+
196
+ private
197
+
198
+ # Start the worker thread, naming it after the internal logger.
199
+ def spawn_worker
200
+ Thread.new do
201
+ Thread.current.name = logger.name
202
+ process
203
+ end
204
+ end
205
+
206
+ def create_queue
207
+ if max_queue_size == -1
208
+ @queue = Queue.new
209
+ @capped = false
210
+ else
211
+ @queue = SizedQueue.new(max_queue_size)
212
+ @capped = true
213
+ end
214
+ end
215
+
216
+ # Separate thread for processing log messages.
217
+ def process
218
+ # This thread is designed to never go down unless the main thread terminates
219
+ # or the appender is closed.
220
+ logger.trace "Async: Appender thread active"
221
+ begin
222
+ batch? ? process_messages_in_batches : process_messages
223
+ rescue StandardError => e
224
+ if async_max_retries == -1 || retry_count < async_max_retries
225
+ @retry_count += 1
226
+ limit = async_max_retries == -1 ? "unlimited" : async_max_retries
227
+ safe_log(:warn, "Async: Restarting due to exception, retry #{retry_count} of #{limit}, sleeping #{retry_count}s", e)
228
+ sleep(retry_count)
229
+ retry
230
+ else
231
+ safe_log(:error, "Async: Stopping after #{retry_count} failed retries", e)
232
+ end
233
+ rescue Exception => e
234
+ safe_log(:error, "Async: Stopping due to fatal exception", e)
235
+ ensure
236
+ @thread = nil
237
+ safe_log(:trace, "Async: Thread has stopped")
238
+ end
239
+ end
240
+
241
+ # Log to the internal logger, ignoring any error.
242
+ # These calls may run after Ruby has released file handles during shutdown.
243
+ def safe_log(level, message, exception = nil)
244
+ logger.public_send(level, message, exception)
245
+ rescue StandardError
246
+ nil
247
+ end
248
+
249
+ # Streaming: write each log message to the appender as it is dequeued.
250
+ def process_messages
251
+ count = 0
252
+ while (message = queue.pop)
253
+ if message.is_a?(Log)
254
+ appender.log(message)
255
+ @processed_count += 1
256
+ @retry_count = 0 unless retry_count.zero?
257
+ count += 1
258
+ # Check every few log messages whether this appender thread is falling behind
259
+ if count > lag_check_interval
260
+ check_lag(message)
261
+ count = 0
262
+ end
263
+ else
264
+ break unless process_message(message)
265
+ end
266
+ end
267
+ logger.trace "Async: Queue Closed"
268
+ end
269
+
270
+ # Batching: group up log messages before writing them via the appender's #batch method.
271
+ def process_messages_in_batches
272
+ loop do
273
+ # Wait for batch interval or number of messages to be exceeded.
274
+ signal.wait(batch_seconds)
275
+
276
+ logs = []
277
+ messages = []
278
+ first = true
279
+ message_count = queue.length
280
+ message_count.times do
281
+ # Queue#pop(true) raises an exception when there are no more messages, which is considered expensive.
282
+ message = queue.pop
283
+ if message.is_a?(Log)
284
+ logs << message
285
+ if first
286
+ check_lag(message)
287
+ first = false
288
+ end
289
+ else
290
+ messages << message
291
+ end
292
+ end
293
+ if logs.size.positive?
294
+ appender.batch(logs)
295
+ @processed_count += logs.size
296
+ @retry_count = 0 unless retry_count.zero?
297
+ end
298
+ # Stop processing once a command (e.g. :close) signals the thread to terminate.
299
+ break if messages.any? { |message| !process_message(message) }
300
+
301
+ signal.reset unless queue.size >= batch_size
302
+ end
303
+ end
304
+
305
+ # Returns false when message processing should be stopped.
306
+ def process_message(message)
307
+ case message[:command]
308
+ when :flush
309
+ appender.flush
310
+ message[:reply_queue] << true if message[:reply_queue]
311
+ when :close
312
+ appender.close
313
+ message[:reply_queue] << true if message[:reply_queue]
314
+ return false
315
+ else
316
+ logger.warn "Async: Appender thread: Ignoring unknown command: #{message[:command]}"
317
+ end
318
+ true
319
+ end
320
+
321
+ # Record a dropped message, reporting the running count to the internal logger at most
322
+ # once every dropped_message_report_seconds.
323
+ def message_dropped
324
+ @dropped_message_mutex.synchronize do
325
+ @dropped_message_count += 1
326
+ @dropped_count += 1
327
+ diff = Time.now - @dropped_message_reported_at
328
+ return if diff < dropped_message_report_seconds
329
+
330
+ logger.warn(
331
+ "Async: Dropped #{@dropped_message_count} log messages in the last #{diff.round} seconds. " \
332
+ "The queue is full (max_queue_size: #{max_queue_size}). " \
333
+ "Consider reducing the log level, increasing max_queue_size, or changing the appenders"
334
+ )
335
+ @dropped_message_count = 0
336
+ @dropped_message_reported_at = Time.now
337
+ end
338
+ end
339
+
340
+ def check_lag(log)
341
+ diff = Time.now - log.time
342
+ return unless diff > lag_threshold_s
343
+
344
+ logger.warn "Async: Appender thread has fallen behind by #{diff} seconds with #{queue.size} messages queued up. Consider reducing the log level or changing the appenders"
345
+ end
346
+
347
+ # Submit a command to the worker thread and wait for the reply.
348
+ def submit_request(command)
349
+ return false unless active?
350
+
351
+ queue_size = queue.size
352
+ msg = "Async: Queued log messages: #{queue_size}, running command: #{command}"
353
+ if queue_size > 1_000
354
+ logger.warn msg
355
+ elsif queue_size > 100
356
+ logger.info msg
357
+ elsif queue_size.positive?
358
+ logger.trace msg
359
+ end
360
+
361
+ # Wake up the processing thread to process this command immediately.
362
+ signal.set if batch?
363
+
364
+ reply_queue = Queue.new
365
+ queue << {command: command, reply_queue: reply_queue}
366
+ reply_queue.pop
367
+ end
368
+ end
369
+ end
@@ -28,18 +28,18 @@ module SemanticLogger
28
28
  attr_accessor :io
29
29
 
30
30
  def before_test(test)
31
- logger.info("START #{test.class_name} #{test.name}")
31
+ logger.info("START #{test.class.name} #{test.name}")
32
32
  end
33
33
 
34
34
  def after_test(test)
35
35
  if test.error?
36
- logger.benchmark_error("FAIL #{test.class_name} #{test.name}", duration: test.time * 1_000,
36
+ logger.benchmark_error("FAIL #{test.class.name} #{test.name}", duration: test.time * 1_000,
37
37
  metric: "minitest/fail")
38
38
  elsif test.skipped?
39
- logger.benchmark_warn("SKIP #{test.class_name} #{test.name}", duration: test.time * 1_000,
39
+ logger.benchmark_warn("SKIP #{test.class.name} #{test.name}", duration: test.time * 1_000,
40
40
  metric: "minitest/skip")
41
41
  else
42
- logger.benchmark_info("PASS #{test.class_name} #{test.name}", duration: test.time * 1_000,
42
+ logger.benchmark_info("PASS #{test.class.name} #{test.name}", duration: test.time * 1_000,
43
43
  metric: "minitest/pass")
44
44
  end
45
45
  end
@@ -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