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
@@ -56,7 +56,7 @@ module SemanticLogger
56
56
 
57
57
  # Send error messages as Runtime exceptions
58
58
  exception = extract_exception(log)
59
- hash = formatter.call(log, self)
59
+ hash = format_log(log)
60
60
  bugsnag_notify(exception, hash, log_level(log.level))
61
61
  true
62
62
  end
@@ -21,6 +21,13 @@ require "concurrent"
21
21
  module SemanticLogger
22
22
  module Appender
23
23
  class CloudwatchLogs < SemanticLogger::Subscriber
24
+ # PutLogEvents limits, from the AWS documentation: a batch holds at most MAX_BATCH_EVENTS
25
+ # events, and at most MAX_BATCH_BYTES counting each event's message in UTF-8 plus
26
+ # EVENT_OVERHEAD_BYTES, in chronological order.
27
+ MAX_BATCH_BYTES = 1_048_576
28
+ MAX_BATCH_EVENTS = 10_000
29
+ EVENT_OVERHEAD_BYTES = 26
30
+
24
31
  attr_reader :client_kwargs, :group, :create_group, :create_stream,
25
32
  :force_flush_interval_seconds, :max_buffered_events,
26
33
  :task, :client, :buffered_logs
@@ -80,14 +87,19 @@ module SemanticLogger
80
87
  end
81
88
 
82
89
  # Method called to log an event
90
+ #
91
+ # Formats the log message now, so that one that cannot be formatted, or that CloudWatch Logs
92
+ # would reject, raises here for that message alone, and the buffer only holds log events
93
+ # ready to send.
83
94
  def log(log)
84
- buffered_logs << log
85
-
95
+ buffered_logs << log_event(log)
86
96
  put_log_events if buffered_logs.size >= max_buffered_events
97
+ true
87
98
  end
88
99
 
100
+ # Sends the buffered log messages on the calling thread, rather than waiting for the timer.
89
101
  def flush
90
- task.execute while buffered_logs.size.positive?
102
+ put_log_events while buffered_logs.size.positive?
91
103
  end
92
104
 
93
105
  def close
@@ -110,38 +122,121 @@ module SemanticLogger
110
122
  SemanticLogger::Formatters::Json.new
111
123
  end
112
124
 
125
+ # Only the log group and stream: the client options can hold AWS credentials. See
126
+ # Subscriber#inspect.
127
+ def inspect_details
128
+ "group=#{group.inspect} stream=#{stream.inspect}"
129
+ end
130
+
113
131
  private
114
132
 
133
+ # Returns [Hash] the PutLogEvents log event for the log message.
134
+ def log_event(log)
135
+ {timestamp: (log.time.to_f * 1000).floor, message: event_message(format_log(log))}
136
+ end
137
+
138
+ # Returns [String] the formatted log message in UTF-8, cut short to the most that a batch can
139
+ # hold.
140
+ #
141
+ # Raises FormatError for a message that CloudWatch Logs would reject, together with every
142
+ # other message in the same request: one that is not a String, or is empty.
143
+ def event_message(message)
144
+ unless message.is_a?(String)
145
+ raise(FormatError, "Could not format the log message (the formatter returned a #{message.class})")
146
+ end
147
+
148
+ message = Utils.encode_utf8_string(message)
149
+ raise(FormatError, "Could not format the log message (it is empty)") if message.empty?
150
+
151
+ limit = MAX_BATCH_BYTES - EVENT_OVERHEAD_BYTES
152
+ message.bytesize <= limit ? message : message.byteslice(0, limit).scrub("")
153
+ end
154
+
155
+ # Sends the buffered log events, in as many requests as the PutLogEvents limits need.
156
+ #
157
+ # A request that fails is reported to the internal logger rather than raised: this usually
158
+ # runs on the timer thread, which discards errors, and the log events were already taken off
159
+ # the buffer, so AWS rejecting a batch, for example one over the size limit, lost every
160
+ # message in it without a trace.
115
161
  def put_log_events
116
- logs = buffered_logs.shift(max_buffered_events)
162
+ events = buffered_logs.shift(max_buffered_events)
163
+ return if events.none?
164
+
165
+ batches(events).each { |batch| put_batch(batch) }
166
+ end
117
167
 
