semantic_logger 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -12
  3. data/Rakefile +15 -4
  4. data/docs/api.md +37 -11
  5. data/docs/appenders.md +123 -39
  6. data/docs/config.md +115 -13
  7. data/docs/index.md +31 -5
  8. data/docs/log.md +19 -11
  9. data/docs/metrics.md +5 -2
  10. data/docs/operations.md +63 -17
  11. data/docs/rails.md +77 -3
  12. data/docs/security.md +183 -16
  13. data/docs/testing.md +5 -3
  14. data/docs/upgrading.md +295 -7
  15. data/lib/semantic_logger/appender/async.rb +18 -5
  16. data/lib/semantic_logger/appender/bugsnag.rb +1 -1
  17. data/lib/semantic_logger/appender/cloudwatch_logs.rb +122 -27
  18. data/lib/semantic_logger/appender/elasticsearch_base.rb +31 -6
  19. data/lib/semantic_logger/appender/elasticsearch_http.rb +1 -1
  20. data/lib/semantic_logger/appender/file.rb +138 -30
  21. data/lib/semantic_logger/appender/graylog.rb +22 -6
  22. data/lib/semantic_logger/appender/honeybadger.rb +1 -1
  23. data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
  24. data/lib/semantic_logger/appender/http.rb +70 -11
  25. data/lib/semantic_logger/appender/io.rb +1 -1
  26. data/lib/semantic_logger/appender/kafka.rb +26 -12
  27. data/lib/semantic_logger/appender/loki.rb +13 -3
  28. data/lib/semantic_logger/appender/mongodb.rb +20 -1
  29. data/lib/semantic_logger/appender/new_relic.rb +1 -1
  30. data/lib/semantic_logger/appender/new_relic_logs.rb +9 -11
  31. data/lib/semantic_logger/appender/open_telemetry.rb +2 -2
  32. data/lib/semantic_logger/appender/rabbitmq.rb +6 -1
  33. data/lib/semantic_logger/appender/sentry.rb +1 -1
  34. data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
  35. data/lib/semantic_logger/appender/splunk.rb +10 -1
  36. data/lib/semantic_logger/appender/syslog.rb +64 -26
  37. data/lib/semantic_logger/appender/tcp.rb +6 -1
  38. data/lib/semantic_logger/appender/udp.rb +11 -1
  39. data/lib/semantic_logger/appender/wrapper.rb +1 -1
  40. data/lib/semantic_logger/appenders.rb +9 -1
  41. data/lib/semantic_logger/base.rb +82 -30
  42. data/lib/semantic_logger/exception_details.rb +50 -0
  43. data/lib/semantic_logger/formatters/base.rb +52 -6
  44. data/lib/semantic_logger/formatters/color.rb +18 -2
  45. data/lib/semantic_logger/formatters/default.rb +22 -5
  46. data/lib/semantic_logger/formatters/logfmt.rb +153 -21
  47. data/lib/semantic_logger/formatters/loki.rb +26 -22
  48. data/lib/semantic_logger/formatters/one_line.rb +67 -3
  49. data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
  50. data/lib/semantic_logger/formatters/pattern.rb +8 -4
  51. data/lib/semantic_logger/formatters/raw.rb +4 -5
  52. data/lib/semantic_logger/formatters/syslog.rb +110 -18
  53. data/lib/semantic_logger/formatters/syslog_cee.rb +29 -16
  54. data/lib/semantic_logger/levels.rb +10 -1
  55. data/lib/semantic_logger/log.rb +183 -37
  56. data/lib/semantic_logger/loggable.rb +26 -3
  57. data/lib/semantic_logger/metric/new_relic.rb +1 -1
  58. data/lib/semantic_logger/metric/signalfx.rb +8 -6
  59. data/lib/semantic_logger/metric/statsd.rb +15 -7
  60. data/lib/semantic_logger/queue_processor/request.rb +55 -0
  61. data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
  62. data/lib/semantic_logger/queue_processor.rb +165 -66
  63. data/lib/semantic_logger/semantic_logger.rb +15 -2
  64. data/lib/semantic_logger/subscriber.rb +96 -10
  65. data/lib/semantic_logger/sync_processor.rb +11 -0
  66. data/lib/semantic_logger/test/capture_log_events.rb +8 -5
  67. data/lib/semantic_logger/utils.rb +244 -16
  68. data/lib/semantic_logger/version.rb +1 -1
  69. data/lib/semantic_logger.rb +1 -1
  70. metadata +21 -5
  71. data/lib/semantic_logger/core_ext/thread.rb +0 -30
