logstash-logger 1.0.0 → 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 +5 -3
- data/Appraisals +2 -4
- data/CHANGELOG.md +33 -0
- data/Gemfile +2 -1
- data/README.md +2 -2
- data/docs/buffering.md +16 -0
- data/docs/outputs.md +23 -0
- data/docs/rails.md +7 -2
- data/docs/ssl.md +49 -37
- data/docs/troubleshooting.md +22 -0
- data/gemfiles/rails_8.0.gemfile +3 -1
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/logstash-logger/buffer.rb +15 -4
- data/lib/logstash-logger/device/aws_stream.rb +96 -17
- data/lib/logstash-logger/device/connectable.rb +27 -1
- data/lib/logstash-logger/device/firehose.rb +6 -0
- data/lib/logstash-logger/device/http.rb +75 -4
- data/lib/logstash-logger/device/kafka.rb +27 -0
- data/lib/logstash-logger/device/kinesis.rb +10 -1
- data/lib/logstash-logger/device/tcp.rb +53 -7
- data/lib/logstash-logger/formatter/base.rb +44 -3
- data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
- data/lib/logstash-logger/version.rb +1 -1
- data/logstash-logger.gemspec +2 -6
- data/spec/device/aws_stream_limits_spec.rb +219 -0
- data/spec/device/connectable_spec.rb +51 -0
- data/spec/device/firehose_spec.rb +4 -1
- data/spec/device/http_spec.rb +166 -1
- data/spec/device/kafka_spec.rb +92 -0
- 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/formatter/base_spec.rb +42 -6
- data/spec/formatter/cee_syslog_spec.rb +71 -19
- data/spec/formatter/framing_spec.rb +58 -0
- data/spec/formatter/json_lines_spec.rb +18 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/support/tls_helper.rb +30 -0
- data/spec/support/unserializable_value.rb +10 -0
- metadata +19 -7
- data/gemfiles/rails_7.2.gemfile +0 -8
|
@@ -8,9 +8,32 @@ module LogStashLogger
|
|
|
8
8
|
# rather than persistent HTTP connections for efficiency.
|
|
9
9
|
class HTTP < Connectable
|
|
10
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
|
+
|
|
11
29
|
def initialize(opts)
|
|
12
30
|
super
|
|
13
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]
|
|
14
37
|
end
|
|
15
38
|
|
|
16
39
|
def connect
|
|
@@ -22,10 +45,58 @@ module LogStashLogger
|
|
|
22
45
|
end
|
|
23
46
|
|
|
24
47
|
def write_batch(messages, _group = nil)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
raise resp
|
|
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
|
|
29
100
|
end
|
|
30
101
|
|
|
31
102
|
end
|
|
@@ -47,6 +47,10 @@ module LogStashLogger
|
|
|
47
47
|
attr_reader :topic, :brokers, :cert_bundle, :kafka_tls_configurator,
|
|
48
48
|
:client_id
|
|
49
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
|
+
|
|
50
54
|
def initialize(opts = {}, kafka_tls_configurator = TLSConfiguration)
|
|
51
55
|
require 'ruby-kafka'
|
|
52
56
|
super(opts)
|
|
@@ -81,10 +85,33 @@ module LogStashLogger
|
|
|
81
85
|
write_messages_to_broker_and_deliver do |producer|
|
|
82
86
|
messages.each {|msg| producer.produce(msg, topic: topic) }
|
|
83
87
|
end
|
|
88
|
+
rescue => e
|
|
89
|
+
raise if retryable_error?(e)
|
|
90
|
+
deliver_one_at_a_time(messages, topic)
|
|
84
91
|
end
|
|
85
92
|
|
|
86
93
|
private
|
|
87
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
|
+
|
|
88
115
|
def write_messages_to_broker_and_deliver(&block)
|
|
89
116
|
with_connection do
|
|
90
117
|
kproducer = producer
|
|
@@ -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,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'openssl'
|
|
2
|
+
require 'ipaddr'
|
|
2
3
|
|
|
3
4
|
module LogStashLogger
|
|
4
5
|
module Device
|
|
@@ -13,11 +14,16 @@ module LogStashLogger
|
|
|
13
14
|
@use_ssl = !!(@ssl_certificate || opts[:ssl_context])
|
|
14
15
|
@use_ssl = opts[:ssl_enable] if opts.has_key? :ssl_enable
|
|
15
16
|
@verify_hostname = opts.fetch(:verify_hostname, true)
|
|
17
|
+
validate_ssl_certificate! if use_ssl? && @ssl_certificate && !@ssl_context
|
|
16
18
|
end
|
|
17
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.
|
|
18
24
|
def ssl_context
|
|
19
25
|
return unless use_ssl?
|
|
20
|
-
@ssl_context || certificate_context
|
|
26
|
+
@ssl_context || certificate_context || default_context
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def use_ssl?
|
|
@@ -49,20 +55,60 @@ module LogStashLogger
|
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def ssl_io
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
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
|
|
55
70
|
end
|
|
56
71
|
|
|
72
|
+
# Trusts the certificate (or CA bundle) in the :ssl_certificate file, in
|
|
73
|
+
# addition to the system trust store.
|
|
57
74
|
def certificate_context
|
|
58
75
|
return unless @ssl_certificate
|
|
59
|
-
@certificate_context ||=
|
|
60
|
-
|
|
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
|
|
61
90
|
end
|
|
62
91
|
end
|
|
63
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}"
|
|
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
|
|
108
|
+
end
|
|
109
|
+
|
|
64
110
|
def verify_hostname?
|
|
65
|
-
return false unless
|
|
111
|
+
return false unless use_ssl?
|
|
66
112
|
!! @verify_hostname
|
|
67
113
|
end
|
|
68
114
|
|
|
@@ -11,6 +11,7 @@ module LogStashLogger
|
|
|
11
11
|
|
|
12
12
|
class Base < ::Logger::Formatter
|
|
13
13
|
FAILED_TO_FORMAT_MSG = 'Failed to format log event'
|
|
14
|
+
FAILED_TO_FORMAT_TAG = '_logstashlogger_format_failure'
|
|
14
15
|
attr_accessor :error_logger
|
|
15
16
|
include ::LogStashLogger::TaggedLogging::Formatter
|
|
16
17
|
|
|
@@ -20,16 +21,43 @@ module LogStashLogger
|
|
|
20
21
|
super()
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def call(severity, time,
|
|
24
|
+
def call(severity, time, progname, message)
|
|
24
25
|
event = build_event(message, severity, time)
|
|
25
|
-
|
|
26
|
+
frame_event(event, progname) unless event.cancelled?
|
|
26
27
|
rescue StandardError => e
|
|
27
28
|
log_error(e)
|
|
28
|
-
|
|
29
|
+
fallback_event(severity, time, progname, e)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
private
|
|
32
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
|
+
|
|
33
61
|
def build_event(message, severity, time)
|
|
34
62
|
data = message
|
|
35
63
|
if data.is_a?(String) && data.start_with?('{'.freeze)
|
|
@@ -51,6 +79,9 @@ module LogStashLogger
|
|
|
51
79
|
event_data['source'.freeze] = value
|
|
52
80
|
when :type, 'type'
|
|
53
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
|
|
54
85
|
else
|
|
55
86
|
event_data[key] = value
|
|
56
87
|
end
|
|
@@ -87,6 +118,16 @@ module LogStashLogger
|
|
|
87
118
|
event
|
|
88
119
|
end
|
|
89
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
|
+
|
|
90
131
|
def force_utf8_encoding(event)
|
|
91
132
|
original_message = event.instance_variable_get(:@data)['message']
|
|
92
133
|
event.message = original_message.dup.force_encoding(Encoding::UTF_8).scrub
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
module LogStashLogger
|
|
2
2
|
module Formatter
|
|
3
3
|
class CeeSyslog < Cee
|
|
4
|
-
def call(severity, time, progname, message)
|
|
5
|
-
@progname = progname
|
|
6
|
-
super
|
|
7
|
-
end
|
|
8
|
-
|
|
9
4
|
private
|
|
10
5
|
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
facility << " #{@progname}" if @progname
|
|
14
|
-
facility
|
|
6
|
+
def frame_event(event, progname)
|
|
7
|
+
"#{build_header(progname)}:#{format_event(event)}\n"
|
|
15
8
|
end
|
|
16
9
|
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
# The header names this process, never a value taken from the logged
|
|
11
|
+
# content, and always stays on one line.
|
|
12
|
+
def build_header(progname)
|
|
13
|
+
header = HOST['hostname'.freeze].to_s.dup
|
|
14
|
+
header << " #{progname}" if progname
|
|
15
|
+
header.gsub(/[[:cntrl:]]/, ' '.freeze)
|
|
19
16
|
end
|
|
20
17
|
end
|
|
21
18
|
end
|
data/logstash-logger.gemspec
CHANGED
|
@@ -26,11 +26,7 @@ Gem::Specification.new do |gem|
|
|
|
26
26
|
gem.add_development_dependency 'aws-sdk-kinesis'
|
|
27
27
|
gem.add_development_dependency 'aws-sdk-firehose'
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
gem.add_development_dependency 'SyslogLogger'
|
|
31
|
-
else
|
|
32
|
-
gem.add_development_dependency 'syslog'
|
|
33
|
-
end
|
|
29
|
+
gem.add_development_dependency 'syslog', '>= 0.4'
|
|
34
30
|
|
|
35
31
|
gem.add_development_dependency 'rspec', '>= 3'
|
|
36
32
|
gem.add_development_dependency 'rake'
|
|
@@ -43,5 +39,5 @@ Gem::Specification.new do |gem|
|
|
|
43
39
|
gem.add_development_dependency 'rubocop-rake'
|
|
44
40
|
gem.add_development_dependency 'rubocop-rspec'
|
|
45
41
|
|
|
46
|
-
gem.required_ruby_version = '>= 3.
|
|
42
|
+
gem.required_ruby_version = '>= 3.3'
|
|
47
43
|
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require 'logstash-logger'
|
|
2
|
+
require 'logger'
|
|
3
|
+
|
|
4
|
+
# Stub AWS clients that record every request and can be told to fail.
|
|
5
|
+
class FakeKinesisClient
|
|
6
|
+
attr_reader :requests
|
|
7
|
+
|
|
8
|
+
def initialize(fail_requests: [])
|
|
9
|
+
@requests = []
|
|
10
|
+
@fail_requests = fail_requests
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def put_records(params)
|
|
14
|
+
@requests << params[:records].map { |r| r[:data] }
|
|
15
|
+
raise Seahorse::Client::NetworkingError.new(StandardError.new('connection reset')) if @fail_requests.include?(@requests.size)
|
|
16
|
+
entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(sequence_number: '1', shard_id: 's') }
|
|
17
|
+
Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: 0, records: entries)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class FakeFirehoseClient
|
|
22
|
+
attr_reader :requests
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@requests = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def put_record_batch(params)
|
|
29
|
+
@requests << params[:records].map { |r| r[:data] }
|
|
30
|
+
entries = params[:records].map { Aws::Firehose::Types::PutRecordBatchResponseEntry.new(record_id: '1') }
|
|
31
|
+
Aws::Firehose::Types::PutRecordBatchOutput.new(failed_put_count: 0, request_responses: entries)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe 'AWS outputs and service limits' do
|
|
36
|
+
let(:error_log) { StringIO.new }
|
|
37
|
+
let(:common) do
|
|
38
|
+
{ sync: false, buffer_max_interval: 3600, buffer_flush_at_exit: false,
|
|
39
|
+
aws_region: 'us-east-1', error_logger: ::Logger.new(error_log) }
|
|
40
|
+
end
|
|
41
|
+
let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
|
|
42
|
+
|
|
43
|
+
def pending_count(device)
|
|
44
|
+
device.instance_variable_get(:@buffer_state)[:pending_count]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe LogStashLogger::Device::Kinesis do
|
|
48
|
+
let(:client) { FakeKinesisClient.new }
|
|
49
|
+
let(:device) { LogStashLogger::Device.new(common.merge(type: :kinesis)) }
|
|
50
|
+
|
|
51
|
+
before { allow(Aws::Kinesis::Client).to receive(:new) { client } }
|
|
52
|
+
|
|
53
|
+
it 'drops only a record over the per-record limit' do
|
|
54
|
+
device.write_batch(small + ['x' * 1_048_576])
|
|
55
|
+
expect(client.requests).to eq([small])
|
|
56
|
+
expect(error_log.string).to match(/dropping a record of 1048612 bytes: over the 1048576-byte limit/)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'accepts larger records when aws_max_record_bytes is raised' do
|
|
60
|
+
big = 'x' * 2_000_000
|
|
61
|
+
device = LogStashLogger::Device.new(common.merge(type: :kinesis, aws_max_record_bytes: 10_485_760))
|
|
62
|
+
device.write_batch([big])
|
|
63
|
+
expect(client.requests).to eq([[big]])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'splits more than 500 records into several requests' do
|
|
67
|
+
messages = (1..600).map(&:to_s)
|
|
68
|
+
device.write_batch(messages)
|
|
69
|
+
expect(client.requests.map(&:size)).to eq([500, 100])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'splits requests that would go over the request-size limit' do
|
|
73
|
+
messages = Array.new(12) { 'x' * 1_000_000 }
|
|
74
|
+
device.write_batch(messages)
|
|
75
|
+
expect(client.requests.map(&:size)).to eq([10, 2])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'sends records that are not Strings as text' do
|
|
79
|
+
device.write_batch([123, :sym])
|
|
80
|
+
expect(client.requests).to eq([['123', 'sym']])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
context 'when a request fails' do
|
|
84
|
+
let(:client) { FakeKinesisClient.new(fail_requests: [1]) }
|
|
85
|
+
|
|
86
|
+
it 'keeps the records for the next flush instead of treating them as delivered' do
|
|
87
|
+
small.each { |m| device.write(m) }
|
|
88
|
+
expect { device.buffer_flush(force: true) }.not_to raise_error
|
|
89
|
+
expect(pending_count(device)).to eq(small.size)
|
|
90
|
+
expect(error_log.string).to include('5 records will be retried on the next flush')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'drops them when drop_messages_on_flush_error is set' do
|
|
94
|
+
device = LogStashLogger::Device.new(common.merge(type: :kinesis, drop_messages_on_flush_error: true))
|
|
95
|
+
small.each { |m| device.write(m) }
|
|
96
|
+
device.buffer_flush(force: true)
|
|
97
|
+
expect(pending_count(device)).to eq(0)
|
|
98
|
+
expect(error_log.string).to include('dropping 5 records after a failed request')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
context 'when a later request fails' do
|
|
103
|
+
let(:client) { FakeKinesisClient.new(fail_requests: [2]) }
|
|
104
|
+
|
|
105
|
+
it 'retries only the records that were not sent' do
|
|
106
|
+
device.write_batch((1..600).map(&:to_s))
|
|
107
|
+
expect(pending_count(device)).to eq(100)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'delivers the rest of a buffered batch around an oversized record' do
|
|
112
|
+
small.each { |m| device.write(m) }
|
|
113
|
+
device.write('x' * 2_000_000)
|
|
114
|
+
device.buffer_flush(force: true)
|
|
115
|
+
expect(client.requests).to eq([small])
|
|
116
|
+
expect(pending_count(device)).to eq(0)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe LogStashLogger::Device::Firehose do
|
|
121
|
+
let(:client) { FakeFirehoseClient.new }
|
|
122
|
+
let(:device) { LogStashLogger::Device.new(common.merge(type: :firehose)) }
|
|
123
|
+
|
|
124
|
+
before { allow(Aws::Firehose::Client).to receive(:new) { client } }
|
|
125
|
+
|
|
126
|
+
it 'drops only a record over the 1,000 KiB limit' do
|
|
127
|
+
device.write_batch(small + ['x' * 1_024_001])
|
|
128
|
+
expect(client.requests).to eq([small])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'splits requests at 4 MB' do
|
|
132
|
+
device.write_batch(Array.new(5) { 'x' * 1_000_000 })
|
|
133
|
+
expect(client.requests.map(&:size)).to eq([4, 1])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Throttles every record of the first `throttled` requests, then accepts all.
|
|
139
|
+
class ThrottlingKinesisClient
|
|
140
|
+
attr_reader :requests
|
|
141
|
+
|
|
142
|
+
def initialize(throttled:, code: 'ProvisionedThroughputExceededException')
|
|
143
|
+
@requests = []
|
|
144
|
+
@throttled = throttled
|
|
145
|
+
@code = code
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def put_records(params)
|
|
149
|
+
@requests << params[:records].map { |r| r[:data] }
|
|
150
|
+
if @requests.size <= @throttled
|
|
151
|
+
entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(error_code: @code, error_message: 'slow down') }
|
|
152
|
+
Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: entries.size, records: entries)
|
|
153
|
+
else
|
|
154
|
+
entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(sequence_number: '1', shard_id: 's') }
|
|
155
|
+
Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: 0, records: entries)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def delivered
|
|
160
|
+
@requests.drop(@throttled).flatten
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
describe LogStashLogger::Device::Kinesis, 'when records are throttled' do
|
|
165
|
+
let(:error_log) { StringIO.new }
|
|
166
|
+
let(:client) { ThrottlingKinesisClient.new(throttled: 1) }
|
|
167
|
+
let(:records) { (1..50).map(&:to_s) }
|
|
168
|
+
|
|
169
|
+
before { allow(Aws::Kinesis::Client).to receive(:new) { client } }
|
|
170
|
+
|
|
171
|
+
def device(**opts)
|
|
172
|
+
LogStashLogger::Device.new({ type: :kinesis, aws_region: 'us-east-1', sync: false, buffer_max_interval: 0.2,
|
|
173
|
+
buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# These flushes run on the buffer's background timer thread, so poll until
|
|
177
|
+
# the block returns true, giving up after `timeout` seconds. The example's
|
|
178
|
+
# own expectations then report what went wrong if it never does.
|
|
179
|
+
def wait_until(timeout: 5)
|
|
180
|
+
deadline = Time.now + timeout
|
|
181
|
+
sleep 0.05 until yield || Time.now > deadline
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'retries a full throttled batch on the timer thread without losing it or stopping the timer' do
|
|
185
|
+
dev = device
|
|
186
|
+
timer = dev.instance_variable_get(:@flush_timer_thread)
|
|
187
|
+
records.each { |r| dev.write(r) }
|
|
188
|
+
|
|
189
|
+
wait_until { client.delivered.size == records.size }
|
|
190
|
+
|
|
191
|
+
expect(client.delivered).to match_array(records)
|
|
192
|
+
expect(dev.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
|
|
193
|
+
expect(dev.instance_variable_get(:@buffer_state)[:outgoing_count]).to eq(0)
|
|
194
|
+
expect(timer).to be_alive
|
|
195
|
+
expect(error_log.string).to include('50 records will be retried on the next flush (ProvisionedThroughputExceededException: 50)')
|
|
196
|
+
expect(error_log.string).not_to include('Buffer Full')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'does not block logging when drop_messages_on_full_buffer is false' do
|
|
200
|
+
dev = device(drop_messages_on_full_buffer: false)
|
|
201
|
+
records.each { |r| dev.write(r) }
|
|
202
|
+
wait_until { client.delivered.size == records.size }
|
|
203
|
+
|
|
204
|
+
writer = Thread.new { dev.write('after') }
|
|
205
|
+
expect(writer.join(2)).not_to be_nil
|
|
206
|
+
expect(client.delivered).to match_array(records)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
context 'with InternalFailure' do
|
|
210
|
+
let(:client) { ThrottlingKinesisClient.new(throttled: 1, code: 'InternalFailure') }
|
|
211
|
+
|
|
212
|
+
it 'retries the records' do
|
|
213
|
+
dev = device
|
|
214
|
+
records.first(3).each { |r| dev.write(r) }
|
|
215
|
+
wait_until { client.delivered.size == 3 }
|
|
216
|
+
expect(client.delivered).to match_array(records.first(3))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -72,3 +72,54 @@ describe LogStashLogger::Device::Connectable do
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
|
+
|
|
76
|
+
describe LogStashLogger::Device::Connectable, 'max_record_bytes' do
|
|
77
|
+
let(:error_log) { StringIO.new }
|
|
78
|
+
|
|
79
|
+
def device(**opts)
|
|
80
|
+
LogStashLogger::Device.new({ type: :udp, port: 5228, sync: false, buffer_max_interval: 3600,
|
|
81
|
+
buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending(dev)
|
|
85
|
+
dev.instance_variable_get(:@buffer_state)[:pending_items][nil]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'is off by default' do
|
|
89
|
+
dev = device
|
|
90
|
+
dev.write('x' * 2_000_000)
|
|
91
|
+
expect(pending(dev).size).to eq(1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'drops only records over the limit, logging the size but not the content' do
|
|
95
|
+
dev = device(max_record_bytes: 100)
|
|
96
|
+
dev.write('small')
|
|
97
|
+
dev.write('secret' * 50)
|
|
98
|
+
expect(pending(dev)).to eq(['small'])
|
|
99
|
+
expect(error_log.string).to include('dropping a record of 300 bytes: over max_record_bytes (100)')
|
|
100
|
+
expect(error_log.string).not_to include('secret')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'measures records that are not Strings' do
|
|
104
|
+
dev = device(max_record_bytes: 2)
|
|
105
|
+
dev.write(123)
|
|
106
|
+
expect(pending(dev)).to be_empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'accepts a String value, as URI query options arrive' do
|
|
110
|
+
dev = LogStashLogger::Device.new(uri: 'udp://localhost:5228?max_record_bytes=100', buffer_flush_at_exit: false,
|
|
111
|
+
error_logger: ::Logger.new(error_log))
|
|
112
|
+
dev.write('x' * 101)
|
|
113
|
+
expect(pending(dev)).to be_empty
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'rejects a value that is not a positive number' do
|
|
117
|
+
expect { device(max_record_bytes: 'lots') }.to raise_error(ArgumentError)
|
|
118
|
+
expect { device(max_record_bytes: 0) }.to raise_error(ArgumentError, /positive/)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'is not forwarded to the Redis client' do
|
|
122
|
+
expect(::Redis).to receive(:new).with(hash_excluding(:max_record_bytes)).and_return(double('redis'))
|
|
123
|
+
LogStashLogger::Device.new(type: :redis, max_record_bytes: 100, buffer_flush_at_exit: false).connect
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -30,9 +30,12 @@ describe LogStashLogger::Device::Firehose do
|
|
|
30
30
|
response.request_responses = [failed_record]
|
|
31
31
|
|
|
32
32
|
expect(client).to receive(:put_record_batch) { response }
|
|
33
|
-
expect(firehose_device).
|
|
33
|
+
expect(firehose_device).not_to receive(:write)
|
|
34
34
|
|
|
35
35
|
firehose_device.write_one "foo"
|
|
36
|
+
state = firehose_device.instance_variable_get(:@buffer_state)
|
|
37
|
+
expect(state[:pending_items][nil]).to eq(["foo"])
|
|
38
|
+
expect(state[:pending_count]).to eq(1)
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
it "defaults the Firehose stream to logstash" do
|