118
- return if logs.none?
168
+ # Returns [Array<Array<Hash>>] the log events in chronological order, as PutLogEvents requires,
169
+ # split into batches that it accepts.
170
+ def batches(events)
171
+ batches = [[]]
172
+ bytes = 0
173
+ # Keeps the order of log events with the same timestamp.
174
+ events.each_with_index.sort_by { |event, index| [event[:timestamp], index] }.each do |event, _index|
175
+ size = event[:message].bytesize + EVENT_OVERHEAD_BYTES
176
+ if batches.last.size >= MAX_BATCH_EVENTS || bytes + size > MAX_BATCH_BYTES
177
+ batches << []
178
+ bytes = 0
179
+ end
180
+ batches.last << event
181
+ bytes += size
182
+ end
183
+ batches.reject(&:empty?)
184
+ end
185
+
186
+ # Sends one batch, reporting any failure to the internal logger, so that the other batches are
187
+ # still sent.
188
+ def put_batch(events)
189
+ response = send_batch(events)
190
+ report_rejected_events(events, response.rejected_log_events_info)
191
+ rescue StandardError => e
192
+ logger.error("Could not send #{events.size} log message(s) to CloudWatch Logs", e)
193
+ end
119
194
 
195
+ # Returns the PutLogEvents response, after creating the log group or stream when it is missing
196
+ # and this appender may create it.
197
+ def send_batch(events)
198
+ created = []
120
199
  begin
121
- client.put_log_events({
122
- log_group_name: group,
123
- log_stream_name: stream,
124
- log_events: logs.map do |log|
125
- {
126
- timestamp: (log.time.to_f * 1000).floor,
127
- message: formatter.call(log, self)
128
- }
129
- end
130
- })
200
+ client.put_log_events(log_group_name: group, log_stream_name: stream, log_events: events)
131
201
  rescue Aws::CloudWatchLogs::Errors::ResourceNotFoundException => e
132
- if e.message.include?("log group does not exist.") && create_group
133
- client.create_log_group({
134
- log_group_name: group
135
- })
136
- retry
137
- elsif e.message.include?("log stream does not exist.") && create_stream
138
- client.create_log_stream({
139
- log_group_name: group,
140
- log_stream_name: stream
141
- })
142
- retry
143
- end
202
+ resource = missing_resource(e)
203
+ raise if resource.nil? || created.include?(resource)
204
+
205
+ create(resource)
206
+ created << resource
207
+ retry
208
+ end
209
+ end
210
+
211
+ # Returns [:group | :stream | nil] the missing resource that this appender may create.
212
+ def missing_resource(exception)
213
+ if exception.message.include?("log group does not exist.") && create_group
214
+ :group
215
+ elsif exception.message.include?("log stream does not exist.") && create_stream
216
+ :stream
217
+ end
218
+ end
219
+
220
+ def create(resource)
221
+ if resource == :group
222
+ client.create_log_group(log_group_name: group)
223
+ else
224
+ client.create_log_stream(log_group_name: group, log_stream_name: stream)
144
225
  end
226
+ rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
227
+ # Created meanwhile, for example by another process logging to the same log group.
228
+ nil
229
+ end
230
+
231
+ # PutLogEvents accepts a request in which it rejects some of the log events: those more than 2
232
+ # hours in the future, older than 14 days, or older than the log group's retention period.
233
+ def report_rejected_events(events, rejected)
234
+ return unless rejected
235
+
236
+ logger.warn(
237
+ "CloudWatch Logs rejected some of #{events.size} log message(s) as too new, too old, or expired",
238
+ rejected.to_h
239
+ )
145
240
  end
146
241
 
147
242
  def stream
@@ -154,27 +154,39 @@ module SemanticLogger
154
154
  reopen
155
155
  end
156
156
 
157
+ # The url is left to the client gem to parse, since it also takes a comma separated list of
158
+ # urls, an Array of them, or hosts without a scheme.
157
159
  def reopen
158
- @client = client_class.new(@client_args)
160
+ @client = Utils.without_url_in_errors(option: ":url or :hosts") { client_class.new(@client_args) }
159
161
  end