@@ -8,7 +8,7 @@
8
8
  module SemanticLogger
9
9
  module Metric
10
10
  class Signalfx < SemanticLogger::Appender::Http
11
- attr_reader :full_url
11
+ attr_reader :full_path
12
12
 
13
13
  END_POINT = "v2/datapoint".freeze
14
14
 
@@ -86,20 +86,22 @@ module SemanticLogger
86
86
  super(url: url, formatter: formatter, **args, &)
87
87
 
88
88
  @header["X-SF-TOKEN"] = token
89
- @full_url = "#{url}/#{END_POINT}"
89
+ # Built from the parsed path, not the supplied url, so that a query string in the
90
+ # url stays at the end of the request uri. See Appender::Http#uri_with_query.
91
+ @full_path = "#{@path.end_with?('/') ? @path : "#{@path}/"}#{END_POINT}"
90
92
  end
91
93
 
92
94
  def log(log)
93
- message = formatter.call(log, self)
95
+ message = format_log(log)
94
96
  logger.trace(message)
95
- post(message, full_url)
97
+ post(message, full_path)
96
98
  end
97
99
 
98
100
  # Logs in batches
99
101
  def batch(logs)
100
- message = formatter.batch(logs, self)
102
+ message = format_batch(logs)
101
103
  logger.trace(message)
102
- post(message, full_url)
104
+ post(message, full_path)
103
105
  end
104
106
 
105
107
  # Only forward log entries that contain metrics.
@@ -32,7 +32,7 @@ module SemanticLogger
32
32
  end
33
33
 
34
34
  def reopen
35
- uri = URI.parse(@url)
35
+ uri = Utils.parse_url(@url)
36
36
  raise('Statsd only supports udp. Example: "udp://localhost:8125"') if uri.scheme != "udp"
37
37
 
38
38
  @statsd = ::Statsd.new(uri.host, uri.port)
@@ -45,12 +45,15 @@ module SemanticLogger
45
45
  if (duration = log.duration)
46
46
  @statsd.timing(metric, duration)
47
47
  else
48
- amount = (log.metric_amount || 1).round
49
- if amount.negative?
50
- amount.abs.times { @statsd.decrement(metric) }
51
- else
52
- amount.times { @statsd.increment(metric) }
53
- end
48
+ amount = log.metric_amount || 1
49
+ # Infinity and NaN cannot be counted, whether a Float or a BigDecimal.
50
+ return if amount.respond_to?(:finite?) && !amount.finite?
51
+
52
+ amount = amount.round
53
+ # A single counter packet, whatever the size of the amount. Incrementing once per unit
54
+ # would let a large amount, such as one supplied by a user, flood the network and
55
+ # stall the logging thread.
56
+ @statsd.count(metric, amount) unless amount.zero?
54
57
  end
55
58
  end
56
59
 
@@ -59,6 +62,11 @@ module SemanticLogger
59
62
  # Does not support metrics with dimensions.
60
63
  log.metric && !log.dimensions && meets_log_level?(log) && !filtered?(log)
61
64
  end
65
+
66
+ # The url without any credentials. See Subscriber#inspect.
67
+ def inspect_details
68
+ "url=#{Utils.redacted_url(url).inspect}"
69
+ end
62
70
  end
63
71
  end
64
72
  end
