fluent-plugin-scalyr 0.7.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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
+