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.
@@ -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
+