@@ -0,0 +1,55 @@
1
+ module SemanticLogger
2
+ class QueueProcessor
3
+ # Internal: a command, such as :flush or :close, submitted on the queue for the worker thread
4
+ # to run, while the submitting thread waits for the reply.
5
+ #
6
+ # It knows its command and how its reply gets back to the submitting thread, and makes sure
7
+ # that the submitting thread is never left waiting on a reply that cannot come.
8
+ class Request
9
+ attr_reader :command
10
+
11
+ def initialize(command)
12
+ @command = command
13
+ @replies = Queue.new
14
+ end
15
+
16
+ # Called by the worker thread: run the block for this request, then reply with true, or
17
+ # with false when the block raised. Replies either way.
18
+ def reply
19
+ done = false
20
+ yield
21
+ done = true
22
+ ensure
23
+ @replies.push(done)
24
+ end
25
+
26
+ # Called by the worker thread: run this command on the appender, then reply. Returns false
27
+ # once message processing must stop (:close).
28
+ def run(appender)
29
+ case command
30
+ when :flush
31
+ reply { appender.flush }
32
+ when :close
33
+ reply { appender.close }
34
+ return false
35
+ else
36
+ reply { appender.logger.warn "Async: Appender thread: Ignoring unknown command: #{command}" }
37
+ end
38
+ true
39
+ end
40
+
41
+ # Called by the submitting thread: wait for the reply from the worker thread.
42
+ #
43
+ # The block returns whether a worker thread that can still reply is running. Returns the
44
+ # reply, or false once none is, for example once the worker gave up after
45
+ # async_max_retries, or was replaced after a fork.
46
+ def wait
47
+ loop do
48
+ reply = @replies.pop(timeout: 1)
49
+ return reply unless reply.nil?
50
+ return @replies.pop(timeout: 0) || false unless yield
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ module SemanticLogger
2
+ class QueueProcessor
3
+ # Internal: counts occurrences of a problem, such as a dropped log message, and has it
4
+ # reported at most once per interval instead of once per occurrence.
5
+ #
6
+ # It knows the total number of occurrences, how many there have been since the previous
7
+ # report, and when that report was made. What to report, and where, is up to the caller.
8
+ #
9
+ # Thread-safe: occurrences may be recorded from any thread.
10
+ class ThrottledCounter
11
+ # Returns [Integer] the total number of occurrences recorded.
12
+ attr_reader :total
13
+
14
+ # Parameters:
15
+ # reported_at: [Time|nil]
16
+ # When counting starts, so that the first report is due one interval later.
17
+ # nil: Report the first occurrence straight away.
18
+ # Default: Time.now
19
+ def initialize(reported_at: Time.now)
20
+ @reported_at = reported_at
21
+ @total = 0
22
+ @unreported = 0
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Record one or more occurrences.
27
+ #
28
+ # When the previous report was at least `interval` seconds ago, yields the number of
29
+ # occurrences since then (including these) and the seconds elapsed since that report (nil
30
+ # when there has been no report yet), then starts counting afresh for the next report.
31
+ def record(count = 1, interval:)
32
+ @mutex.synchronize do
33
+ @total += count
34
+ @unreported += count
35
+ now = Time.now
36
+ elapsed = @reported_at && (now - @reported_at)
37
+ return if elapsed && elapsed < interval
38
+
39
+ yield(@unreported, elapsed)
40
+ @unreported = 0
41
+ @reported_at = now
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,3 +1,6 @@
1
+ require "semantic_logger/queue_processor/request"
2
+ require "semantic_logger/queue_processor/throttled_counter"
3
+
1
4
  module SemanticLogger
2
5
  # Internal class that processes log messages from a queue on a separate thread.
3
6
  #
@@ -12,9 +15,9 @@ module SemanticLogger
12
15
  # batch_seconds have elapsed since the previous batch.
13
16
  class QueueProcessor
14
17
  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
+ :batch_size, :batch_seconds, :non_blocking
19
+ attr_reader :appender, :queue, :max_queue_size, :signal,
20
+ :processed_count, :async_max_retries, :retry_count
18
21
 
19
22
  # Create a new processor and start its worker thread.
20
23
  def self.start(**args)
@@ -63,7 +66,8 @@ module SemanticLogger
63
66
  #
64
67
  # dropped_message_report_seconds: [Integer]
