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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +53 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +85 -105
  5. data/Appraisals +6 -20
  6. data/CHANGELOG.md +61 -0
  7. data/Gemfile +2 -2
  8. data/README.md +12 -833
  9. data/docs/buffering.md +86 -0
  10. data/docs/customization.md +86 -0
  11. data/docs/outputs.md +65 -0
  12. data/docs/rails.md +349 -0
  13. data/docs/ssl.md +102 -0
  14. data/docs/troubleshooting.md +106 -0
  15. data/docs/usage.md +148 -0
  16. data/gemfiles/rails_8.0.gemfile +10 -0
  17. data/gemfiles/rails_8.1.gemfile +9 -0
  18. data/lib/logstash-logger/buffer.rb +15 -5
  19. data/lib/logstash-logger/configuration.rb +1 -2
  20. data/lib/logstash-logger/device/aws_stream.rb +96 -17
  21. data/lib/logstash-logger/device/base.rb +2 -2
  22. data/lib/logstash-logger/device/connectable.rb +30 -12
  23. data/lib/logstash-logger/device/file.rb +21 -4
  24. data/lib/logstash-logger/device/firehose.rb +6 -0
  25. data/lib/logstash-logger/device/http.rb +104 -0
  26. data/lib/logstash-logger/device/kafka.rb +182 -38
  27. data/lib/logstash-logger/device/kinesis.rb +10 -1
  28. data/lib/logstash-logger/device/redis.rb +8 -1
  29. data/lib/logstash-logger/device/tcp.rb +54 -12
  30. data/lib/logstash-logger/device.rb +24 -2
  31. data/lib/logstash-logger/formatter/base.rb +97 -11
  32. data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
  33. data/lib/logstash-logger/formatter/json.rb +13 -0
  34. data/lib/logstash-logger/formatter/json_lines.rb +13 -0
  35. data/lib/logstash-logger/formatter.rb +14 -6
  36. data/lib/logstash-logger/logger.rb +6 -19
  37. data/lib/logstash-logger/multi_logger.rb +2 -1
  38. data/lib/logstash-logger/railtie.rb +1 -1
  39. data/lib/logstash-logger/tagged_logging.rb +3 -1
  40. data/lib/logstash-logger/version.rb +1 -1
  41. data/logstash-logger.gemspec +8 -4
  42. data/spec/device/aws_stream_limits_spec.rb +219 -0
  43. data/spec/device/connectable_spec.rb +51 -0
  44. data/spec/device/file_spec.rb +65 -0
  45. data/spec/device/firehose_spec.rb +4 -1
  46. data/spec/device/http_spec.rb +176 -0
  47. data/spec/device/kafka_spec.rb +429 -14
  48. data/spec/device/kinesis_spec.rb +4 -1
  49. data/spec/device/tcp_spec.rb +94 -5
  50. data/spec/device/tcp_tls_integration_spec.rb +91 -0
  51. data/spec/device_spec.rb +13 -0
  52. data/spec/formatter/base_spec.rb +82 -1
  53. data/spec/formatter/cee_syslog_spec.rb +71 -19
  54. data/spec/formatter/framing_spec.rb +58 -0
  55. data/spec/formatter/json_lines_spec.rb +41 -0
  56. data/spec/formatter/json_spec.rb +49 -0
  57. data/spec/formatter_spec.rb +19 -2
  58. data/spec/logger_spec.rb +5 -5
  59. data/spec/multi_logger_spec.rb +16 -0
  60. data/spec/spec_helper.rb +7 -5
  61. data/spec/support/tls_helper.rb +30 -0
  62. data/spec/support/unserializable_value.rb +10 -0
  63. data/spec/tagged_logging_spec.rb +15 -0
  64. metadata +101 -16
  65. data/.travis.yml +0 -26
  66. data/gemfiles/rails_3.2.gemfile +0 -9
  67. data/gemfiles/rails_4.0.gemfile +0 -9
  68. data/gemfiles/rails_4.1.gemfile +0 -9
  69. data/gemfiles/rails_4.2.gemfile +0 -9
  70. data/gemfiles/rails_5.0.gemfile +0 -9
  71. data/gemfiles/rails_5.1.gemfile +0 -9