160
162
 
161
163
  # Log to the index for today
162
164
  def log(log)
163
- bulk_payload = formatter.call(log, self)
164
- bulk_write([bulk_index(log), bulk_payload])
165
+ bulk_write([bulk_index(log), bulk_line(log)])
165
166
  true
166
167
  end
167
168
 
168
169
  def batch(logs)
169
170
  messages = []
170
171
  logs.each do |log|
171
- messages << bulk_index(log) << formatter.call(log, self)
172
+ messages << bulk_index(log) << bulk_line(log)
172
173
  end
173
174
 
174
175
  bulk_write(messages)
175
176
  true
176
177
  end
177
178
 
179
+ # The whole batch is formatted into a single bulk request before anything is sent.
180
+ def batch_all_or_nothing?
181
+ true
182
+ end
183
+
184
+ # The url without its credentials, and the index. See Subscriber#inspect.
185
+ def inspect_details
186
+ url_detail = @client_args.key?(:url) ? "url=#{Utils.redacted_url(url).inspect} " : ""
187
+ "#{url_detail}index=#{index.inspect}"
188
+ end
189
+
178
190
  private
179
191
 
180
192
  # The backing client class, e.g. ::Elasticsearch::Client or ::OpenSearch::Client.
@@ -182,12 +194,25 @@ module SemanticLogger
182
194
  raise NotImplementedError, "#{self.class} must implement #client_class"
183
195
  end
184
196
 
197
+ # Returns [String] the log message formatted as a bulk line, serialized by Utils.to_json
198
+ # and handed to the client as a String. The client gem's serializer writes Infinity and NaN
199
+ # as bare words, which is not JSON, and does not repair invalid UTF-8, a structure nested too
200
+ # deeply, or a Struct (written through its #inspect), so such an entry was dropped or its
201
+ # objects' instance variables were written. Serializing is part of formatting, so an error
202
+ # doing so discards the message rather than stalling the appender (see #format_log). A
203
+ # formatter that returns a String is taken to have serialized the line itself.
204
+ def bulk_line(log)
205
+ format_log(log) { |document| document.is_a?(String) ? document : Utils.to_json(document) }
206
+ end
207
+
208
+ # Serializes the action lines; the documents already are (see #bulk_line).
185
209
  def bulk_write(messages)
210
+ body = messages.map { |message| message.is_a?(String) ? message : message.to_json }
186
211
  bulk_result =
187
212
  if @data_stream
188
- @client.bulk(index: index, body: messages)
213
+ @client.bulk(index: index, body: body)
189
214
  else
190
- @client.bulk(body: messages)
215
+ @client.bulk(body: body)
191
216
  end
192
217
 
193
218
  return unless bulk_result["errors"]
@@ -73,7 +73,7 @@ module SemanticLogger
73
73
 
74
74
  # Log to the index for today.
75
75
  def log(log)
76
- post(formatter.call(log, self), log.time.strftime(@logging_path))
76
+ post(format_log(log), log.time.strftime(@logging_path))
77
77
  end
78
78
 
79
79
  # Deletes all log data captured for a day.
@@ -10,6 +10,15 @@ module SemanticLogger
10
10
  :reopen_period, :reopen_count, :reopen_size, :permissions
11
11
  attr_reader :log_count, :log_size, :current_file_name, :reopen_at
12
12
 
13
+ # With permissions, the log file is opened without following a symbolic link, so that a link
14
+ # planted in the log directory can neither create nor open the file it points at, without
15
+ # blocking, so that a planted FIFO fails to open instead of waiting for a reader, and without
16
+ # becoming the controlling terminal.
17
+ SECURE_OPEN_FLAGS = ::File::NOFOLLOW | ::File::NONBLOCK | ::File::NOCTTY
18
+ # Seconds to wait before opening a refused log file again.
19
+ REFUSED_RETRY_INTERVAL = 5
20
+ private_constant :SECURE_OPEN_FLAGS, :REFUSED_RETRY_INTERVAL
21
+
13
22
  # Create an appender to log to a named file.
14
23
  #
15
24
  # Parameters