65
68
  # When non_blocking is enabled, log the count of dropped messages to the internal logger
66
- # at most once every this number of seconds.
69
+ # at most once every this number of seconds. Also limits how often messages discarded
70
+ # because their content could not be written are reported, after the first.
67
71
  # Default: 30
68
72
  #
69
73
  # async_max_retries: [Integer]
@@ -99,11 +103,9 @@ module SemanticLogger
99
103
  @thread = nil
100
104
  # Only batch mode parks the worker on the signal; streaming mode never touches it.
101
105
  @signal = Concurrent::Event.new if batch
102
- @dropped_message_count = 0
103
- @dropped_message_reported_at = Time.now
104
- @dropped_message_mutex = Mutex.new
106
+ @dropped = ThrottledCounter.new
107
+ @failed = ThrottledCounter.new(reported_at: nil)
105
108
  @processed_count = 0
106
- @dropped_count = 0
107
109
  create_queue
108
110
 
109
111
  return unless batch? && !appender.respond_to?(:batch)
@@ -112,9 +114,7 @@ module SemanticLogger
112
114
  end
113
115
 
114
116
  # Internal logger used to report problems encountered while processing log messages.
115
- def logger
116
- appender.logger
117
- end
117
+ def logger = appender.logger
118
118
 
119
119
  # Returns [Thread] the worker thread.
120
120
  #
@@ -126,33 +126,39 @@ module SemanticLogger
126
126
  end
127
127
 
128
128
  # Returns [true|false] whether the worker thread is running.
129
- def active?
130
- @thread&.alive?
131
- end
129
+ def active? = @thread&.alive?
132
130
 
133
131
  # Returns [true|false] whether the queue has a capped size.
134
- def capped?
135
- @capped
136
- end
132
+ def capped? = @capped
137
133
 
138
134
  # Returns [true|false] whether messages are dropped instead of blocking when the queue is full.
139
135
  # Only a capped queue can drop messages.
140
- def non_blocking?
141
- @non_blocking && capped?
142
- end
136
+ def non_blocking? = @non_blocking && capped?
137
+
138
+ # Returns [Integer] the number of log messages dropped because the queue was full.
139
+ def dropped_count = @dropped.total
140
+
141
+ # Returns [Integer] the number of log messages discarded because their content could not be
142
+ # written (see Subscriber#message_error?).
143
+ def failed_count = @failed.total
143
144
 
144
145
  # Returns [true|false] whether messages are processed in batches.
145
- def batch?
146
- @batch
147
- end
146
+ def batch? = @batch
147
+
148
+ # Returns [true|false] whether the worker thread gave up after an error, so that queued
149
+ # messages are no longer being processed.
150
+ def stopped? = @stopped == true
148
151
 
149
152
  # Add a log message to the queue for processing.
150
153
  #
151
154
  # When non-blocking and the queue is full, the message is dropped instead of blocking the
152
155
  # calling thread, and the count of dropped messages is reported periodically.
156
+ #
157
+ # Once the worker thread has stopped for good (see async_max_retries), nothing will empty the
158
+ # queue, so messages are also dropped instead of blocking the calling thread forever.
153
159
  def log(log)
154
160
  enqueued = true
155
- if non_blocking?
161
+ if non_blocking? || stopped?
156
162
  begin
157
163
  queue.push(log, true)
158
164
  rescue ThreadError
@@ -195,14 +201,26 @@ module SemanticLogger
195
201
 
196
202
  private
197
203
 
198
- # Start the worker thread, naming it after the internal logger.
204
+ # Start the worker thread.
199
205
  def spawn_worker
206
+ @stopped = false
207
+ @stack_overflow = nil
200
208
  Thread.new do
201
- Thread.current.name = logger.name
209
+ name_worker
202
210
  process
203
211
  end
204
212
  end
205
213
 
