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,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
|
data/spec/device/kafka_spec.rb
CHANGED
|
@@ -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(:
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
data/spec/device/kinesis_spec.rb
CHANGED
|
@@ -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).
|
|
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
|