@@ -77,7 +86,12 @@ module SemanticLogger
77
86
  # Octal file permissions to apply to the log file.
78
87
  # Log files frequently contain sensitive information, so restrict access
79
88
  # by supplying for example `0o640` (owner read/write, group read).
80
- # Applied both when the file is created and to an existing log file.
89
+ # Applied both when the file is created and to an existing log file, which must be
90
+ # a regular file owned by the process, with a single hard link, that the file_name
91
+ # names directly rather than through a symbolic link. Nothing is written to any
92
+ # other file, or when the file cannot be opened: the appender raises IOError when it
93
+ # is created, and when the file is later reopened, it reports why and writes nothing
94
+ # until the file is fixed, trying again every few seconds.
81
95
  # Default: nil (use the process umask, the standard Ruby behavior)
82
96
  #
83
97
  # :retry_count [Integer]
@@ -153,45 +167,34 @@ module SemanticLogger
153
167
 
154
168
  # Open the file at creation time so that misconfiguration (bad directory,
155
169
  # insufficient permissions, ...) raises immediately in SemanticLogger.add_appender,
156
- # instead of surfacing later on the appender thread via the internal logger.
157
- reopen
170
+ # instead of surfacing later on the appender thread via the internal logger. With
171
+ # permissions, a refused log file raises IOError (see #open_with_permissions).
172
+ raise(IOError, refusal_message, cause: @refusal_cause) unless reopen
158
173
  end
159
174
 
160
175
  # After forking an active process call #reopen to re-open
161
176
  # open the file handles etc to resources.
162
177
  def reopen
163
- begin
164
- @file&.close
165
- rescue StandardError
166
- nil
167
- end
178
+ close_quietly(@file)
168
179
 
169
180
  self.current_file_name = apply_format_directives(file_name)
170
- if ::File.directory?(file_name)
181
+ # With permissions, a directory is refused when it fails to open, like any other file.
182
+ if !permissions && ::File.directory?(current_file_name)
171
183
  raise(ArgumentError, "The supplied log file_name: #{current_file_name} is already a directory.")
172
184
  end
173
185
 
174
186
  self.log_count = 0
175
- if append && reopen_size && ::File.exist?(current_file_name)
176
- self.log_size = ::File.size(current_file_name)
177
- self.log_size = 0 if log_size >= reopen_size
178
- else
179
- self.log_size = 0
180
- end
187
+ # File.size? rather than File.size, since the file can be removed at any time.
188
+ size = ::File.size?(current_file_name) if append && reopen_size
189
+ self.log_size = size && size < reopen_size ? size : 0
181
190
 
182
191
  self.reopen_at = reopen_period ? next_reopen_period(reopen_period) : nil
183
192
 
184
193
  options = ::File::WRONLY | ::File::CREAT
185
194
  options |= ::File::APPEND if append
186
- @file =
187
- if permissions
188
- ::File.open(current_file_name, options, permissions)
189
- else
190
- ::File.open(current_file_name, options)
191
- end
192
- # File.open only applies the permissions when creating the file, so also
193
- # enforce them on an already existing log file.
194
- @file.chmod(permissions) if permissions
195
+ @file = permissions ? open_with_permissions(options) : ::File.open(current_file_name, options)
196
+ return unless @file
197
+
195
198
  # Force all log entries to write immediately without buffering
196
199
  # Allows multiple processes to write to the same log file simultaneously
197
200
  @file.sync = true
@@ -203,18 +206,23 @@ module SemanticLogger
203
206
  # Since only one appender thread will be writing to the file at a time
204
207
  # it is not necessary to protect access to the file with a semaphore.
205
208
  def log(log)
206
- reopen if time_to_reopen?
209
+ # Read once, since #reopen can be called from another thread meanwhile.
210
+ file = time_to_reopen? ? reopen : @file
211
+ return refused unless file
207
212
 
208
- count = 0
213
+ # Formatted once, outside the retry below: reopening the file cannot fix the content.
214
+ message = format_log(log) << "\n"
215
+ count = 0
209
216
  begin
210
- message = formatter.call(log, self) << "\n"
211
- @file.write(message)
217
+ file.write(message)
212
218
  @log_count += 1