214
+ # Name the worker thread after its appender, such as "SL File", short enough for tools that
215
+ # list threads, such as `top -H`, to tell the workers apart: Linux keeps only the first 15 bytes.
216
+ # The name is only a label, so when Ruby rejects it, for example for a class given a temporary
217
+ # name with a NUL byte, the thread carries on without one.
218
+ def name_worker
219
+ Thread.current.name = ["SL", appender.class.name&.split("::")&.last].compact.join(" ")
220
+ rescue StandardError
221
+ nil
222
+ end
223
+
206
224
  def create_queue
207
225
  if max_queue_size == -1
208
226
  @queue = Queue.new
@@ -219,7 +237,7 @@ module SemanticLogger
219
237
  # or the appender is closed.
220
238
  logger.trace "Async: Appender thread active"
221
239
  begin
222
- batch? ? process_messages_in_batches : process_messages
240
+ carry_on = batch? ? process_messages_in_batches : process_messages
223
241
  rescue StandardError => e
224
242
  if async_max_retries == -1 || retry_count < async_max_retries
225
243
  @retry_count += 1
@@ -229,13 +247,16 @@ module SemanticLogger
229
247
  sleep(retry_count)
230
248
  retry
231
249
  else
232
- safe_log(:error, "Async: Stopping after #{retry_count} failed retries", e)
250
+ @stopped = true
251
+ safe_log(:error, "Async: Stopping after #{retry_count} failed retries, dropping new messages", e)
233
252
  end
234
253
  rescue Exception => e
254
+ @stopped = true
235
255
  safe_log(:error, "Async: Stopping due to fatal exception", e)
236
256
  ensure
237
- @thread = nil
238
- safe_log(:trace, "Async: Thread has stopped")
257
+ # A thread that overflowed its stack hands the queue over to a new one (see #stack_overflowed).
258
+ @thread = carry_on ? spawn_worker : nil
259
+ safe_log(:trace, "Async: Thread has stopped") unless carry_on
239
260
  end
240
261
  end
241
262
 
@@ -248,12 +269,15 @@ module SemanticLogger
248
269
  end
249
270
 
250
271
  # Streaming: write each log message to the appender as it is dequeued.
272
+ #
273
+ # Returns true when processing must carry on in a new thread (see #stack_overflowed).
251
274
  def process_messages
252
275
  count = 0
253
276
  while (message = queue.pop)
254
277
  if message.is_a?(Log)
255
- appender.log(message)
256
- @processed_count += 1
278
+ write_message(message)
279
+ return true if @stack_overflow
280
+
257
281
  @retry_count = 0 unless retry_count.zero?
258
282
  count += 1
259
283
  # Check every few log messages whether this appender thread is falling behind
@@ -262,20 +286,23 @@ module SemanticLogger
262
286
  count = 0
263
287
  end
264
288
  else
265
- break unless process_message(message)
289
+ break unless message.run(appender)
266
290
  end
267
291
  end
268
292
  logger.trace "Async: Queue Closed"
293
+ false
269
294
  end
270
295
 
271
296
  # Batching: group up log messages before writing them via the appender's #batch method.
297
+ #
298
+ # Returns true when processing must carry on in a new thread (see #stack_overflowed).
272
299
  def process_messages_in_batches
273
300
  loop do
274
301
  # Wait for batch interval or number of messages to be exceeded.
275
302
  signal.wait(batch_seconds)
276
303
 
277
304
  logs = []
278
- messages = []
305
+ commands = []
279
306
  first = true
280
307
  message_count = queue.length
281
308
  message_count.times do
@@ -288,53 +315,122 @@ module SemanticLogger
288
315
  first = false
289
316
  end
290
317
  else
291
- messages << message
318
+ commands << message
292
319
  end
293
320
  end
294
- if logs.size.positive?
295
- appender.batch(logs)
296
- @processed_count += logs.size
297
- @retry_count = 0 unless retry_count.zero?
298
- end
299
321
  # Stop processing once a command (e.g. :close) signals the thread to terminate.
300
- break if messages.any? { |message| !process_message(message) }
322
+ return false unless write_batch_then_run(logs, commands)
323
+ return true if @stack_overflow
301
324
 
302
325
  signal.reset unless queue.size >= batch_size
303
326
  end
304
327
  end
305
328
 
