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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +5 -3
  3. data/Appraisals +2 -4
  4. data/CHANGELOG.md +33 -0
  5. data/Gemfile +2 -1
  6. data/README.md +2 -2
  7. data/docs/buffering.md +16 -0
  8. data/docs/outputs.md +23 -0
  9. data/docs/rails.md +7 -2
  10. data/docs/ssl.md +49 -37
  11. data/docs/troubleshooting.md +22 -0
  12. data/gemfiles/rails_8.0.gemfile +3 -1
  13. data/gemfiles/rails_8.1.gemfile +2 -1
  14. data/lib/logstash-logger/buffer.rb +15 -4
  15. data/lib/logstash-logger/device/aws_stream.rb +96 -17
  16. data/lib/logstash-logger/device/connectable.rb +27 -1
  17. data/lib/logstash-logger/device/firehose.rb +6 -0
  18. data/lib/logstash-logger/device/http.rb +75 -4
  19. data/lib/logstash-logger/device/kafka.rb +27 -0
  20. data/lib/logstash-logger/device/kinesis.rb +10 -1
  21. data/lib/logstash-logger/device/tcp.rb +53 -7
  22. data/lib/logstash-logger/formatter/base.rb +44 -3
  23. data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
  24. data/lib/logstash-logger/version.rb +1 -1
  25. data/logstash-logger.gemspec +2 -6
  26. data/spec/device/aws_stream_limits_spec.rb +219 -0
  27. data/spec/device/connectable_spec.rb +51 -0
  28. data/spec/device/firehose_spec.rb +4 -1
  29. data/spec/device/http_spec.rb +166 -1
  30. data/spec/device/kafka_spec.rb +92 -0
  31. data/spec/device/kinesis_spec.rb +4 -1
  32. data/spec/device/tcp_spec.rb +94 -5
  33. data/spec/device/tcp_tls_integration_spec.rb +91 -0
  34. data/spec/formatter/base_spec.rb +42 -6
  35. data/spec/formatter/cee_syslog_spec.rb +71 -19
  36. data/spec/formatter/framing_spec.rb +58 -0
  37. data/spec/formatter/json_lines_spec.rb +18 -0
  38. data/spec/spec_helper.rb +5 -0
  39. data/spec/support/tls_helper.rb +30 -0
  40. data/spec/support/unserializable_value.rb +10 -0
  41. metadata +19 -7
  42. 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
- # Logstash HTTP input expects JSON array instead of lines of JSON
26
- body = "[#{messages.join(',')}]"
27
- resp = Net::HTTP.post @url, body, {"Content-Type" => "application/json"}
28
- raise resp.message if Net::HTTPError === 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
- ssl_context ?
53
- OpenSSL::SSL::SSLSocket.new(tcp_io, ssl_context) :
54
- OpenSSL::SSL::SSLSocket.new(tcp_io)
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 ||= OpenSSL::SSL::SSLContext.new.tap do |ctx|
60
- ctx.set_params(cert: @ssl_certificate)
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 ssl_context
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, _progname, message)
24
+ def call(severity, time, progname, message)
24
25
  event = build_event(message, severity, time)
25
- format_event(event) unless event.cancelled?
26
+ frame_event(event, progname) unless event.cancelled?
26
27
  rescue StandardError => e
27
28
  log_error(e)
28
- FAILED_TO_FORMAT_MSG
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 build_facility(host)
12
- facility = host['hostname'.freeze].dup
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
- def format_event(event)
18
- "#{build_facility(event["host".freeze])}:#{super}\n"
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
@@ -1,3 +1,3 @@
1
1
  module LogStashLogger
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -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
- if defined?(JRUBY_VERSION)
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.2'
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).to receive(:write).with("foo")
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