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.
- checksums.yaml +4 -4
- data/README.md +13 -12
- data/Rakefile +15 -4
- data/docs/api.md +37 -11
- data/docs/appenders.md +123 -39
- data/docs/config.md +115 -13
- data/docs/index.md +31 -5
- data/docs/log.md +19 -11
- data/docs/metrics.md +5 -2
- data/docs/operations.md +63 -17
- data/docs/rails.md +77 -3
- data/docs/security.md +183 -16
- data/docs/testing.md +5 -3
- data/docs/upgrading.md +295 -7
- data/lib/semantic_logger/appender/async.rb +18 -5
- data/lib/semantic_logger/appender/bugsnag.rb +1 -1
- data/lib/semantic_logger/appender/cloudwatch_logs.rb +122 -27
- data/lib/semantic_logger/appender/elasticsearch_base.rb +31 -6
- data/lib/semantic_logger/appender/elasticsearch_http.rb +1 -1
- data/lib/semantic_logger/appender/file.rb +138 -30
- data/lib/semantic_logger/appender/graylog.rb +22 -6
- data/lib/semantic_logger/appender/honeybadger.rb +1 -1
- data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
- data/lib/semantic_logger/appender/http.rb +70 -11
- data/lib/semantic_logger/appender/io.rb +1 -1
- data/lib/semantic_logger/appender/kafka.rb +26 -12
- data/lib/semantic_logger/appender/loki.rb +13 -3
- data/lib/semantic_logger/appender/mongodb.rb +20 -1
- data/lib/semantic_logger/appender/new_relic.rb +1 -1
- data/lib/semantic_logger/appender/new_relic_logs.rb +9 -11
- data/lib/semantic_logger/appender/open_telemetry.rb +2 -2
- data/lib/semantic_logger/appender/rabbitmq.rb +6 -1
- data/lib/semantic_logger/appender/sentry.rb +1 -1
- data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
- data/lib/semantic_logger/appender/splunk.rb +10 -1
- data/lib/semantic_logger/appender/syslog.rb +64 -26
- data/lib/semantic_logger/appender/tcp.rb +6 -1
- data/lib/semantic_logger/appender/udp.rb +11 -1
- data/lib/semantic_logger/appender/wrapper.rb +1 -1
- data/lib/semantic_logger/appenders.rb +9 -1
- data/lib/semantic_logger/base.rb +82 -30
- data/lib/semantic_logger/exception_details.rb +50 -0
- data/lib/semantic_logger/formatters/base.rb +52 -6
- data/lib/semantic_logger/formatters/color.rb +18 -2
- data/lib/semantic_logger/formatters/default.rb +22 -5
- data/lib/semantic_logger/formatters/logfmt.rb +153 -21
- data/lib/semantic_logger/formatters/loki.rb +26 -22
- data/lib/semantic_logger/formatters/one_line.rb +67 -3
- data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
- data/lib/semantic_logger/formatters/pattern.rb +8 -4
- data/lib/semantic_logger/formatters/raw.rb +4 -5
- data/lib/semantic_logger/formatters/syslog.rb +110 -18
- data/lib/semantic_logger/formatters/syslog_cee.rb +29 -16
- data/lib/semantic_logger/levels.rb +10 -1
- data/lib/semantic_logger/log.rb +183 -37
- data/lib/semantic_logger/loggable.rb +26 -3
- data/lib/semantic_logger/metric/new_relic.rb +1 -1
- data/lib/semantic_logger/metric/signalfx.rb +8 -6
- data/lib/semantic_logger/metric/statsd.rb +15 -7
- data/lib/semantic_logger/queue_processor/request.rb +55 -0
- data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
- data/lib/semantic_logger/queue_processor.rb +165 -66
- data/lib/semantic_logger/semantic_logger.rb +15 -2
- data/lib/semantic_logger/subscriber.rb +96 -10
- data/lib/semantic_logger/sync_processor.rb +11 -0
- data/lib/semantic_logger/test/capture_log_events.rb +8 -5
- data/lib/semantic_logger/utils.rb +244 -16
- data/lib/semantic_logger/version.rb +1 -1
- data/lib/semantic_logger.rb +1 -1
- metadata +21 -5
- data/lib/semantic_logger/core_ext/thread.rb +0 -30
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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) <<
|
|
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:
|
|
213
|
+
@client.bulk(index: index, body: body)
|
|
189
214
|
else
|
|
190
|
-
@client.bulk(body:
|
|
215
|
+
@client.bulk(body: body)
|
|
191
216
|
end
|
|
192
217
|
|
|
193
218
|
return unless bulk_result["errors"]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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!(
|
|
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
|