306
- # Returns false when message processing should be stopped.
307
- def process_message(message)
308
- case message[:command]
309
- when :flush
310
- appender.flush
311
- message[:reply_queue] << true if message[:reply_queue]
312
- when :close
313
- appender.close
314
- message[:reply_queue] << true if message[:reply_queue]
315
- return false
316
- else
317
- logger.warn "Async: Appender thread: Ignoring unknown command: #{message[:command]}"
329
+ # Write the log messages taken off the queue as a batch, then run the commands taken off the
330
+ # queue with them. Returns false once a command (:close) stops processing.
331
+ #
332
+ # When the batch cannot be written, for example because the destination is unavailable, the
333
+ # commands still run before the error propagates to #process to back off. Otherwise they
334
+ # would be lost, and a caller waiting on #flush or #close, such as the flush at exit, would
335
+ # wait forever.
336
+ def write_batch_then_run(logs, commands)
337
+ unless logs.empty?
338
+ write_batch(logs)
339
+ @retry_count = 0 unless retry_count.zero?
340
+ end
341
+ commands.all? { |command| command.run(appender) }
342
+ rescue StandardError => e
343
+ raise e if commands.all? { |command| process_message_after_failure(command) }
344
+
345
+ safe_log(:warn, "Async: Closed without writing #{logs.size} log messages", e)
346
+ false
347
+ end
348
+
349
+ # Run a command after a batch could not be written. Returns false once a command (:close)
350
+ # stops processing, even when the command itself fails.
351
+ def process_message_after_failure(command)
352
+ command.run(appender)
353
+ rescue StandardError => e
354
+ safe_log(:warn, "Async: Failed to #{command.command} the appender", e)
355
+ command.command != :close
356
+ end
357
+
358
+ # Write one log message to the appender.
359
+ #
360
+ # A message whose content cannot be written (see #message_error?) is discarded and reported,
361
+ # without restarting the thread. Any other error, such as the destination being unavailable,
362
+ # propagates to #process, which backs off before retrying. A stack overflow discards the
363
+ # message too (see #stack_overflowed).
364
+ def write_message(log)
365
+ appender.log(log)
366
+ @processed_count += 1
367
+ rescue SystemStackError => e
368
+ stack_overflowed([log], e)
369
+ rescue StandardError => e
370
+ raise unless message_error?(e)
371
+
372
+ messages_failed([log], e)
373
+ end
374
+
375
+ # Write the log messages to the appender as a batch.
376
+ #
377
+ # When the content of a message cannot be written (see #message_error?), and the appender's
378
+ # #batch is all or nothing, the batch is split in half and each half written separately, so
379
+ # that only the offending message is discarded rather than every message in the batch.
380
+ # Otherwise the batch is discarded, since writing it again could duplicate the messages that
381
+ # the appender had already written. A stack overflow discards the batch, and the rest of the
382
+ # batch being split, without splitting it further (see #stack_overflowed).
383
+ def write_batch(logs)
384
+ return messages_failed(logs, @stack_overflow) if @stack_overflow
385
+
386
+ appender.batch(logs)
387
+ @processed_count += logs.size
388
+ rescue SystemStackError => e
389
+ stack_overflowed(logs, e)
390
+ rescue StandardError => e
391
+ raise unless message_error?(e)
392
+ return messages_failed(logs, e) if logs.size == 1 || !appender.batch_all_or_nothing?
393
+
394
+ logs.each_slice((logs.size + 1) / 2) { |half| write_batch(half) }
395
+ end
396
+
397
+ # Discard log messages whose writing overflowed the stack, for example a value nested too
398
+ # deeply for a custom formatter. Retrying cannot write them, as for #message_error?.
399
+ #
400
+ # This thread must not overflow its stack again: on macOS a second stack overflow on the same
401
+ # thread crashes Ruby. So nothing else is written on it, including the rest of a batch, which
402
+ # is discarded rather than split up further to find the message at fault. Once any commands
403
+ # taken off the queue with the messages have run, a new thread carries on (see #process).
404
+ def stack_overflowed(logs, exception)
405
+ @stack_overflow = exception
406
+ messages_failed(logs, exception)
407
+ end
408
+
409
+ # Whether the appender says the exception was caused by the content of a log message, rather
410
+ # than by the destination. An appender that cannot tell (it does not implement
411
+ # #message_error?) has every error retried with a back-off.
412
+ def message_error?(exception) = appender.respond_to?(:message_error?) && appender.message_error?(exception)
413
+
414
+ # Record log messages discarded because their content could not be written. The first is
415
+ # reported to the internal logger straight away, with the error. After that the count is
416
+ # reported, with the latest error, at most once every dropped_message_report_seconds.
417
+ def messages_failed(logs, exception)
418
+ @failed.record(logs.size, interval: dropped_message_report_seconds) do |count, seconds|
419
+ period = seconds ? " in the last #{seconds.round} seconds" : ""
420
+ safe_log(:error, "Async: Discarded #{count} log message(s)#{period} that could not be written, " \
421
+ "most recently from #{logs.first.name}", exception)
318
422
  end