213
219
  @log_size += message.size
214
220
  rescue StandardError => e
215
221
  if count < retry_count
216
222
  count += 1
217
- reopen
223
+ file = reopen
224
+ return refused unless file
225
+
218
226
  retry
219
227
  end
220
228
  raise(e)
@@ -228,18 +236,118 @@ module SemanticLogger
228
236
  @file&.flush
229
237
  end
230
238
 
239
+ # See Subscriber#inspect.
240
+ def inspect_details
241
+ "file_name=#{file_name.inspect}"
242
+ end
243
+
231
244
  private
232
245
 
233
246
  attr_writer :log_count, :log_size, :current_file_name, :reopen_at
234
247
 
248
+ # Opens the log file and applies the permissions to it, when it is safe to do so (see
249
+ # #permissions_refusal). Returns the open file, or nil after closing it and recording why it is
250
+ # refused, so that nothing is written to it.
251
+ #
252
+ # Changing the permissions of a file that another name links to could loosen them on an
253
+ # unrelated file, such as a private key linked into a shared log directory. Writing log messages
254
+ # to a file that this process does not own, or that another name links to, would let whoever
255
+ # controls it read them, despite the permissions asked for. So with permissions, the appender
256
+ # fails closed: it raises when it is created (see #initialize), and when the file is later
257
+ # reopened, on a rotation, a fork, or a write retry, it stops writing and reports why (see
258
+ # #refused) until the file is fixed (see #refused_retry_due?).
259
+ def open_with_permissions(options)
260
+ file = ::File.open(current_file_name, options | SECURE_OPEN_FLAGS, permissions)
261
+ reason = permissions_refusal(file)
262
+ if reason
263
+ close_quietly(file)
264
+ return refuse(reason)
265
+ end
266
+
267
+ file.chmod(permissions)
268
+ @refusal = @refusal_cause = @reported_refusal = nil
269
+ file
270
+ rescue SystemCallError => e
271
+ close_quietly(file)
272
+ refuse(open_refusal(e), e)
273
+ end
274
+
275
+ # Records why the log file is refused, and returns nil.
276
+ def refuse(reason, cause = nil)
277
+ @refusal = reason
278
+ @refusal_cause = cause
279
+ @refused_retry_at = monotonic_time + REFUSED_RETRY_INTERVAL
280
+ nil
281
+ end
282
+
283
+ # Reports why log messages are not being written to the log file, once for each file and
284
+ # reason, and returns false.
285
+ def refused
286
+ refusal = [current_file_name, @refusal]
287
+ unless @reported_refusal == refusal
288
+ @reported_refusal = refusal
289
+ logger.error(refusal_message)
290
+ end
291
+ false
292
+ end
293
+
294
+ def refusal_message
295
+ "Not writing to the log file #{current_file_name}: #{@refusal}"
296
+ end
297
+
298
+ # Returns why the log file could not be opened with permissions.
299
+ def open_refusal(error)
300
+ return "it is a symbolic link" if error.is_a?(Errno::ELOOP) && ::File.symlink?(current_file_name)
301
+
302
+ "it could not be opened (#{error.message})"
303
+ end
304
+
305
+ # Returns why the permissions must not be applied to the open log file, nor log messages
306
+ # written to it, or nil when they can.
307
+ #
308
+ # It must be a regular file owned by this process, with a single hard link, since a hard link
309
+ # planted in the log directory is otherwise indistinguishable from the file it links to. The
310
+ # path must also still name it, so that swapping the path after opening cannot redirect the
311
+ # log messages.
312
+ def permissions_refusal(file)
313
+ stat = file.stat
314
+ path_stat = ::File.lstat(current_file_name)
315
+ return "it is not a regular file" unless stat.file?
316
+ return "it is not owned by this process" unless stat.uid == ::Process.euid
317
+ return "it has more than one hard link" unless stat.nlink == 1
318
+ return "it changed after it was opened" unless path_stat.dev == stat.dev && path_stat.ino == stat.ino
319
+
320
+ nil
321
+ rescue SystemCallError => e
322
+ "it could not be checked (#{e.message})"
323
+ end
324
+
325
+ def close_quietly(file)
326
+ file&.close
327
+ rescue StandardError
328
+ nil
329
+ end
330
+
331
+ def monotonic_time
332
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
333
+ end
334
+
235
335
  def time_to_reopen?