@@ -0,0 +1,176 @@
1
+ require 'logstash-logger'
2
+
3
+ describe LogStashLogger::Device::HTTP do
4
+ include_context 'device'
5
+
6
+ it "Post event to HTTP" do
7
+ expect(Net::HTTP).to receive(:post).and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
8
+ http_device.write('test')
9
+ end
10
+
11
+ describe "body format" do
12
+ let(:url) { 'http://localhost' }
13
+ let(:messages) { ["{\"a\":1}\n", "{\"b\":2}"] }
14
+
15
+ it "sends a JSON array by default" do
16
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true)
17
+ expect(Net::HTTP).to receive(:post)
18
+ .with(URI(url), "[{\"a\":1}\n,{\"b\":2}]", anything)
19
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
20
+ device.write_batch(messages)
21
+ end
22
+
23
+ it "sends a JSON array with format: :json_array" do
24
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :json_array)
25
+ expect(Net::HTTP).to receive(:post)
26
+ .with(URI(url), "[{\"a\":1}\n,{\"b\":2}]", anything)
27
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
28
+ device.write_batch(messages)
29
+ end
30
+
31
+ it "sends newline-delimited JSON with format: :ndjson" do
32
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson)
33
+ expect(Net::HTTP).to receive(:post)
34
+ .with(URI(url), "{\"a\":1}\n{\"b\":2}\n", anything)
35
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
36
+ device.write_batch(messages)
37
+ end
38
+
39
+ it "accepts the format as a string" do
40
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: "ndjson")
41
+ expect(Net::HTTP).to receive(:post)
42
+ .with(URI(url), "{\"a\":1}\n{\"b\":2}\n", anything)
43
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
44
+ device.write_batch(messages)
45
+ end
46
+
47
+ it "raises an error for an unknown format" do
48
+ expect {
49
+ LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :xml)
50
+ }.to raise_error(ArgumentError, /Unknown HTTP body format: :xml/)
51
+ end
52
+ end
53
+
54
+ describe "content type" do
55
+ let(:url) { 'http://localhost' }
56
+
57
+ def expect_content_type(type)
58
+ expect(Net::HTTP).to receive(:post)
59
+ .with(anything, anything, {"Content-Type" => type})
60
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
61
+ end
62
+
63
+ it "is application/json by default" do
64
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true)
65
+ expect_content_type("application/json")
66
+ device.write_batch(["{}"])
67
+ end
68
+
69
+ it "is application/x-ndjson for format: :ndjson" do
70
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson)
71
+ expect_content_type("application/x-ndjson")
72
+ device.write_batch(["{}"])
73
+ end
74
+
75
+ it "can be overridden with content_type" do
76
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson, content_type: "application/json")
77
+ expect_content_type("application/json")
78
+ device.write_batch(["{}"])
79
+ end
80
+ end
81
+
82
+ it "does not raise on a successful response" do
83
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
84
+ expect { http_device.write_batch(['test']) }.not_to raise_error
85
+ end
86
+
87
+ it "raises on an error response" do
88
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPInternalServerError.new('1.1', '500', 'Internal Server Error'))
89
+ expect { http_device.write_batch(['test']) }.to raise_error(RuntimeError, "HTTP log upload failed: 500 Internal Server Error")
90
+ end
91
+
92
+ it "raises on a client error response" do
93
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPUnauthorized.new('1.1', '401', 'Unauthorized'))
94
+ expect { http_device.write_batch(['test']) }.to raise_error(RuntimeError, /401 Unauthorized/)
95
+ end
96
+ end
97
+
98
+ describe LogStashLogger::Device::HTTP, 'records the collector rejects' do
99
+ let(:url) { 'http://localhost' }
100
+ let(:error_log) { StringIO.new }
101
+ let(:sent) { [] }
102
+ let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
103
+
104
+ def device(**opts)
105
+ LogStashLogger::Device.new({ type: :http, url: url, sync: false, buffer_max_interval: 3600,
106
+ buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
107
+ end
108
+
109
+ # Stubs the HTTP endpoint: any request containing a "BAD" record gets the
110
+ # given error status, and every other request succeeds and is recorded.
111
+ def stub_endpoint_rejecting_bad_records(status:, message:)
112
+ allow(Net::HTTP).to receive(:post) do |_url, body, _headers|
113
+ if body.include?('BAD')
114
+ klass = Net::HTTPResponse::CODE_TO_OBJ[status]
115
+ klass.new('1.1', status, message)
116
+ else
117
+ sent << body
118
+ Net::HTTPOK.new('1.1', '200', 'OK')
119
+ end
120
+ end
121
+ end
122
+
123
+ it 'delivers the other records and drops only the rejected one' do
124
+ stub_endpoint_rejecting_bad_records(status: '413', message: 'Payload Too Large')
125
+ device.write_batch(small + ['"BAD"'])
126
+ expect(sent).to eq(small.map { |m| "[#{m}]" })
127
+ expect(error_log.string).to include('dropping a record of 5 bytes that the collector rejected: HTTP 413')
128
+ end
129
+
130
+ it 'drops a rejected single-record batch without raising' do
131
+ stub_endpoint_rejecting_bad_records(status: '400', message: 'Bad Request')
132
+ expect { device.write_batch(['"BAD"']) }.not_to raise_error
133
+ expect(error_log.string).to include('HTTP 400')
134
+ end
135
+
136
+ it 'clears a buffered batch instead of retrying the rejected record forever' do
137
+ stub_endpoint_rejecting_bad_records(status: '422', message: 'Unprocessable Entity')
138
+ dev = device
139
+ small.each { |m| dev.write(m) }
140
+ dev.write('"BAD"')
141
+ dev.buffer_flush(force: true)
142
+ expect(dev.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
143
+ expect(sent.size).to eq(small.size)
144
+ end
145
+
146
+ # Errors that do not depend on the records, so the batch is retried
147
+ {
148
+ '401' => 'Unauthorized', # missing or wrong credentials
149
+ '403' => 'Forbidden', # credentials not allowed
150
+ '404' => 'Not Found', # wrong URL, or the collector is not deployed yet
151
+ '429' => 'Too Many Requests', # rate limited
152
+ '500' => 'Internal Server Error', # collector error
153
+ '503' => 'Service Unavailable', # collector down or restarting
154
+ }.each do |status, message|
155
+ it "retries the whole batch on HTTP #{status} #{message}" do
156
+ stub_endpoint_rejecting_bad_records(status: status, message: message)
157
+ expect { device.write_batch(['"BAD"', 'ok']) }.to raise_error(RuntimeError, /HTTP log upload failed: #{status}/)
158
+ expect(error_log.string).to include("HTTP log upload failed: #{status}")
159
+ end
160
+ end
161
+
162
+ it 'raises if a record sent on its own hits a retryable error' do
163
+ calls = 0
164
+ allow(Net::HTTP).to receive(:post) do
165
+ calls += 1
166
+ calls == 1 ? Net::HTTPRequestEntityTooLarge.new('1.1', '413', 'Too Large') : Net::HTTPServiceUnavailable.new('1.1', '503', 'Unavailable')
167
+ end
168
+ expect { device.write_batch(['a', 'b']) }.to raise_error(RuntimeError, /503/)
169
+ end
170
+
171
+ it 'sends records that are not Strings as text' do
172
+ allow(Net::HTTP).to receive(:post) { |_u, body, _h| sent << body; Net::HTTPOK.new('1.1', '200', 'OK') }
173
+ device(format: :ndjson).write_batch([123, '{"a":1}'])
174
+ expect(sent).to eq(["123\n{\"a\":1}\n"])
175
+ end
176
+ end
@@ -1,32 +1,447 @@
1
1
  require 'logstash-logger'
2
+ # The device requires ruby-kafka only when the first one is built. Load it here
3
+ # so the classes reopened below and Kafka::SslContext exist in any test order.
4
+ require 'ruby-kafka'
5
+
6
+ describe LogStashLogger::Device::Kafka::TLSConfiguration do
7
+ context "with TLS" do
8
+ let(:complete_bundle) do
9
+ # NOTE these keys are obviously fake. We don't actually make connections
10
+ {
11
+ ssl_ca_cert: "----BEGIN CERT---- lkajdslkajdsjk ----END CERT---",
12
+ ssl_client_cert: "----BEGIN CERT---- lkajdslkajdsjk ----END CERT---",
13
+ ssl_client_cert_key: "----PRIVATE---- lkajdslkajdsjk ----PRIVATE---",
14
+ }
15
+ end
16
+
17
+ context "when complete params are passed in" do
18
+ let(:instance) { described_class.new(complete_bundle) }
19
+
20
+ it "returns an empty hash when no ssl params are initialized" do
21
+ expect(instance.cert_bundle).to_not be_empty
22
+ expect(instance.valid?).to be_truthy
23
+ end
24
+ end
25
+
26
+ context "when incomplete params are passed in" do
27
+ [:ssl_ca_cert, :ssl_client_cert, :ssl_client_cert_key].each do |param|
28
+ it "fails with an ArgumentError when #{param} no provided" do
29
+ opts = complete_bundle
30
+ opts[param] = nil
31
+ instance = described_class.new(opts)
32
+ expect(instance.cert_bundle).to be_empty
33
+ expect(instance.valid?).to be_falsey
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ context "without TLS" do
40
+ let(:instance) { subject }
41
+
42
+ it "returns an empty hash when no ssl params are initialized" do
43
+ expect(instance.cert_bundle).to be_empty
44
+ expect(instance.valid?).to be_truthy
45
+ end
46
+ end
47
+
48
+ context "#invalid?" do
49
+ it 'is just the opposite of valid?' do
50
+ expect(subject).to receive(:valid?).and_return(false)
51
+ expect(subject.invalid?).to be_truthy
52
+
53
+ expect(subject).to receive(:valid?).and_return(true)
54
+ expect(subject.invalid?).to be_falsey
55
+ end
56
+ end
57
+ end
2
58
 
3
59
  describe LogStashLogger::Device::Kafka do
4
60
  include_context 'device'
5
61
 
6
- let(:producer) { double("Poseidon::Producer") }
62
+ let(:broker_hosts) { "localhost:9300 localhost:9232" }
63
+ let(:opts) do
64
+ { topic: 'hello-world',
65
+ brokers: broker_hosts,
66
+ }
67
+ end
68
+ let(:instance) { LogStashLogger::Device::Kafka.new(opts) }
69
+ let(:brokers) { %w(localhost:9300 localhost:9232) }
70
+
71
+ describe "initializing" do
72
+ context "uri-derived options" do
73
+ it "normalizes host/port/path into brokers and topic" do
74
+ instance = described_class.new(
75
+ host: 'localhost',
76
+ port: 9092,
77
+ path: '/logstash'
78
+ )
79
+
80
+ expect(instance.brokers).to eq(['localhost:9092'])
81
+ expect(instance.topic).to eq('logstash')
82
+ end
83
+ end
84
+
85
+ context "brokers" do
86
+ context "when array" do
87
+ it "sets the brokers array to @brokers" do
88
+ instance = LogStashLogger::Device::Kafka.new(opts.merge({brokers: brokers}))
89
+
90
+ expect(instance.brokers).to eql(brokers)
91
+ end
92
+
93
+ it 'sets the brokers to an array if a string is passed in' do
94
+ instance = LogStashLogger::Device::Kafka.new(opts.merge({brokers: broker_hosts}))
95
+ expect(instance.brokers).to eql(brokers)
96
+ end
97
+ end
98
+
99
+ context "when missing or empty" do
100
+ it "raises an error if brokers are nil" do
101
+ expect {
102
+ described_class.new(topic: 'hello-world', brokers: nil)
103
+ }.to raise_error(ArgumentError)
104
+ end
105
+
106
+ it "raises an error if brokers are an empty array" do
107
+ expect {
108
+ described_class.new(topic: 'hello-world', brokers: [])
109
+ }.to raise_error(ArgumentError)
110
+ end
111
+
112
+ it "raises an error if brokers are blank" do
113
+ expect {
114
+ described_class.new(topic: 'hello-world', brokers: " ")
115
+ }.to raise_error(ArgumentError)
116
+ end
117
+ end
118
+ end
119
+
120
+ context "topic" do
121
+ it 'sets the topic to the option provided' do
122
+ instance = described_class.new(topic: 'hello-world', brokers: brokers)
123
+ expect(instance.topic).to eql('hello-world')
124
+ end
125
+
126
+ it 'raises an exception if no topic is set' do
127
+ expect {
128
+ described_class.new(topic: nil)
129
+ }.to raise_error(ArgumentError)
130
+ end
131
+
132
+ it 'raises an exception if the topic is blank' do
133
+ expect {
134
+ described_class.new(topic: " ", brokers: brokers)
135
+ }.to raise_error(ArgumentError)
136
+ end
137
+ end
138
+
139
+ context "Client Introspection" do
140
+ # YUCK! ruby-kafka does not presently allow reading certain variables
141
+ module ::Kafka
142
+ class Client
143
+ attr_reader :connection_builder
144
+ end
145
+ end
146
+
147
+ module ::Kafka
148
+ class ConnectionBuilder
149
+ attr_reader :ssl_context, :client_id
150
+ end
151
+ end
152
+
153
+ context "client_id" do
154
+ it 'sets a client_id when connecting if one is passed in to the options' do
155
+ instance = described_class.new(opts.merge(client_id: 'hello-world'))
156
+ expect(instance.client_id).to eql('hello-world')
157
+ expect(instance.connection.connection_builder.client_id).to eql('hello-world')
158
+ end
159
+ end
160
+
161
+ context "cert bundle" do
162
+ let(:complete_bundle) do
163
+ # NOTE these keys are obviously fake. We don't actually make connections
164
+ {
165
+ ssl_ca_cert: "----BEGIN CERT---- lkajdslkajdsjk ----END CERT---",
166
+ ssl_client_cert: "----BEGIN CERT---- lkajdslkajdsjk ----END CERT---",
167
+ ssl_client_cert_key: "----PRIVATE---- lkajdslkajdsjk ----PRIVATE---",
168
+ }
169
+ end
170
+
171
+ context "no certs" do
172
+ it 'creates a connection without an ssl_context' do
173
+ connection = instance.connection
174
+ expect(connection.connection_builder.ssl_context).to be_nil
175
+ end
176
+ end
177
+
178
+ context "partial certs passed in" do
179
+ it 'fails if the complete cert suite is not passed in' do
180
+ [:ssl_ca_cert, :ssl_client_cert, :ssl_client_cert_key].each do |param|
181
+ base_opts = opts
182
+ invalid_opts = complete_bundle.merge(base_opts)
183
+ invalid_opts[param] = nil
184
+ expect {
185
+ LogStashLogger::Device::Kafka.new(invalid_opts).connection
186
+ }.to raise_error( ArgumentError )
187
+ end
188
+ end
189
+ end
190
+
191
+ context "complete cert bundle" do
192
+
193
+ it 'correctly passes in the cert bundle to the Kafka Client' do
194
+ certopts = complete_bundle.merge(opts)
195
+
196
+ ssl_context = double("ssl_context")
197
+ expect(::Kafka::SslContext).to receive(:build)
198
+ .with(hash_including(
199
+ ca_cert: certopts[:ssl_ca_cert],
200
+ client_cert: certopts[:ssl_client_cert],
201
+ client_cert_key: certopts[:ssl_client_cert_key],
202
+ ))
203
+ .and_return(ssl_context)
204
+
205
+ LogStashLogger::Device::Kafka.new(certopts).connection
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ describe "writing single message to broker" do
213
+ it 'writes the message to the topic' do
214
+ producer = double('producer', produce: true)
215
+ connect_double = double("connection", producer: producer)
216
+ instance = described_class.new(opts)
217
+
218
+ # NOTE: this is stubbing out the ruby-kafka API
219
+ expect(instance).to receive(:connection).and_return(connect_double)
220
+ expect(connect_double).to receive(:producer)
221
+ expect(producer).to receive(:produce).and_return(true)
222
+ expect(producer).to receive(:deliver_messages).and_return(true)
223
+ instance.write_one("hello world")
224
+ end
225
+
226
+ it 'is capabable of writing to a different topic than instantiated' do
227
+ producer = double('producer', produce: lambda {|_message, _topic| "hi" })
228
+ connect_double = double("connection", producer: producer)
229
+ instance = described_class.new(opts)
230
+
231
+ message = 'hello world'
232
+ topic = 'my topic'
233
+ # NOTE: this is stubbing out the ruby-kafka API
234
+ expect(instance).to receive(:connection).and_return(connect_double)
235
+ expect(connect_double).to receive(:producer)
236
+ expect(producer).to receive(:produce).
237
+ with(message, topic: topic).
238
+ and_return(true)
239
+ expect(producer).to receive(:deliver_messages).and_return(true)
240
+
241
+ instance.write_one(message, topic)
242
+ end
243
+ end
244
+
245
+ describe "error handling" do
246
+ it "clears the producer and connection when an error occurs" do
247
+ producer = double('producer')
248
+ allow(producer).to receive(:produce).and_raise(StandardError, "boom")
249
+ allow(producer).to receive(:deliver_messages)
250
+
251
+ connection = double("connection", producer: producer, close: true)
252
+ instance = described_class.new(opts)
253
+
254
+ allow(instance).to receive(:connection).and_return(connection)
255
+ expect(connection).to receive(:close)
7
256
 
8
- before(:each) do
9
- allow(Poseidon::Producer).to receive(:new) { producer }
257
+ expect {
258
+ instance.write_one("hello world")
259
+ }.to raise_error(StandardError)
260
+
261
+ expect(instance.instance_variable_get(:@producer)).to be_nil
262
+ expect(instance.instance_variable_get(:@io)).to be_nil
263
+ end
10
264
  end
11
265
 
12
- it "writes to a Kafka topic" do
13
- expect(producer).to receive(:send_messages)
14
- kafka_device.write "foo"
266
+ describe "buffer group" do
267
+ it "uses the topic so final flush does not pass a boolean topic" do
268
+ instance = described_class.new(opts)
269
+
270
+ expect(instance).to receive(:write_batch).with(['hello world'], 'hello-world')
271
+ instance.write('hello world')
272
+ instance.buffer_flush(final: true)
273
+ end
15
274
  end
16
275
 
17
- it "defaults the Kafka hosts to ['localhost:9092']" do
18
- expect(kafka_device.hosts).to eq(['localhost:9092'])
276
+ describe "writing a batch of messages to the broker" do
277
+ it "writes the messages to the topic" do
278
+ producer = double('producer', produce: lambda {|_message, _topic| "hi" })
279
+ connect_double = double("connection", producer: producer)
280
+ instance = described_class.new(opts)
281
+
282
+ messages = ['hello world', 'goodbye world']
283
+ topic = 'my topic'
284
+ # NOTE: this is stubbing out the ruby-kafka API
285
+ expect(instance).to receive(:connection).and_return(connect_double)
286
+ expect(connect_double).to receive(:producer)
287
+ expect(producer).to receive(:produce).
288
+ with(messages.first, topic: topic).
289
+ and_return(true)
290
+ expect(producer).to receive(:produce).
291
+ with(messages.last, topic: topic).
292
+ and_return(true)
293
+ expect(producer).to receive(:deliver_messages).and_return(true)
294
+
295
+ instance.write_batch(messages, topic)
296
+ end
19
297
  end
20
298
 
21
- it "defaults the Kafka topic to 'logstash'" do
22
- expect(kafka_device.topic).to eq('logstash')
299
+ describe "connecting/reconnecting" do
300
+ it "sets @io when connecting" do
301
+ instance = described_class.new(opts)
302
+ connection = double("connection")
303
+
304
+ instance.instance_variable_set(:@io, connection)
305
+ expect(instance.connect).to eq(connection)
306
+ expect(instance.io).to eq(connection)
307
+ end
308
+
309
+ it "reconnects without raising" do
310
+ instance = described_class.new(opts)
311
+ connection = double("connection", close: true)
312
+
313
+ allow(instance).to receive(:connection).and_return(connection)
314
+ expect { instance.reconnect }.not_to raise_error
315
+ end
23
316
  end
24
317
 
25
- it "defaults the Kafka producer to 'logstash-logger'" do
26
- expect(kafka_device.producer).to eq('logstash-logger')
318
+ describe "producer lifecycle" do
319
+ it "memoizes the producer across writes" do
320
+ producer = double('producer', produce: true, deliver_messages: true)
321
+ connection = double("connection", producer: producer)
322
+ instance = described_class.new(opts)
323
+
324
+ expect(instance).to receive(:connection).and_return(connection)
325
+ expect(connection).to receive(:producer).once.and_return(producer)
326
+
327
+ instance.write_one("hello world")
328
+ instance.write_one("goodbye world")
329
+ end
330
+
331
+ it "shuts down the producer on close" do
332
+ instance = described_class.new(opts)
333
+ producer = double("producer", shutdown: true)
334
+ connection = double("connection", close: true)
335
+
336
+ instance.instance_variable_set(:@producer, producer)
337
+ instance.instance_variable_set(:@io, connection)
338
+
339
+ expect(producer).to receive(:shutdown)
340
+ expect(connection).to receive(:close)
341
+
342
+ instance.close
343
+ end
27
344
  end
28
345
 
29
- it "defaults the Kafka backoff to 1" do
30
- expect(kafka_device.backoff).to eq(1)
346
+ describe "closing connection" do
347
+ it "closes the Kafka connection when present" do
348
+ instance = described_class.new(opts)
349
+ connection = double("connection")
350
+ expect(connection).to receive(:close)
351
+
352
+ instance.instance_variable_set(:@io, connection)
353
+ instance.close
354
+
355
+ expect(instance.instance_variable_get(:@connection)).to be_nil
356
+ end
357
+ end
358
+ end
359
+
360
+ describe LogStashLogger::Device::Kafka, 'records the broker always rejects' do
361
+ # Stands in for a ruby-kafka producer: rejects any delivery that contains a
362
+ # record over the limit, the way a broker answers MESSAGE_TOO_LARGE.
363
+ class FakeKafkaProducer
364
+ LIMIT = 1_000
365
+
366
+ def initialize(delivered, errors = {})
367
+ @delivered = delivered
368
+ @errors = errors
369
+ @buffer = []
370
+ end
371
+
372
+ def produce(message, topic:)
373
+ raise ::Kafka::BufferOverflow, 'buffer full' if message.bytesize > LIMIT * 10
374
+ @buffer << message
375
+ end
376
+
377
+ def deliver_messages
378
+ if (error = @errors.delete(:next_delivery))
379
+ @buffer.clear
380
+ raise error
381
+ end
382
+ if @buffer.any? { |m| m.bytesize > LIMIT }
383
+ @buffer.clear
384
+ raise ::Kafka::MessageSizeTooLarge, 'message too large'
385
+ end
386
+ @delivered.concat(@buffer)
387
+ @buffer.clear
388
+ end
389
+
390
+ def shutdown; end
391
+ end
392
+
393
+ let(:delivered) { [] }
394
+ let(:errors) { {} }
395
+ let(:error_log) { StringIO.new }
396
+ let(:device) do
397
+ LogStashLogger::Device::Kafka.new(topic: 'logs', brokers: 'localhost:9092', sync: false,
398
+ buffer_max_interval: 3600, buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log))
399
+ end
400
+
401
+ before do
402
+ allow(device).to receive(:connection) do
403
+ double('connection', producer: FakeKafkaProducer.new(delivered, errors), close: true)
404
+ end
405
+ end
406
+
407
+ let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
408
+
409
+ it 'delivers the other records and drops only an oversized one' do
410
+ device.write_batch(small + ['x' * 2_000])
411
+ expect(delivered).to eq(small)
412
+ expect(error_log.string).to include('dropping a record of 2000 bytes that Kafka rejected: Kafka::MessageSizeTooLarge')
413
+ end
414
+
415
+ it 'drops a record the client buffer cannot hold' do
416
+ device.write_batch(small + ['x' * 20_000])
417
+ expect(delivered).to eq(small)
418
+ expect(error_log.string).to include('Kafka::BufferOverflow')
419
+ end
420
+
421
+ it 'delivers everything when the batch is only too large as a whole' do
422
+ allow(device).to receive(:connection) do
423
+ producer = FakeKafkaProducer.new(delivered, errors)
424
+ def producer.deliver_messages
425
+ raise ::Kafka::MessageSizeTooLarge, 'batch too large' if @buffer.size > 1
426
+ super
427
+ end
428
+ double('connection', producer: producer, close: true)
429
+ end
430
+ device.write_batch(small)
431
+ expect(delivered).to eq(small)
432
+ end
433
+
434
+ it 'still raises transient errors so the buffer retries the batch' do
435
+ errors[:next_delivery] = ::Kafka::ConnectionError.new('broker unavailable')
436
+ expect { device.write_batch(small) }.to raise_error(::Kafka::ConnectionError)
437
+ end
438
+
439
+ it 'clears the buffer instead of holding other records behind the rejected one' do
440
+ small.each { |m| device.write(m) }
441
+ device.write('x' * 2_000)
442
+ device.buffer_flush(force: true)
443
+
444
+ expect(delivered).to eq(small)
445
+ expect(device.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
31
446
  end
32
447
  end
@@ -30,9 +30,12 @@ describe LogStashLogger::Device::Kinesis do
30
30
  response.records = [failed_record]
31
31
 
32
32
  expect(client).to receive(:put_records) { response }
33
- expect(kinesis_device).to receive(:write).with("foo")
33
+ expect(kinesis_device).not_to receive(:write)
34
34
 
35
35
  kinesis_device.write_one "foo"
36
+ state = kinesis_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 kinesis stream to logstash" do