319
- true
320
423
  end
321
424
 
322
425
  # Record a dropped message, reporting the running count to the internal logger at most
323
426
  # once every dropped_message_report_seconds.
324
427
  def message_dropped
325
- @dropped_message_mutex.synchronize do
326
- @dropped_message_count += 1
327
- @dropped_count += 1
328
- diff = Time.now - @dropped_message_reported_at
329
- return if diff < dropped_message_report_seconds
330
-
428
+ @dropped.record(interval: dropped_message_report_seconds) do |count, seconds|
331
429
  logger.warn(
332
- "Async: Dropped #{@dropped_message_count} log messages in the last #{diff.round} seconds. " \
430
+ "Async: Dropped #{count} log messages in the last #{seconds.round} seconds. " \
333
431
  "The queue is full (max_queue_size: #{max_queue_size}). " \
334
432
  "Consider reducing the log level, increasing max_queue_size, or changing the appenders"
335
433
  )
336
- @dropped_message_count = 0
337
- @dropped_message_reported_at = Time.now
338
434
  end
339
435
  end
340
436
 
@@ -364,9 +460,12 @@ module SemanticLogger
364
460
  # Wake up the processing thread to process this command immediately.
365
461
  signal.set if batch?
366
462
 
367
- reply_queue = Queue.new
368
- queue << {command: command, reply_queue: reply_queue}
369
- reply_queue.pop
463
+ submitted_to = queue
464
+ request = Request.new(command)
465
+ submitted_to << request
466
+ # A reply can come while the request is on the current queue, which #reopen replaces, and a
467
+ # worker thread is running it, which may be a new one after a stack overflow.
468
+ request.wait { queue.equal?(submitted_to) && active? }
370
469
  end
371
470
  end
372
471
  end
@@ -549,8 +549,9 @@ module SemanticLogger
549
549
  # processed: [Integer] Cumulative number of log messages processed since startup.
550
550
  # dropped: [Integer] Cumulative number of log messages dropped at the main queue.
551
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`.
552
+ # async thread report their queue_size and
553
+ # processed/dropped/failed counts; appenders that log
554
+ # inline report `async: false`.
554
555
  #
555
556
  # All counters are cumulative since process startup. They are thread-safe to read and
556
557
  # are maintained without adding any locking to the logging hot path.
@@ -576,6 +577,18 @@ module SemanticLogger
576
577
  Logger.processor.lag_threshold_s
577
578
  end
578
579
 
580
+ # Returns [true|false] whether log messages are dropped instead of blocking
581
+ # when the main pipeline queue is full.
582
+ def self.non_blocking?
583
+ Logger.processor.non_blocking?
584
+ end
585
+
586
+ # Drop log messages instead of blocking the calling thread when the main
587
+ # pipeline queue is full.
588
+ def self.non_blocking=(non_blocking)
589
+ Logger.processor.non_blocking = non_blocking
590
+ end
591
+
579
592
  def self.default_level_index
580
593
  Thread.current[:semantic_logger_silence] || @default_level_index
581
594
  end