236
- return true unless @file
336
+ return refused_retry_due? unless @file
237
337
 
238
338
  (reopen_count.positive? && (log_count >= reopen_count)) ||
239
339
  (reopen_size.positive? && (log_size >= reopen_size)) ||
240
340
  (reopen_at && (Time.now > reopen_at))
241
341
  end
242
342
 
343
+ # A refused log file is opened again every few seconds, and at the next scheduled reopen,
344
+ # rather than for every message.
345
+ def refused_retry_due?
346
+ return true unless @refused_retry_at
347
+
348
+ monotonic_time >= @refused_retry_at || (reopen_at && Time.now > reopen_at)
349
+ end
350
+
243
351
  def apply_format_directives(file_name)
244
352
  return file_name unless file_name.include?("%")
245
353
 
@@ -100,7 +100,7 @@ module SemanticLogger
100
100
 
101
101
  # Re-open after process fork
102
102
  def reopen
103
- uri = URI.parse(@url)
103
+ uri = Utils.parse_url(@url)
104
104
  @server = uri.host
105
105
  @port = uri.port
106
106
  @protocol = uri.scheme.to_sym
@@ -120,10 +120,10 @@ module SemanticLogger
120
120
  def call(log, logger)
121
121
  h = default_formatter.call(log, logger)
122
122
 
123
- h[:short_message] = h.delete(:message)
124
- if h[:short_message].nil?
125
- h[:short_message] = log.exception.nil? ? "<no-exception-message>" : log.exception.message
126
- end
123
+ # GELF rejects an empty short_message, such as a message of only ANSI escape codes.
124
+ h[:short_message] = h.delete(:message).to_s
125
+ h[:short_message] = log.exception.message.to_s.strip if h[:short_message].empty? && log.exception
126
+ h[:short_message] = "<no-exception-message>" if h[:short_message].empty?
127
127
  h[:level] = logger.level_map[log.level]
128
128
  h[:level_str] = log.level.to_s
129
129
  h[:duration_str] = h.delete(:duration)
@@ -131,11 +131,27 @@ module SemanticLogger
131
131
  end
132
132
 
133
133
  # Forward log messages
134
+ #
135
+ # GELF serializes the Hash with #to_json, which raises for Infinity, NaN, invalid UTF-8, or a
136
+ # structure nested too deeply, and writes a Struct through its #inspect, so such a Hash is
137
+ # handed over as a copy that JSON can represent (see Utils.json_ready).
134
138
  def log(log)
135
- notifier.notify!(formatter.call(log, self))
139
+ notifier.notify!(format_log(log) { |hash| Utils.json_ready(hash) })
136
140
  true
137
141
  end
138
142
 
143
+ # GELF raises ArgumentError for a log message it cannot send, such as one too large to fit in
144
+ # the maximum number of chunks, which retrying cannot send either. Unavailable servers raise
145
+ # network errors instead.
146
+ def message_error?(exception)
147
+ exception.is_a?(ArgumentError) || super
148
+ end
149
+
150
+ # The url without any credentials. See Subscriber#inspect.
151
+ def inspect_details
152
+ "url=#{Utils.redacted_url(url).inspect}"
153
+ end
154
+
139
155
  private
140
156
 
141
157
  def default_formatter
@@ -43,7 +43,7 @@ module SemanticLogger
43
43
 
44
44
  # Send an error notification to honeybadger
45
45
  def log(log)
46
- context = formatter.call(log, self)
46
+ context = format_log(log)
47
47
  if log.exception
48
48
  context.delete(:exception)
49
49
  ::Honeybadger.notify(log.exception, context)
@@ -43,7 +43,7 @@ module SemanticLogger
43
43
 
44
44
  # Send log to honeybadger events API
45
45
  def log(log)
46
- event = formatter.call(log, self).merge(event_type: "log")
46
+ event = format_log(log).merge(event_type: "log")
47
47
 
48
48
  ::Honeybadger.event(event)
49
49