fluent-plugin-out-http 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ *~
2
+ \#*
3
+ .\#*
4
+ *.gem
5
+ .bundle
6
+ Gemfile.lock
7
+ vendor
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in fluent-plugin-out-http.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,11 @@
1
+ Licensed under the Apache License, Version 2.0 (the "License");
2
+ you may not use this file except in compliance with the License.
3
+ You may obtain a copy of the License at
4
+
5
+ http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software
8
+ distributed under the License is distributed on an "AS IS" BASIS,
9
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ See the License for the specific language governing permissions and
11
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # fluent-plugin-out-http
2
+
3
+ A generic [fluentd][1] output plugin for sending logs to an HTTP endpoint.
4
+
5
+ ## Configs
6
+
7
+ <match *>
8
+ type http
9
+ endpoint_url http://localhost.local/api/
10
+ http_method put
11
+ serializer json
12
+ rate_limit_msec 100
13
+ authentication basic
14
+ username alice
15
+ password bobpop
16
+ </match>
17
+
18
+ ----
19
+
20
+ Heavily based on [fluent-plugin-growthforecast][2]
21
+
22
+ [1]: http://fluentd.org/
23
+ [2]: https://github.com/tagomoris/fluent-plugin-growthforecast
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ require 'rake/testtask'
5
+ Rake::TestTask.new(:test) do |test|
6
+ test.libs << 'lib' << 'test'
7
+ test.pattern = 'test/**/test_*.rb'
8
+ test.verbose = true
9
+ end
10
+
11
+ task :default => :test
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.name = "fluent-plugin-out-http"
5
+ gem.version = "0.1.0"
6
+ gem.authors = ["Marica Odagaki"]
7
+ gem.email = ["ento.entotto@gmail.com"]
8
+ gem.summary = %q{A generic Fluentd output plugin to send logs to an HTTP endpoint}
9
+ gem.description = gem.summary
10
+ gem.homepage = "https://github.com/ento/fluent-plugin-out-http"
11
+
12
+ gem.files = `git ls-files`.split($\)
13
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
14
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
15
+ gem.require_paths = ["lib"]
16
+
17
+ gem.add_development_dependency "bundler"
18
+ gem.add_development_dependency "json"
19
+ gem.add_development_dependency "fluentd"
20
+ gem.add_runtime_dependency "fluentd"
21
+ end
@@ -0,0 +1,127 @@
1
+ require 'json'
2
+
3
+ class Fluent::HTTPOutput < Fluent::Output
4
+ Fluent::Plugin.register_output('http', self)
5
+
6
+ def initialize
7
+ super
8
+ require 'net/http'
9
+ require 'uri'
10
+ end
11
+
12
+ # Endpoint URL ex. localhost.local/api/
13
+ config_param :endpoint_url, :string
14
+
15
+ # HTTP method
16
+ config_param :http_method, :string, :default => :post
17
+
18
+ # form | json
19
+ config_param :serializer, :string, :default => :form
20
+
21
+ # Simple rate limiting: ignore any records within `rate_limit_msec`
22
+ # since the last one.
23
+ config_param :rate_limit_msec, :integer, :default => 0
24
+
25
+ # nil | 'none' | 'basic'
26
+ config_param :authentication, :string, :default => nil
27
+ config_param :username, :string, :default => ''
28
+ config_param :password, :string, :default => ''
29
+
30
+ def configure(conf)
31
+ super
32
+
33
+ serializers = [:json, :form]
34
+ @serializer = if serializers.include? @serializer.intern
35
+ @serializer.intern
36
+ else
37
+ :form
38
+ end
39
+
40
+ http_methods = [:get, :put, :post, :delete]
41
+ @http_method = if http_methods.include? @http_method.intern
42
+ @http_method.intern
43
+ else
44
+ :post
45
+ end
46
+
47
+ @auth = case @authentication
48
+ when 'basic' then :basic
49
+ else
50
+ :none
51
+ end
52
+ end
53
+
54
+ def start
55
+ super
56
+ end
57
+
58
+ def shutdown
59
+ super
60
+ end
61
+
62
+ def format_url(tag, time, record)
63
+ @endpoint_url
64
+ end
65
+
66
+ def set_body(req, tag, time, record)
67
+ if @serializer == :json
68
+ set_json_body(req, record)
69
+ else
70
+ req.set_form_data(record)
71
+ end
72
+ req
73
+ end
74
+
75
+ def set_header(req, tag, time, record)
76
+ req
77
+ end
78
+
79
+ def set_json_body(req, data)
80
+ req.body = JSON.dump(data)
81
+ req['Content-Type'] = 'application/json'
82
+ end
83
+
84
+ def create_request(tag, time, record)
85
+ url = format_url(tag, time, record)
86
+ uri = URI.parse(url)
87
+ req = Net::HTTP.const_get(@http_method.to_s.capitalize).new(uri.path)
88
+ set_body(req, tag, time, record)
89
+ set_header(req, tag, time, record)
90
+ return req, uri
91
+ end
92
+
93
+ def send_request(req, uri)
94
+ is_rate_limited = (@rate_limit_msec != 0 and not @last_request_time.nil?)
95
+ if is_rate_limited and ((Time.now.to_f - @last_request_time) * 1000.0 < @rate_limit_msec)
96
+ $log.info('Dropped request due to rate limiting')
97
+ return
98
+ end
99
+
100
+ res = nil
101
+ begin
102
+ if @auth and @auth == :basic
103
+ req.basic_auth(@username, @password)
104
+ end
105
+ @last_request_time = Time.now.to_f
106
+ res = Net::HTTP.new(uri.host, uri.port).start {|http| http.request(req) }
107
+ rescue IOError, EOFError, SystemCallError
108
+ # server didn't respond
109
+ $log.warn "Net::HTTP.#{req.method.capitalize} raises exception: #{$!.class}, '#{$!.message}'"
110
+ end
111
+ unless res and res.is_a?(Net::HTTPSuccess)
112
+ $log.warn "failed to #{req.method} #{uri} (#{res.code} #{res.message} #{res.body})"
113
+ end
114
+ end
115
+
116
+ def handle_record(tag, time, record)
117
+ req, uri = create_request(tag, time, record)
118
+ send_request(req, uri)
119
+ end
120
+
121
+ def emit(tag, es, chain)
122
+ es.each do |time, record|
123
+ handle_record(tag, time, record)
124
+ end
125
+ chain.next
126
+ end
127
+ end
@@ -0,0 +1,54 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+
4
+ begin
5
+ Bundler.setup(:default, :development)
6
+ rescue Bundler::BundlerError => e
7
+ $stderr.puts e.message
8
+ $stderr.puts "Run `bundle install` to install missing gems"
9
+ exit e.status_code
10
+ end
11
+
12
+ require 'test/unit'
13
+
14
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
15
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
16
+
17
+ require 'fluent/test'
18
+
19
+ unless ENV.has_key?('VERBOSE')
20
+ nulllogger = Object.new
21
+ nulllogger.instance_eval {|obj|
22
+ def method_missing(method, *args)
23
+ # pass
24
+ end
25
+ }
26
+ $log = nulllogger
27
+ end
28
+
29
+ class Test::Unit::TestCase
30
+ end
31
+
32
+ require 'webrick'
33
+
34
+ # to handle POST/PUT/DELETE ...
35
+ module WEBrick::HTTPServlet
36
+ class ProcHandler < AbstractServlet
37
+ alias do_POST do_GET
38
+ alias do_PUT do_GET
39
+ alias do_DELETE do_GET
40
+ end
41
+ end
42
+
43
+ def get_code(server, port, path, headers={})
44
+ require 'net/http'
45
+ Net::HTTP.start(server, port){|http|
46
+ http.get(path, headers).code
47
+ }
48
+ end
49
+ def get_content(server, port, path, headers={})
50
+ require 'net/http'
51
+ Net::HTTP.start(server, port){|http|
52
+ http.get(path, headers).body
53
+ }
54
+ end
@@ -0,0 +1,281 @@
1
+ require 'json'
2
+ require 'fluent/test/http_output_test'
3
+ require 'fluent/plugin/out_http'
4
+
5
+
6
+ TEST_LISTEN_PORT = 5126
7
+
8
+
9
+ class HTTPOutputTestBase < Test::Unit::TestCase
10
+ # setup / teardown for servers
11
+ def setup
12
+ Fluent::Test.setup
13
+ @posts = []
14
+ @puts = []
15
+ @prohibited = 0
16
+ @auth = false
17
+ @dummy_server_thread = Thread.new do
18
+ srv = if ENV['VERBOSE']
19
+ WEBrick::HTTPServer.new({:BindAddress => '127.0.0.1', :Port => TEST_LISTEN_PORT})
20
+ else
21
+ logger = WEBrick::Log.new('/dev/null', WEBrick::BasicLog::DEBUG)
22
+ WEBrick::HTTPServer.new({:BindAddress => '127.0.0.1', :Port => TEST_LISTEN_PORT, :Logger => logger, :AccessLog => []})
23
+ end
24
+ begin
25
+ allowed_methods = %w(POST PUT)
26
+ srv.mount_proc('/api/') { |req,res|
27
+ unless allowed_methods.include? req.request_method
28
+ res.status = 405
29
+ res.body = 'request method mismatch'
30
+ next
31
+ end
32
+ if @auth and req.header['authorization'][0] == 'Basic YWxpY2U6c2VjcmV0IQ==' # pattern of user='alice' passwd='secret!'
33
+ # ok, authorized
34
+ elsif @auth
35
+ res.status = 403
36
+ @prohibited += 1
37
+ next
38
+ else
39
+ # ok, authorization not required
40
+ end
41
+
42
+ record = {:auth => nil}
43
+ if req.content_type == 'application/json'
44
+ record[:json] = JSON.parse(req.body)
45
+ else
46
+ record[:form] = Hash[*(req.body.split('&').map{|kv|kv.split('=')}.flatten)]
47
+ end
48
+
49
+ instance_variable_get("@#{req.request_method.downcase}s").push(record)
50
+
51
+ res.status = 200
52
+ }
53
+ srv.mount_proc('/') { |req,res|
54
+ res.status = 200
55
+ res.body = 'running'
56
+ }
57
+ srv.start
58
+ ensure
59
+ srv.shutdown
60
+ end
61
+ end
62
+
63
+ # to wait completion of dummy server.start()
64
+ require 'thread'
65
+ cv = ConditionVariable.new
66
+ watcher = Thread.new {
67
+ connected = false
68
+ while not connected
69
+ begin
70
+ get_content('localhost', TEST_LISTEN_PORT, '/')
71
+ connected = true
72
+ rescue Errno::ECONNREFUSED
73
+ sleep 0.1
74
+ rescue StandardError => e
75
+ p e
76
+ sleep 0.1
77
+ end
78
+ end
79
+ cv.signal
80
+ }
81
+ mutex = Mutex.new
82
+ mutex.synchronize {
83
+ cv.wait(mutex)
84
+ }
85
+ end
86
+
87
+ def test_dummy_server
88
+ host = '127.0.0.1'
89
+ port = TEST_LISTEN_PORT
90
+ client = Net::HTTP.start(host, port)
91
+
92
+ assert_equal '200', client.request_get('/').code
93
+ assert_equal '200', client.request_post('/api/service/metrics/hoge', 'number=1&mode=gauge').code
94
+
95
+ assert_equal 1, @posts.size
96
+
97
+ assert_equal '1', @posts[0][:form]['number']
98
+ assert_equal 'gauge', @posts[0][:form]['mode']
99
+ assert_nil @posts[0][:auth]
100
+
101
+ @auth = true
102
+
103
+ assert_equal '403', client.request_post('/api/service/metrics/pos', 'number=30&mode=gauge').code
104
+
105
+ req_with_auth = lambda do |number, mode, user, pass|
106
+ url = URI.parse("http://#{host}:#{port}/api/service/metrics/pos")
107
+ req = Net::HTTP::Post.new(url.path)
108
+ req.basic_auth user, pass
109
+ req.set_form_data({'number'=>number, 'mode'=>mode})
110
+ req
111
+ end
112
+
113
+ assert_equal '403', client.request(req_with_auth.call(500, 'count', 'alice', 'wrong password!')).code
114
+
115
+ assert_equal '403', client.request(req_with_auth.call(500, 'count', 'alice', 'wrong password!')).code
116
+
117
+ assert_equal 1, @posts.size
118
+
119
+ assert_equal '200', client.request(req_with_auth.call(500, 'count', 'alice', 'secret!')).code
120
+
121
+ assert_equal 2, @posts.size
122
+
123
+ end
124
+
125
+ def teardown
126
+ @dummy_server_thread.kill
127
+ @dummy_server_thread.join
128
+ end
129
+ end
130
+
131
+ class HTTPOutputTest < HTTPOutputTestBase
132
+ CONFIG = %[
133
+ endpoint_url http://127.0.0.1:#{TEST_LISTEN_PORT}/api/
134
+ ]
135
+
136
+ CONFIG_JSON = %[
137
+ endpoint_url http://127.0.0.1:#{TEST_LISTEN_PORT}/api/
138
+ serializer json
139
+ ]
140
+
141
+ CONFIG_PUT = %[
142
+ endpoint_url http://127.0.0.1:#{TEST_LISTEN_PORT}/api/
143
+ http_method put
144
+ ]
145
+
146
+ RATE_LIMIT_MSEC = 1200
147
+
148
+ CONFIG_RATE_LIMIT = %[
149
+ endpoint_url http://127.0.0.1:#{TEST_LISTEN_PORT}/api/
150
+ rate_limit_msec #{RATE_LIMIT_MSEC}
151
+ ]
152
+
153
+ def create_driver(conf=CONFIG, tag='test.metrics')
154
+ Fluent::Test::OutputTestDriver.new(Fluent::HTTPOutput, tag).configure(conf)
155
+ end
156
+
157
+ def test_configure
158
+ d = create_driver
159
+ assert_equal "http://127.0.0.1:#{TEST_LISTEN_PORT}/api/", d.instance.endpoint_url
160
+ assert_equal :form, d.instance.serializer
161
+
162
+ d = create_driver CONFIG_JSON
163
+ assert_equal "http://127.0.0.1:#{TEST_LISTEN_PORT}/api/", d.instance.endpoint_url
164
+ assert_equal :json, d.instance.serializer
165
+ end
166
+
167
+ def test_emit_form
168
+ d = create_driver
169
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
170
+ d.run
171
+
172
+ assert_equal 1, @posts.size
173
+ record = @posts[0]
174
+
175
+ assert_equal '50', record[:form]['field1']
176
+ assert_equal '20', record[:form]['field2']
177
+ assert_equal '10', record[:form]['field3']
178
+ assert_equal '1', record[:form]['otherfield']
179
+ assert_nil record[:auth]
180
+
181
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
182
+ d.run
183
+
184
+ assert_equal 2, @posts.size
185
+ end
186
+
187
+ def test_emit_form_put
188
+ d = create_driver CONFIG_PUT
189
+ d.emit({ 'field1' => 50 })
190
+ d.run
191
+
192
+ assert_equal 0, @posts.size
193
+ assert_equal 1, @puts.size
194
+ record = @puts[0]
195
+
196
+ assert_equal '50', record[:form]['field1']
197
+ assert_nil record[:auth]
198
+
199
+ d.emit({ 'field1' => 50 })
200
+ d.run
201
+
202
+ assert_equal 0, @posts.size
203
+ assert_equal 2, @puts.size
204
+ end
205
+
206
+ def test_emit_json
207
+ d = create_driver CONFIG_JSON
208
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
209
+ d.run
210
+
211
+ assert_equal 1, @posts.size
212
+ record = @posts[0]
213
+
214
+ assert_equal 50, record[:json]['field1']
215
+ assert_equal 20, record[:json]['field2']
216
+ assert_equal 10, record[:json]['field3']
217
+ assert_equal 1, record[:json]['otherfield']
218
+ assert_nil record[:auth]
219
+ end
220
+
221
+ def test_rate_limiting
222
+ d = create_driver CONFIG_RATE_LIMIT
223
+ record = { :k => 1 }
224
+
225
+ last_emit = _current_msec
226
+ d.emit(record)
227
+ d.run
228
+
229
+ assert_equal 1, @posts.size
230
+
231
+ d.emit({})
232
+ d.run
233
+ assert last_emit + RATE_LIMIT_MSEC > _current_msec, "Still under rate limiting interval"
234
+ assert_equal 1, @posts.size
235
+
236
+ sleep (last_emit + RATE_LIMIT_MSEC - _current_msec) * 0.001
237
+
238
+ assert last_emit + RATE_LIMIT_MSEC < _current_msec, "No longer under rate limiting interval"
239
+ d.emit(record)
240
+ d.run
241
+ assert_equal 2, @posts.size
242
+ end
243
+
244
+ def _current_msec
245
+ Time.now.to_f * 1000
246
+ end
247
+
248
+ def test_auth
249
+ @auth = true # enable authentication of dummy server
250
+
251
+ d = create_driver(CONFIG, 'test.metrics')
252
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
253
+ d.run # failed in background, and output warn log
254
+
255
+ assert_equal 0, @posts.size
256
+ assert_equal 1, @prohibited
257
+
258
+ d = create_driver(CONFIG + %[
259
+ authentication basic
260
+ username alice
261
+ password wrong_password
262
+ ], 'test.metrics')
263
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
264
+ d.run # failed in background, and output warn log
265
+
266
+ assert_equal 0, @posts.size
267
+ assert_equal 2, @prohibited
268
+
269
+ d = create_driver(CONFIG + %[
270
+ authentication basic
271
+ username alice
272
+ password secret!
273
+ ], 'test.metrics')
274
+ d.emit({ 'field1' => 50, 'field2' => 20, 'field3' => 10, 'otherfield' => 1 })
275
+ d.run # failed in background, and output warn log
276
+
277
+ assert_equal 1, @posts.size
278
+ assert_equal 2, @prohibited
279
+ end
280
+
281
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fluent-plugin-out-http
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Marica Odagaki
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-02-14 00:00:00.000000000 +09:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: bundler
17
+ requirement: &70205234747520 !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: '0'
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: *70205234747520
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: &70205234747080 !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ! '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: *70205234747080
37
+ - !ruby/object:Gem::Dependency
38
+ name: fluentd
39
+ requirement: &70205234746660 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ! '>='
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ type: :development
46
+ prerelease: false
47
+ version_requirements: *70205234746660
48
+ - !ruby/object:Gem::Dependency
49
+ name: fluentd
50
+ requirement: &70205234746240 !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ! '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: *70205234746240
59
+ description: A generic Fluentd output plugin to send logs to an HTTP endpoint
60
+ email:
61
+ - ento.entotto@gmail.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - .gitignore
67
+ - Gemfile
68
+ - LICENSE.txt
69
+ - README.md
70
+ - Rakefile
71
+ - fluent-plugin-out-http.gemspec
72
+ - lib/fluent/plugin/out_http.rb
73
+ - lib/fluent/test/http_output_test.rb
74
+ - test/plugin/test_out_http.rb
75
+ has_rdoc: true
76
+ homepage: https://github.com/ento/fluent-plugin-out-http
77
+ licenses: []
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ! '>='
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubyforge_project:
96
+ rubygems_version: 1.6.2
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: A generic Fluentd output plugin to send logs to an HTTP endpoint
100
+ test_files:
101
+ - test/plugin/test_out_http.rb