fluent-plugin-scalyr 0.7.4
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 +7 -0
- data/AUTHORS +1 -0
- data/Gemfile +3 -0
- data/LICENSE +202 -0
- data/README.md +166 -0
- data/Rakefile +11 -0
- data/VERSION +1 -0
- data/fluent-plugin-scalyr.gemspec +23 -0
- data/fluent.conf.sample +28 -0
- data/lib/fluent/plugin/out_scalyr.rb +350 -0
- data/lib/fluent/plugin/scalyr-exceptions.rb +24 -0
- data/test/helper.rb +36 -0
- data/test/test_config.rb +58 -0
- data/test/test_events.rb +248 -0
- data/test/test_handle_response.rb +90 -0
- data/test/test_ssl_verify.rb +35 -0
- metadata +139 -0
@@ -0,0 +1,350 @@
|
|
1
|
+
#
|
2
|
+
# Scalyr Output Plugin for Fluentd
|
3
|
+
#
|
4
|
+
# Copyright (C) 2015 Scalyr, Inc.
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
|
18
|
+
|
19
|
+
require 'fluent/plugin/scalyr-exceptions'
|
20
|
+
require 'json'
|
21
|
+
require 'net/http'
|
22
|
+
require 'net/https'
|
23
|
+
require 'securerandom'
|
24
|
+
require 'thread'
|
25
|
+
|
26
|
+
module Scalyr
|
27
|
+
class ScalyrOut < Fluent::BufferedOutput
|
28
|
+
Fluent::Plugin.register_output( 'scalyr', self )
|
29
|
+
|
30
|
+
config_param :api_write_token, :string
|
31
|
+
config_param :server_attributes, :hash, :default => nil
|
32
|
+
config_param :scalyr_server, :string, :default => "https://agent.scalyr.com/"
|
33
|
+
config_param :ssl_ca_bundle_path, :string, :default => "/etc/ssl/certs/ca-bundle.crt"
|
34
|
+
config_param :ssl_verify_peer, :bool, :default => true
|
35
|
+
config_param :ssl_verify_depth, :integer, :default => 5
|
36
|
+
config_param :message_field, :string, :default => "message"
|
37
|
+
config_param :max_request_buffer, :integer, :default => 1024*1024
|
38
|
+
config_param :force_message_encoding, :string, :default => nil
|
39
|
+
config_param :replace_invalid_utf8, :bool, :default => false
|
40
|
+
|
41
|
+
config_set_default :retry_limit, 40 #try a maximum of 40 times before discarding
|
42
|
+
config_set_default :retry_wait, 5 #wait a minimum of 5 seconds before retrying again
|
43
|
+
config_set_default :max_retry_wait, 30 #wait a maximum of 30 seconds per retry
|
44
|
+
config_set_default :flush_interval, 5 #default flush interval of 5 seconds
|
45
|
+
|
46
|
+
def configure( conf )
|
47
|
+
#need to call this before super because there doesn't seem to be any other way to
|
48
|
+
#set the default value for the buffer_chunk_limit, which is created and configured in super
|
49
|
+
if !conf.key? "buffer_chunk_limit"
|
50
|
+
conf["buffer_chunk_limit"] = "100k"
|
51
|
+
end
|
52
|
+
if !conf.key? "buffer_queue_limit"
|
53
|
+
conf["buffer_queue_limit"] = 1024
|
54
|
+
end
|
55
|
+
super
|
56
|
+
|
57
|
+
if @buffer.buffer_chunk_limit > 1024*1024
|
58
|
+
$log.warn "Buffer chunk size is greater than 1Mb. This may result in requests being rejected by Scalyr"
|
59
|
+
end
|
60
|
+
|
61
|
+
if @max_request_buffer > (1024*1024*3)
|
62
|
+
$log.warn "Maximum request buffer > 3Mb. This may result in requests being rejected by Scalyr"
|
63
|
+
end
|
64
|
+
|
65
|
+
@message_encoding = nil
|
66
|
+
if @force_message_encoding.to_s != ''
|
67
|
+
begin
|
68
|
+
@message_encoding = Encoding.find( @force_message_encoding )
|
69
|
+
$log.debug "Forcing message encoding to '#{@force_message_encoding}'"
|
70
|
+
rescue ArgumentError
|
71
|
+
$log.warn "No encoding '#{@force_message_encoding}' found. Ignoring"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
@scalyr_server << '/' unless @scalyr_server.end_with?('/')
|
76
|
+
|
77
|
+
@add_events_uri = URI @scalyr_server + "addEvents"
|
78
|
+
|
79
|
+
#forcibly limit the number of threads to 1 for now, to ensure requests always have incrementing timestamps
|
80
|
+
raise Fluent::ConfigError, "num_threads is currently limited to 1. You specified #{@num_threads}." if @num_threads > 1
|
81
|
+
end
|
82
|
+
|
83
|
+
def start
|
84
|
+
super
|
85
|
+
$log.info "Scalyr Fluentd Plugin ID - #{self.plugin_id()}"
|
86
|
+
#Generate a session id. This will be called once for each <match> in fluent.conf that uses scalyr
|
87
|
+
@session = SecureRandom.uuid
|
88
|
+
|
89
|
+
@sync = Mutex.new
|
90
|
+
#the following variables are all under the control of the above mutex
|
91
|
+
@thread_ids = Hash.new #hash of tags -> id
|
92
|
+
@next_id = 1 #incrementing thread id for the session
|
93
|
+
@last_timestamp = 0 #timestamp of most recent event
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
def format( tag, time, record )
|
98
|
+
begin
|
99
|
+
if @message_field != "message"
|
100
|
+
if record.key? @message_field
|
101
|
+
if record.key? "message"
|
102
|
+
$log.warn "Overwriting log record field 'message'. You are seeing this warning because in your fluentd config file you have configured the '#{@message_field}' field to be converted to the 'message' field, but the log record already contains a field called 'message' and this is now being overwritten."
|
103
|
+
end
|
104
|
+
record["message"] = record[@message_field]
|
105
|
+
record.delete( @message_field )
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
if @message_encoding
|
110
|
+
if @replace_invalid_utf8 and @message_encoding == Encoding::UTF_8
|
111
|
+
record["message"] = record["message"].encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "<?>").force_encoding('UTF-8')
|
112
|
+
else
|
113
|
+
record["message"].force_encoding( @message_encoding )
|
114
|
+
end
|
115
|
+
end
|
116
|
+
[tag, time, record].to_msgpack
|
117
|
+
|
118
|
+
rescue JSON::GeneratorError
|
119
|
+
$log.warn "Unable to format message due to JSON::GeneratorError. Record is:\n\t#{record.to_s}"
|
120
|
+
raise
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
#called by fluentd when a chunk of log messages is ready
|
125
|
+
def write( chunk )
|
126
|
+
begin
|
127
|
+
$log.debug "Size of chunk is: #{chunk.size}"
|
128
|
+
requests = self.build_add_events_body( chunk )
|
129
|
+
$log.debug "Chunk split into #{requests.size} request(s)."
|
130
|
+
|
131
|
+
requests.each_with_index { |request, index|
|
132
|
+
$log.debug "Request #{index + 1}/#{requests.size}: #{request[:body].bytesize} bytes"
|
133
|
+
begin
|
134
|
+
response = self.post_request( @add_events_uri, request[:body] )
|
135
|
+
self.handle_response( response )
|
136
|
+
rescue OpenSSL::SSL::SSLError => e
|
137
|
+
if e.message.include? "certificate verify failed"
|
138
|
+
$log.warn "SSL certificate verification failed. Please make sure your certificate bundle is configured correctly and points to a valid file. You can configure this with the ssl_ca_bundle_path configuration option. The current value of ssl_ca_bundle_path is '#{@ssl_ca_bundle_path}'"
|
139
|
+
end
|
140
|
+
$log.warn e.message
|
141
|
+
$log.warn "Discarding buffer chunk without retrying or logging to <secondary>"
|
142
|
+
rescue Scalyr::Client4xxError => e
|
143
|
+
$log.warn "4XX status code received for request #{index + 1}/#{requests.size}. Discarding buffer without retrying or logging.\n\t#{response.code} - #{e.message}\n\tChunk Size: #{chunk.size}\n\tLog messages this request: #{request[:record_count]}\n\tJSON payload size: #{request[:body].bytesize}\n\tSample: #{request[:body][0,1024]}..."
|
144
|
+
|
145
|
+
end
|
146
|
+
}
|
147
|
+
|
148
|
+
rescue JSON::GeneratorError
|
149
|
+
$log.warn "Unable to format message due to JSON::GeneratorError."
|
150
|
+
raise
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
|
155
|
+
|
156
|
+
#explicit function to convert to nanoseconds
|
157
|
+
#will make things easier to maintain if/when fluentd supports higher than second resolutions
|
158
|
+
def to_nanos( seconds )
|
159
|
+
seconds * 10**9
|
160
|
+
end
|
161
|
+
|
162
|
+
#explicit function to convert to milliseconds
|
163
|
+
#will make things easier to maintain if/when fluentd supports higher than second resolutions
|
164
|
+
def to_millis( seconds )
|
165
|
+
seconds * 10**6
|
166
|
+
end
|
167
|
+
|
168
|
+
def post_request( uri, body )
|
169
|
+
|
170
|
+
https = Net::HTTP.new( uri.host, uri.port )
|
171
|
+
https.use_ssl = true
|
172
|
+
|
173
|
+
#verify peers to prevent potential MITM attacks
|
174
|
+
if @ssl_verify_peer
|
175
|
+
https.ca_file = @ssl_ca_bundle_path
|
176
|
+
https.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
177
|
+
https.verify_depth = @ssl_verify_depth
|
178
|
+
end
|
179
|
+
|
180
|
+
post = Net::HTTP::Post.new uri.path
|
181
|
+
post.add_field( 'Content-Type', 'application/json' )
|
182
|
+
|
183
|
+
post.body = body
|
184
|
+
|
185
|
+
https.request( post )
|
186
|
+
|
187
|
+
end
|
188
|
+
|
189
|
+
def handle_response( response )
|
190
|
+
$log.debug "Response Code: #{response.code}"
|
191
|
+
$log.debug "Response Body: #{response.body}"
|
192
|
+
|
193
|
+
response_hash = Hash.new
|
194
|
+
|
195
|
+
begin
|
196
|
+
response_hash = JSON.parse( response.body )
|
197
|
+
rescue
|
198
|
+
response_hash["status"] = "Invalid JSON response from server"
|
199
|
+
end
|
200
|
+
|
201
|
+
#make sure the JSON reponse has a "status" field
|
202
|
+
if !response_hash.key? "status"
|
203
|
+
$log.debug "JSON response does not contain status message"
|
204
|
+
raise Scalyr::ServerError.new "JSON response does not contain status message"
|
205
|
+
end
|
206
|
+
|
207
|
+
status = response_hash["status"]
|
208
|
+
|
209
|
+
#4xx codes are handled separately
|
210
|
+
if response.code =~ /^4\d\d/
|
211
|
+
raise Scalyr::Client4xxError.new status
|
212
|
+
else
|
213
|
+
if status != "success"
|
214
|
+
if status =~ /discardBuffer/
|
215
|
+
$log.warn "Received 'discardBuffer' message from server. Buffer dropped."
|
216
|
+
elsif status =~ %r"/client/"i
|
217
|
+
raise Scalyr::ClientError.new status
|
218
|
+
else #don't check specifically for server, we assume all non-client errors are server errors
|
219
|
+
raise Scalyr::ServerError.new status
|
220
|
+
end
|
221
|
+
elsif !response.code.include? "200" #response code is a string not an int
|
222
|
+
raise Scalyr::ServerError
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
end
|
227
|
+
|
228
|
+
def build_add_events_body( chunk )
|
229
|
+
|
230
|
+
#requests
|
231
|
+
requests = Array.new
|
232
|
+
|
233
|
+
#set of unique scalyr threads for this chunk
|
234
|
+
current_threads = Hash.new
|
235
|
+
|
236
|
+
#byte count
|
237
|
+
total_bytes = 0
|
238
|
+
|
239
|
+
#create a Scalyr event object for each record in the chunk
|
240
|
+
events = Array.new
|
241
|
+
chunk.msgpack_each {|(tag,time,record)|
|
242
|
+
|
243
|
+
timestamp = self.to_nanos( time )
|
244
|
+
thread_id = 0
|
245
|
+
|
246
|
+
@sync.synchronize {
|
247
|
+
#ensure timestamp is at least 1 nanosecond greater than the last one
|
248
|
+
timestamp = [timestamp, @last_timestamp + 1].max
|
249
|
+
@last_timestamp = timestamp
|
250
|
+
|
251
|
+
#get thread id or add a new one if we haven't seen this tag before
|
252
|
+
if @thread_ids.key? tag
|
253
|
+
thread_id = @thread_ids[tag]
|
254
|
+
else
|
255
|
+
thread_id = @next_id
|
256
|
+
@thread_ids[tag] = thread_id
|
257
|
+
@next_id += 1
|
258
|
+
end
|
259
|
+
}
|
260
|
+
|
261
|
+
#then update the map of threads for this chunk
|
262
|
+
current_threads[tag] = thread_id
|
263
|
+
|
264
|
+
#add a logfile field if one doesn't exist
|
265
|
+
if !record.key? "logfile"
|
266
|
+
record["logfile"] = "/fluentd/#{tag}"
|
267
|
+
end
|
268
|
+
|
269
|
+
#append to list of events
|
270
|
+
event = { :thread => thread_id.to_s,
|
271
|
+
:ts => timestamp.to_s,
|
272
|
+
:attrs => record
|
273
|
+
}
|
274
|
+
|
275
|
+
#get json string of event to keep track of how many bytes we are sending
|
276
|
+
|
277
|
+
begin
|
278
|
+
event_json = event.to_json
|
279
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError => e
|
280
|
+
$log.warn "#{e.class}: #{e.message}"
|
281
|
+
|
282
|
+
# Send the faulty event to a label @ERROR block and allow to handle it there (output to exceptions file for ex)
|
283
|
+
router.emit_error_event(tag, time, record, e)
|
284
|
+
|
285
|
+
event[:attrs].each do |key, value|
|
286
|
+
$log.debug "\t#{key} (#{value.encoding.name}): '#{value}'"
|
287
|
+
event[:attrs][key] = value.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "<?>").force_encoding('UTF-8')
|
288
|
+
end
|
289
|
+
event_json = event.to_json
|
290
|
+
end
|
291
|
+
|
292
|
+
#generate new request if json size of events in the array exceed maximum request buffer size
|
293
|
+
append_event = true
|
294
|
+
if total_bytes + event_json.bytesize > @max_request_buffer
|
295
|
+
#make sure we always have at least one event
|
296
|
+
if events.size == 0
|
297
|
+
events << event
|
298
|
+
append_event = false
|
299
|
+
end
|
300
|
+
request = self.create_request( events, current_threads )
|
301
|
+
requests << request
|
302
|
+
|
303
|
+
total_bytes = 0
|
304
|
+
current_threads = Hash.new
|
305
|
+
events = Array.new
|
306
|
+
end
|
307
|
+
|
308
|
+
#if we haven't consumed the current event already
|
309
|
+
#add it to the end of our array and keep track of the json bytesize
|
310
|
+
if append_event
|
311
|
+
events << event
|
312
|
+
total_bytes += event_json.bytesize
|
313
|
+
end
|
314
|
+
|
315
|
+
}
|
316
|
+
|
317
|
+
#create a final request with any left over events
|
318
|
+
request = self.create_request( events, current_threads )
|
319
|
+
requests << request
|
320
|
+
|
321
|
+
end
|
322
|
+
|
323
|
+
def create_request( events, current_threads )
|
324
|
+
#build the scalyr thread objects
|
325
|
+
threads = Array.new
|
326
|
+
current_threads.each do |tag, id|
|
327
|
+
threads << { :id => id.to_s,
|
328
|
+
:name => "Fluentd: #{tag}"
|
329
|
+
}
|
330
|
+
end
|
331
|
+
|
332
|
+
current_time = self.to_millis( Fluent::Engine.now )
|
333
|
+
|
334
|
+
body = { :token => @api_write_token,
|
335
|
+
:client_timestamp => current_time.to_s,
|
336
|
+
:session => @session,
|
337
|
+
:events => events,
|
338
|
+
:threads => threads
|
339
|
+
}
|
340
|
+
|
341
|
+
#add server_attributes hash if it exists
|
342
|
+
if @server_attributes
|
343
|
+
body[:sessionInfo] = @server_attributes
|
344
|
+
end
|
345
|
+
|
346
|
+
{ :body => body.to_json, :record_count => events.size }
|
347
|
+
end
|
348
|
+
|
349
|
+
end
|
350
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
#
|
2
|
+
# Scalyr Output Plugin for Fluentd
|
3
|
+
#
|
4
|
+
# Copyright (C) 2015 Scalyr, Inc.
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
|
18
|
+
|
19
|
+
|
20
|
+
module Scalyr
|
21
|
+
class ClientError < StandardError; end
|
22
|
+
class Client4xxError < StandardError; end
|
23
|
+
class ServerError < StandardError; end
|
24
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
#
|
2
|
+
# Scalyr Output Plugin for Fluentd
|
3
|
+
#
|
4
|
+
# Copyright (C) 2015 Scalyr, Inc.
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
|
18
|
+
|
19
|
+
require 'fluent/test'
|
20
|
+
require 'fluent/plugin/out_scalyr'
|
21
|
+
|
22
|
+
module Scalyr
|
23
|
+
class ScalyrOutTest < Test::Unit::TestCase
|
24
|
+
def setup
|
25
|
+
Fluent::Test.setup
|
26
|
+
end
|
27
|
+
|
28
|
+
CONFIG = %[
|
29
|
+
api_write_token test_token
|
30
|
+
]
|
31
|
+
|
32
|
+
def create_driver( conf = CONFIG )
|
33
|
+
Fluent::Test::BufferedOutputTestDriver.new( Scalyr::ScalyrOut ).configure( conf )
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/test/test_config.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
#
|
2
|
+
# Scalyr Output Plugin for Fluentd
|
3
|
+
#
|
4
|
+
# Copyright (C) 2015 Scalyr, Inc.
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
|
18
|
+
|
19
|
+
require 'helper'
|
20
|
+
|
21
|
+
class ConfigTest < Scalyr::ScalyrOutTest
|
22
|
+
|
23
|
+
def test_default_params
|
24
|
+
d = create_driver
|
25
|
+
assert_nil( d.instance.server_attributes, "Default server_attributes not nil" )
|
26
|
+
assert( d.instance.ssl_verify_peer, "Default ssl_verify_peer should be true" )
|
27
|
+
|
28
|
+
#check default buffer limits because they are set outside of the config_set_default
|
29
|
+
assert_equal( 100*1024, d.instance.buffer.buffer_chunk_limit, "Buffer chunk limit should be 100k" )
|
30
|
+
assert_equal( 1024, d.instance.buffer.buffer_queue_limit, "Buffer queue limit should be 1024" )
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_configure_ssl_verify_peer
|
34
|
+
d = create_driver CONFIG + 'ssl_verify_peer false'
|
35
|
+
assert( !d.instance.ssl_verify_peer, "Config failed to set ssl_verify_peer" )
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_scalyr_server_adding_trailing_slash
|
39
|
+
d = create_driver CONFIG + 'scalyr_server http://www.example.com'
|
40
|
+
assert_equal( "http://www.example.com/", d.instance.scalyr_server, "Missing trailing slash for scalyr_server" )
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_configure_ssl_ca_bundle_path
|
44
|
+
d = create_driver CONFIG + 'ssl_ca_bundle_path /test/ca-bundle.crt'
|
45
|
+
assert_equal( "/test/ca-bundle.crt", d.instance.ssl_ca_bundle_path, "Config failed to set ssl_ca_bundle_path" )
|
46
|
+
end
|
47
|
+
|
48
|
+
def test_configure_ssl_verify_depth
|
49
|
+
d = create_driver CONFIG + 'ssl_verify_depth 10'
|
50
|
+
assert_equal( 10, d.instance.ssl_verify_depth, "Config failed to set ssl_verify_depth" )
|
51
|
+
end
|
52
|
+
|
53
|
+
def test_configure_server_attributes
|
54
|
+
d = create_driver CONFIG + 'server_attributes { "test":"value" }'
|
55
|
+
assert_equal( "value", d.instance.server_attributes["test"], "Config failed to set server_attributes" )
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|