logstash-logger 0.26.1 → 1.1.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/.github/workflows/tests.yml +53 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +85 -105
- data/Appraisals +6 -20
- data/CHANGELOG.md +61 -0
- data/Gemfile +2 -2
- data/README.md +12 -833
- data/docs/buffering.md +86 -0
- data/docs/customization.md +86 -0
- data/docs/outputs.md +65 -0
- data/docs/rails.md +349 -0
- data/docs/ssl.md +102 -0
- data/docs/troubleshooting.md +106 -0
- data/docs/usage.md +148 -0
- data/gemfiles/rails_8.0.gemfile +10 -0
- data/gemfiles/rails_8.1.gemfile +9 -0
- data/lib/logstash-logger/buffer.rb +15 -5
- data/lib/logstash-logger/configuration.rb +1 -2
- data/lib/logstash-logger/device/aws_stream.rb +96 -17
- data/lib/logstash-logger/device/base.rb +2 -2
- data/lib/logstash-logger/device/connectable.rb +30 -12
- data/lib/logstash-logger/device/file.rb +21 -4
- data/lib/logstash-logger/device/firehose.rb +6 -0
- data/lib/logstash-logger/device/http.rb +104 -0
- data/lib/logstash-logger/device/kafka.rb +182 -38
- data/lib/logstash-logger/device/kinesis.rb +10 -1
- data/lib/logstash-logger/device/redis.rb +8 -1
- data/lib/logstash-logger/device/tcp.rb +54 -12
- data/lib/logstash-logger/device.rb +24 -2
- data/lib/logstash-logger/formatter/base.rb +97 -11
- data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
- data/lib/logstash-logger/formatter/json.rb +13 -0
- data/lib/logstash-logger/formatter/json_lines.rb +13 -0
- data/lib/logstash-logger/formatter.rb +14 -6
- data/lib/logstash-logger/logger.rb +6 -19
- data/lib/logstash-logger/multi_logger.rb +2 -1
- data/lib/logstash-logger/railtie.rb +1 -1
- data/lib/logstash-logger/tagged_logging.rb +3 -1
- data/lib/logstash-logger/version.rb +1 -1
- data/logstash-logger.gemspec +8 -4
- data/spec/device/aws_stream_limits_spec.rb +219 -0
- data/spec/device/connectable_spec.rb +51 -0
- data/spec/device/file_spec.rb +65 -0
- data/spec/device/firehose_spec.rb +4 -1
- data/spec/device/http_spec.rb +176 -0
- data/spec/device/kafka_spec.rb +429 -14
- data/spec/device/kinesis_spec.rb +4 -1
- data/spec/device/tcp_spec.rb +94 -5
- data/spec/device/tcp_tls_integration_spec.rb +91 -0
- data/spec/device_spec.rb +13 -0
- data/spec/formatter/base_spec.rb +82 -1
- data/spec/formatter/cee_syslog_spec.rb +71 -19
- data/spec/formatter/framing_spec.rb +58 -0
- data/spec/formatter/json_lines_spec.rb +41 -0
- data/spec/formatter/json_spec.rb +49 -0
- data/spec/formatter_spec.rb +19 -2
- data/spec/logger_spec.rb +5 -5
- data/spec/multi_logger_spec.rb +16 -0
- data/spec/spec_helper.rb +7 -5
- data/spec/support/tls_helper.rb +30 -0
- data/spec/support/unserializable_value.rb +10 -0
- data/spec/tagged_logging_spec.rb +15 -0
- metadata +101 -16
- data/.travis.yml +0 -26
- data/gemfiles/rails_3.2.gemfile +0 -9
- data/gemfiles/rails_4.0.gemfile +0 -9
- data/gemfiles/rails_4.1.gemfile +0 -9
- data/gemfiles/rails_4.2.gemfile +0 -9
- data/gemfiles/rails_5.0.gemfile +0 -9
- data/gemfiles/rails_5.1.gemfile +0 -9
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
|
|
4
|
+
module LogStashLogger
|
|
5
|
+
module Device
|
|
6
|
+
|
|
7
|
+
# Rudimentary write to Logstash HTTP Input relying on buffering
|
|
8
|
+
# rather than persistent HTTP connections for efficiency.
|
|
9
|
+
class HTTP < Connectable
|
|
10
|
+
|
|
11
|
+
# Request body formats, with the Content-Type sent by default for each.
|
|
12
|
+
FORMATS = {
|
|
13
|
+
json_array: "application/json",
|
|
14
|
+
ndjson: "application/x-ndjson",
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_FORMAT = :json_array
|
|
18
|
+
|
|
19
|
+
# Statuses that reject the content of the request. Sending the same
|
|
20
|
+
# records again can never succeed, so the rejected records are found and
|
|
21
|
+
# dropped. Every other error status is retried.
|
|
22
|
+
CONTENT_REJECTED_STATUSES = [
|
|
23
|
+
400, # Bad Request: the body is malformed
|
|
24
|
+
413, # Content Too Large: the body is over the collector's size limit
|
|
25
|
+
415, # Unsupported Media Type: the collector does not accept this content type
|
|
26
|
+
422, # Unprocessable Content: the body is well-formed but cannot be processed
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(opts)
|
|
30
|
+
super
|
|
31
|
+
@url = URI(opts[:url])
|
|
32
|
+
@format = (opts[:format] || DEFAULT_FORMAT).to_sym
|
|
33
|
+
unless FORMATS.key?(@format)
|
|
34
|
+
raise ArgumentError, "Unknown HTTP body format: #{@format.inspect} (expected one of #{FORMATS.keys.map(&:inspect).join(', ')})"
|
|
35
|
+
end
|
|
36
|
+
@content_type = opts[:content_type] || FORMATS[@format]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def connect
|
|
40
|
+
# no-op
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write_one(message)
|
|
44
|
+
write_batch([message])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_batch(messages, _group = nil)
|
|
48
|
+
messages = messages.map(&:to_s)
|
|
49
|
+
resp = post(messages)
|
|
50
|
+
return if resp.is_a?(Net::HTTPSuccess)
|
|
51
|
+
raise upload_error(resp) unless content_rejected?(resp)
|
|
52
|
+
deliver_one_at_a_time(messages, resp)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def post(messages)
|
|
58
|
+
body = @format == :ndjson ? ndjson_body(messages) : json_array_body(messages)
|
|
59
|
+
Net::HTTP.post @url, body, {"Content-Type" => @content_type}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The collector rejected the content of the batch. Send the records one
|
|
63
|
+
# at a time and drop only the ones that are still rejected, so one bad
|
|
64
|
+
# record does not hold back, or take down, the rest.
|
|
65
|
+
def deliver_one_at_a_time(messages, batch_resp)
|
|
66
|
+
return drop(messages.first, batch_resp) if messages.size == 1
|
|
67
|
+
|
|
68
|
+
messages.each do |message|
|
|
69
|
+
resp = post([message])
|
|
70
|
+
next if resp.is_a?(Net::HTTPSuccess)
|
|
71
|
+
|
|
72
|
+
if content_rejected?(resp)
|
|
73
|
+
drop(message, resp)
|
|
74
|
+
else
|
|
75
|
+
raise upload_error(resp)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def content_rejected?(resp)
|
|
81
|
+
CONTENT_REJECTED_STATUSES.include?(resp.code.to_i)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def drop(message, resp)
|
|
85
|
+
log_warning("dropping a record of #{message.bytesize} bytes that the collector rejected: HTTP #{resp.code} #{resp.message}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def upload_error(resp)
|
|
89
|
+
RuntimeError.new("HTTP log upload failed: #{resp.code} #{resp.message}").tap { |e| log_error(e) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Logstash HTTP input expects a JSON array instead of lines of JSON
|
|
93
|
+
def json_array_body(messages)
|
|
94
|
+
"[#{messages.join(',')}]"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Newline-delimited JSON: one event per line, each ending with a newline
|
|
98
|
+
def ndjson_body(messages)
|
|
99
|
+
messages.map { |m| m.end_with?("\n") ? m : "#{m}\n" }.join
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -1,56 +1,200 @@
|
|
|
1
|
-
require 'poseidon'
|
|
2
|
-
|
|
3
1
|
module LogStashLogger
|
|
4
2
|
module Device
|
|
5
3
|
class Kafka < Connectable
|
|
4
|
+
class TLSConfiguration
|
|
5
|
+
attr_reader :ssl_ca_cert, :ssl_client_cert, :ssl_client_cert_key
|
|
6
|
+
|
|
7
|
+
def initialize(opts = {})
|
|
8
|
+
@ssl_ca_cert = opts[:ssl_ca_cert]
|
|
9
|
+
@ssl_client_cert = opts[:ssl_client_cert]
|
|
10
|
+
@ssl_client_cert_key = opts[:ssl_client_cert_key]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cert_bundle
|
|
14
|
+
@cert_bundle ||= all_cert_params? ? cert_params_as_hash : {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def valid?
|
|
18
|
+
all_cert_params? || no_cert_params?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def invalid?
|
|
22
|
+
!valid?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def cert_params_as_hash
|
|
28
|
+
{ ssl_ca_cert: @ssl_ca_cert,
|
|
29
|
+
ssl_client_cert: @ssl_client_cert,
|
|
30
|
+
ssl_client_cert_key: @ssl_client_cert_key,
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all_cert_params?
|
|
35
|
+
cert_params_as_hash.values.compact.length == valid_cert_params_length
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def no_cert_params?
|
|
39
|
+
cert_params_as_hash.values.compact.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def valid_cert_params_length
|
|
43
|
+
cert_params_as_hash.keys.length
|
|
44
|
+
end
|
|
45
|
+
end
|
|
6
46
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
super
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@
|
|
20
|
-
@topic = opts[:
|
|
21
|
-
@producer = opts[:producer] || DEFAULT_PRODUCER
|
|
22
|
-
@backoff = opts[:backoff] || DEFAULT_BACKOFF
|
|
47
|
+
attr_reader :topic, :brokers, :cert_bundle, :kafka_tls_configurator,
|
|
48
|
+
:client_id
|
|
49
|
+
|
|
50
|
+
# Errors that will recur on every retry of the same records. Looked up by
|
|
51
|
+
# name at rescue time, because ruby-kafka is only required in #initialize.
|
|
52
|
+
NOT_RETRYABLE_ERRORS = %w[MessageSizeTooLarge RecordListTooLarge BufferOverflow].freeze
|
|
53
|
+
|
|
54
|
+
def initialize(opts = {}, kafka_tls_configurator = TLSConfiguration)
|
|
55
|
+
require 'ruby-kafka'
|
|
56
|
+
super(opts)
|
|
57
|
+
|
|
58
|
+
opts = normalize_uri_options(opts)
|
|
59
|
+
@client_id = opts[:client_id]
|
|
60
|
+
@topic = normalize_topic(opts[:topic])
|
|
23
61
|
@buffer_group = @topic
|
|
62
|
+
@kafka_tls_configurator = kafka_tls_configurator
|
|
63
|
+
@brokers = make_brokers_array(opts[:brokers])
|
|
64
|
+
raise_no_brokers_set! if @brokers.empty?
|
|
65
|
+
make_cert_bundle(opts)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def connection
|
|
69
|
+
@io ||= ::Kafka.new(**kafka_client_connection_hash)
|
|
24
70
|
end
|
|
25
71
|
|
|
26
72
|
def connect
|
|
27
|
-
@io =
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def with_connection
|
|
31
|
-
connect unless connected?
|
|
32
|
-
yield
|
|
33
|
-
rescue ::Poseidon::Errors::ChecksumError, Poseidon::Errors::UnableToFetchMetadata => e
|
|
34
|
-
log_error(e)
|
|
35
|
-
log_warning("reconnect/retry")
|
|
36
|
-
sleep backoff if backoff
|
|
37
|
-
reconnect
|
|
38
|
-
retry
|
|
39
|
-
rescue => e
|
|
40
|
-
log_error(e)
|
|
41
|
-
log_warning("giving up")
|
|
42
|
-
close(flush: false)
|
|
73
|
+
@io = connection
|
|
43
74
|
end
|
|
44
75
|
|
|
45
|
-
def
|
|
76
|
+
def write_one(message, topic=nil)
|
|
46
77
|
topic ||= @topic
|
|
78
|
+
write_messages_to_broker_and_deliver do |producer|
|
|
79
|
+
producer.produce(message, topic: topic)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def write_batch(messages, topic=nil)
|
|
84
|
+
topic ||= @topic
|
|
85
|
+
write_messages_to_broker_and_deliver do |producer|
|
|
86
|
+
messages.each {|msg| producer.produce(msg, topic: topic) }
|
|
87
|
+
end
|
|
88
|
+
rescue => e
|
|
89
|
+
raise if retryable_error?(e)
|
|
90
|
+
deliver_one_at_a_time(messages, topic)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# One record the broker or client will never accept fails the whole batch.
|
|
96
|
+
# Send the records one at a time and drop only the ones that are still
|
|
97
|
+
# rejected. Retryable errors raise, so the buffer retries the batch.
|
|
98
|
+
def deliver_one_at_a_time(messages, topic)
|
|
99
|
+
messages.each do |message|
|
|
100
|
+
begin
|
|
101
|
+
write_one(message, topic)
|
|
102
|
+
rescue => e
|
|
103
|
+
raise if retryable_error?(e)
|
|
104
|
+
log_warning("dropping a record of #{message.to_s.bytesize} bytes that Kafka rejected: #{e.class}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def retryable_error?(error)
|
|
110
|
+
NOT_RETRYABLE_ERRORS.none? do |name|
|
|
111
|
+
::Kafka.const_defined?(name) && error.is_a?(::Kafka.const_get(name))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def write_messages_to_broker_and_deliver(&block)
|
|
47
116
|
with_connection do
|
|
48
|
-
|
|
117
|
+
kproducer = producer
|
|
118
|
+
block.call(kproducer) if block_given?
|
|
119
|
+
kproducer.deliver_messages
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def close!
|
|
124
|
+
begin
|
|
125
|
+
if @producer
|
|
126
|
+
if @producer.respond_to?(:shutdown)
|
|
127
|
+
@producer.shutdown
|
|
128
|
+
elsif @producer.respond_to?(:close)
|
|
129
|
+
@producer.close
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
ensure
|
|
133
|
+
@producer = nil
|
|
134
|
+
super
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def kafka_client_connection_hash
|
|
139
|
+
{ seed_brokers: @brokers,
|
|
140
|
+
client_id: @client_id,
|
|
141
|
+
}.merge(@cert_bundle)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def producer
|
|
145
|
+
@producer ||= (connected? ? @io : connection).producer
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def raise_no_topic_set!
|
|
149
|
+
fail ArgumentError, "a topic must be configured"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def raise_no_brokers_set!
|
|
153
|
+
fail ArgumentError, "brokers must be configured"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_topic(topic)
|
|
157
|
+
normalized = topic.to_s.strip
|
|
158
|
+
return raise_no_topic_set! if normalized.empty?
|
|
159
|
+
normalized
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def make_brokers_array(opt)
|
|
163
|
+
brokers =
|
|
164
|
+
case opt
|
|
165
|
+
when Array
|
|
166
|
+
opt.flatten
|
|
167
|
+
when String
|
|
168
|
+
opt.split(/\s+/)
|
|
169
|
+
else
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
brokers.compact.map(&:to_s).reject(&:empty?)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize_uri_options(opts)
|
|
176
|
+
normalized = opts.dup
|
|
177
|
+
if normalized[:brokers].nil?
|
|
178
|
+
if normalized[:hosts]
|
|
179
|
+
normalized[:brokers] = normalized[:hosts]
|
|
180
|
+
elsif normalized[:host]
|
|
181
|
+
broker = normalized[:host]
|
|
182
|
+
broker = "#{broker}:#{normalized[:port]}" if normalized[:port]
|
|
183
|
+
normalized[:brokers] = broker
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
if normalized[:topic].nil? && normalized[:path]
|
|
187
|
+
normalized[:topic] = normalized[:path].to_s.sub(%r{\A/}, '')
|
|
49
188
|
end
|
|
189
|
+
normalized
|
|
50
190
|
end
|
|
51
191
|
|
|
52
|
-
def
|
|
53
|
-
|
|
192
|
+
def make_cert_bundle(opts)
|
|
193
|
+
tls_conf = kafka_tls_configurator.new(opts)
|
|
194
|
+
if tls_conf.invalid?
|
|
195
|
+
fail ArgumentError, "all ssl parameters (ssl_ca_cert, ssl_client_cert and ssl_client_cert_key) are required or do use any of them to not use TLS"
|
|
196
|
+
end
|
|
197
|
+
@cert_bundle ||= tls_conf.cert_bundle
|
|
54
198
|
end
|
|
55
199
|
end
|
|
56
200
|
end
|
|
@@ -14,9 +14,18 @@ module LogStashLogger
|
|
|
14
14
|
"ServiceUnavailable",
|
|
15
15
|
"Throttling",
|
|
16
16
|
"RequestExpired",
|
|
17
|
-
"ProvisionedThroughputExceededException"
|
|
17
|
+
"ProvisionedThroughputExceededException",
|
|
18
|
+
"InternalFailure"
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
21
|
+
# https://docs.aws.amazon.com/kinesis/latest/APIReference/API_PutRecords.html
|
|
22
|
+
# Records count the partition key. 1 MiB is the default and smallest
|
|
23
|
+
# per-stream maximum; raise it with aws_max_record_bytes if the stream
|
|
24
|
+
# is configured for larger records.
|
|
25
|
+
def self.max_records; 500; end
|
|
26
|
+
def self.max_record_bytes; 1_048_576; end
|
|
27
|
+
def self.max_request_bytes; 10_485_760; end
|
|
28
|
+
|
|
20
29
|
def transform_message(message)
|
|
21
30
|
{
|
|
22
31
|
data: message,
|
|
@@ -13,6 +13,7 @@ module LogStashLogger
|
|
|
13
13
|
@buffer_group = @list
|
|
14
14
|
|
|
15
15
|
normalize_path(opts)
|
|
16
|
+
delete_unknown_keywords(opts)
|
|
16
17
|
|
|
17
18
|
@redis_options = opts
|
|
18
19
|
end
|
|
@@ -59,11 +60,17 @@ module LogStashLogger
|
|
|
59
60
|
def normalize_path(opts)
|
|
60
61
|
path = opts.fetch(:path, nil)
|
|
61
62
|
if path
|
|
62
|
-
opts[:db] = path.
|
|
63
|
+
opts[:db] = path.delete("/").to_i unless path.empty?
|
|
63
64
|
opts.delete(:path)
|
|
64
65
|
end
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
def delete_unknown_keywords(opts)
|
|
69
|
+
# due to restrictions in redis client version >= 5
|
|
70
|
+
# Continous error is being logged for unknown keywords, at present there is only one i.e. :sync
|
|
71
|
+
# to prevent that adding following line :)
|
|
72
|
+
opts.delete(:sync)
|
|
73
|
+
end
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'openssl'
|
|
2
|
+
require 'ipaddr'
|
|
2
3
|
|
|
3
4
|
module LogStashLogger
|
|
4
5
|
module Device
|
|
@@ -12,16 +13,17 @@ module LogStashLogger
|
|
|
12
13
|
@ssl_context = opts[:ssl_context]
|
|
13
14
|
@use_ssl = !!(@ssl_certificate || opts[:ssl_context])
|
|
14
15
|
@use_ssl = opts[:ssl_enable] if opts.has_key? :ssl_enable
|
|
15
|
-
if opts.has_key?(:use_ssl)
|
|
16
|
-
@use_ssl = opts[:use_ssl]
|
|
17
|
-
warn "[LogStashLogger] The use_ssl option is deprecated. Use ssl_enable instead."
|
|
18
|
-
end
|
|
19
16
|
@verify_hostname = opts.fetch(:verify_hostname, true)
|
|
17
|
+
validate_ssl_certificate! if use_ssl? && @ssl_certificate && !@ssl_context
|
|
20
18
|
end
|
|
21
19
|
|
|
20
|
+
# The TLS context used for the connection. Without an operator-supplied
|
|
21
|
+
# :ssl_context, the server certificate is always verified: against the
|
|
22
|
+
# :ssl_certificate file when one is given, otherwise against the system
|
|
23
|
+
# trust store.
|
|
22
24
|
def ssl_context
|
|
23
25
|
return unless use_ssl?
|
|
24
|
-
@ssl_context || certificate_context
|
|
26
|
+
@ssl_context || certificate_context || default_context
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def use_ssl?
|
|
@@ -41,7 +43,7 @@ module LogStashLogger
|
|
|
41
43
|
ssl_io
|
|
42
44
|
else
|
|
43
45
|
tcp_io
|
|
44
|
-
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
protected
|
|
@@ -53,20 +55,60 @@ module LogStashLogger
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def ssl_io
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
OpenSSL::SSL::SSLSocket.new(
|
|
58
|
+
socket = tcp_io
|
|
59
|
+
begin
|
|
60
|
+
OpenSSL::SSL::SSLSocket.new(socket, ssl_context).tap do |ssl|
|
|
61
|
+
# Closing the TLS socket also closes the TCP socket underneath it
|
|
62
|
+
ssl.sync_close = true
|
|
63
|
+
# SNI is only sent for DNS names, never for IP addresses
|
|
64
|
+
ssl.hostname = hostname unless ip_address?(hostname)
|
|
65
|
+
end
|
|
66
|
+
rescue StandardError
|
|
67
|
+
socket.close rescue nil
|
|
68
|
+
raise
|
|
69
|
+
end
|
|
59
70
|
end
|
|
60
71
|
|
|
72
|
+
# Trusts the certificate (or CA bundle) in the :ssl_certificate file, in
|
|
73
|
+
# addition to the system trust store.
|
|
61
74
|
def certificate_context
|
|
62
75
|
return unless @ssl_certificate
|
|
63
|
-
@certificate_context ||=
|
|
64
|
-
|
|
76
|
+
@certificate_context ||= verifying_context(ca_file: @ssl_certificate)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Used when TLS is enabled without an :ssl_context or :ssl_certificate.
|
|
80
|
+
def default_context
|
|
81
|
+
@default_context ||= verifying_context
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verifying_context(params = {})
|
|
85
|
+
OpenSSL::SSL::SSLContext.new.tap do |ctx|
|
|
86
|
+
# VERIFY_PEER, hostname verification and the system certificate store
|
|
87
|
+
ctx.set_params(params)
|
|
88
|
+
# Honour an explicit verify_hostname: false
|
|
89
|
+
ctx.verify_hostname = !!@verify_hostname
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_ssl_certificate!
|
|
94
|
+
path = @ssl_certificate.to_s
|
|
95
|
+
unless ::File.file?(path) && ::File.readable?(path)
|
|
96
|
+
raise ArgumentError, "ssl_certificate must be a readable certificate file: #{path.inspect}"
|
|
65
97
|
end
|
|
98
|
+
OpenSSL::X509::Certificate.new(::File.read(path))
|
|
99
|
+
rescue OpenSSL::X509::CertificateError => e
|
|
100
|
+
raise ArgumentError, "ssl_certificate #{path.inspect} is not a PEM or DER certificate: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ip_address?(name)
|
|
104
|
+
IPAddr.new(name.to_s)
|
|
105
|
+
true
|
|
106
|
+
rescue IPAddr::Error
|
|
107
|
+
false
|
|
66
108
|
end
|
|
67
109
|
|
|
68
110
|
def verify_hostname?
|
|
69
|
-
return false unless
|
|
111
|
+
return false unless use_ssl?
|
|
70
112
|
!! @verify_hostname
|
|
71
113
|
end
|
|
72
114
|
|
|
@@ -20,6 +20,7 @@ module LogStashLogger
|
|
|
20
20
|
autoload :Stderr, 'logstash-logger/device/stderr'
|
|
21
21
|
autoload :Balancer, 'logstash-logger/device/balancer'
|
|
22
22
|
autoload :MultiDelegator, 'logstash-logger/device/multi_delegator'
|
|
23
|
+
autoload :HTTP, 'logstash-logger/device/http'
|
|
23
24
|
|
|
24
25
|
def self.new(opts)
|
|
25
26
|
opts = opts.dup
|
|
@@ -27,7 +28,7 @@ module LogStashLogger
|
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def self.build_device(opts)
|
|
30
|
-
if parsed_uri_opts = parse_uri_config(opts)
|
|
31
|
+
if (parsed_uri_opts = parse_uri_config(opts))
|
|
31
32
|
opts.delete(:uri)
|
|
32
33
|
opts.merge!(parsed_uri_opts)
|
|
33
34
|
end
|
|
@@ -38,10 +39,30 @@ module LogStashLogger
|
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def self.parse_uri_config(opts)
|
|
41
|
-
if uri = opts[:uri]
|
|
42
|
+
if (uri = opts[:uri])
|
|
42
43
|
require 'uri'
|
|
43
44
|
parsed = ::URI.parse(uri)
|
|
44
45
|
{type: parsed.scheme, host: parsed.host, port: parsed.port, path: parsed.path}
|
|
46
|
+
.merge(parse_uri_query(parsed.query))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.parse_uri_query(query)
|
|
51
|
+
return {} unless query && !query.empty?
|
|
52
|
+
::URI.decode_www_form(query).each_with_object({}) do |(key, value), acc|
|
|
53
|
+
next unless key
|
|
54
|
+
key = key.to_s.strip
|
|
55
|
+
next if key.empty?
|
|
56
|
+
acc[key.to_sym] = cast_uri_value(key, value)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.cast_uri_value(key, value)
|
|
61
|
+
case key
|
|
62
|
+
when 'buffer_max_items'
|
|
63
|
+
value.to_i
|
|
64
|
+
else
|
|
65
|
+
value
|
|
45
66
|
end
|
|
46
67
|
end
|
|
47
68
|
|
|
@@ -60,6 +81,7 @@ module LogStashLogger
|
|
|
60
81
|
when :stderr then Stderr
|
|
61
82
|
when :multi_delegator then MultiDelegator
|
|
62
83
|
when :balancer then Balancer
|
|
84
|
+
when :http then HTTP
|
|
63
85
|
else fail ArgumentError, 'Invalid device type'
|
|
64
86
|
end
|
|
65
87
|
end
|
|
@@ -4,23 +4,60 @@ require 'time'
|
|
|
4
4
|
|
|
5
5
|
module LogStashLogger
|
|
6
6
|
module Formatter
|
|
7
|
-
HOST =
|
|
7
|
+
HOST = {
|
|
8
|
+
'hostname' => ::Socket.gethostname,
|
|
9
|
+
'ip' => Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6_loopback?).map(&:ip_address)
|
|
10
|
+
}.freeze
|
|
8
11
|
|
|
9
12
|
class Base < ::Logger::Formatter
|
|
13
|
+
FAILED_TO_FORMAT_MSG = 'Failed to format log event'
|
|
14
|
+
FAILED_TO_FORMAT_TAG = '_logstashlogger_format_failure'
|
|
15
|
+
attr_accessor :error_logger
|
|
10
16
|
include ::LogStashLogger::TaggedLogging::Formatter
|
|
11
17
|
|
|
12
|
-
def initialize(customize_event: nil)
|
|
18
|
+
def initialize(customize_event: nil, error_logger: LogStashLogger.configuration.default_error_logger)
|
|
13
19
|
@customize_event = customize_event
|
|
20
|
+
@error_logger = error_logger
|
|
14
21
|
super()
|
|
15
22
|
end
|
|
16
23
|
|
|
17
|
-
def call(severity, time,
|
|
24
|
+
def call(severity, time, progname, message)
|
|
18
25
|
event = build_event(message, severity, time)
|
|
19
|
-
|
|
26
|
+
frame_event(event, progname) unless event.cancelled?
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
log_error(e)
|
|
29
|
+
fallback_event(severity, time, progname, e)
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
private
|
|
23
33
|
|
|
34
|
+
# Wraps a built event in this formatter's record framing. Formatters that
|
|
35
|
+
# add a header or trailer override this; the fallback below goes through
|
|
36
|
+
# the same framing.
|
|
37
|
+
def frame_event(event, _progname)
|
|
38
|
+
format_event(event)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# When a record cannot be built or serialized, emit a well-formed
|
|
42
|
+
# placeholder in this formatter's own framing instead, so the failure never
|
|
43
|
+
# changes how a collector splits the stream into records. Returns nil
|
|
44
|
+
# (which devices drop) if even the placeholder cannot be formatted.
|
|
45
|
+
def fallback_event(severity, time, progname, error)
|
|
46
|
+
event = LogStash::Event.new(
|
|
47
|
+
'@timestamp'.freeze => time.is_a?(Time) ? time : Time.now,
|
|
48
|
+
'message'.freeze => FAILED_TO_FORMAT_MSG,
|
|
49
|
+
'severity'.freeze => severity.to_s,
|
|
50
|
+
'host'.freeze => HOST,
|
|
51
|
+
'tags'.freeze => [FAILED_TO_FORMAT_TAG],
|
|
52
|
+
'error'.freeze => error.class.name
|
|
53
|
+
)
|
|
54
|
+
event.timestamp = event.timestamp.iso8601(3) if event.timestamp.is_a?(Time)
|
|
55
|
+
frame_event(event, progname)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
log_error(e)
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
24
61
|
def build_event(message, severity, time)
|
|
25
62
|
data = message
|
|
26
63
|
if data.is_a?(String) && data.start_with?('{'.freeze)
|
|
@@ -31,15 +68,27 @@ module LogStashLogger
|
|
|
31
68
|
when LogStash::Event
|
|
32
69
|
data.clone
|
|
33
70
|
when Hash
|
|
34
|
-
event_data =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
event_data = { '@timestamp'.freeze => time }
|
|
72
|
+
data.each do |key, value|
|
|
73
|
+
case key
|
|
74
|
+
when :message, 'message'
|
|
75
|
+
event_data['message'.freeze] = value
|
|
76
|
+
when :tags, 'tags'
|
|
77
|
+
event_data['tags'.freeze] = value
|
|
78
|
+
when :source, 'source'
|
|
79
|
+
event_data['source'.freeze] = value
|
|
80
|
+
when :type, 'type'
|
|
81
|
+
event_data['type'.freeze] = value
|
|
82
|
+
when :'@timestamp', '@timestamp'
|
|
83
|
+
# Keep the logger time unless the value is a usable time
|
|
84
|
+
event_data['@timestamp'.freeze] = usable_time(value) || time
|
|
85
|
+
else
|
|
86
|
+
event_data[key] = value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
40
89
|
LogStash::Event.new(event_data)
|
|
41
90
|
else
|
|
42
|
-
LogStash::Event.new("
|
|
91
|
+
LogStash::Event.new("@timestamp".freeze => time, "message".freeze => msg2str(data))
|
|
43
92
|
end
|
|
44
93
|
|
|
45
94
|
event['severity'.freeze] ||= severity
|
|
@@ -68,6 +117,43 @@ module LogStashLogger
|
|
|
68
117
|
def format_event(event)
|
|
69
118
|
event
|
|
70
119
|
end
|
|
120
|
+
|
|
121
|
+
# LogStash::Event runs String timestamps through Time.parse, which raises
|
|
122
|
+
# for text that is not a time. Keep every other value exactly as given.
|
|
123
|
+
def usable_time(value)
|
|
124
|
+
return value unless value.is_a?(String)
|
|
125
|
+
Time.parse(value)
|
|
126
|
+
value
|
|
127
|
+
rescue ArgumentError, RangeError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def force_utf8_encoding(event)
|
|
132
|
+
original_message = event.instance_variable_get(:@data)['message']
|
|
133
|
+
event.message = original_message.dup.force_encoding(Encoding::UTF_8).scrub
|
|
134
|
+
event
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Check if the message has encoding issues that would cause JSON serialization problems.
|
|
138
|
+
# This is needed because some Ruby implementations (e.g., JRuby) may not raise
|
|
139
|
+
# exceptions during JSON encoding but produce malformed output instead.
|
|
140
|
+
def message_has_encoding_issue?(event)
|
|
141
|
+
message = event.instance_variable_get(:@data)['message']
|
|
142
|
+
return false unless message.is_a?(String)
|
|
143
|
+
|
|
144
|
+
# Check if already valid UTF-8
|
|
145
|
+
return false if message.encoding == Encoding::UTF_8 && message.valid_encoding?
|
|
146
|
+
|
|
147
|
+
# Try to encode to UTF-8 to detect issues
|
|
148
|
+
message.encode(Encoding::UTF_8)
|
|
149
|
+
false
|
|
150
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def log_error(e)
|
|
155
|
+
error_logger.error "[#{self.class}] #{e.class} - #{e.message}"
|
|
156
|
+
end
|
|
71
157
|
end
|
|
72
158
|
end
|
|
73
159
|
end
|