statsdserver 0.4

Sign up to get free protection for your applications and to get access to all the features.
data/bin/statsd ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+
3
+
4
+ require "rubygems"
5
+ require "bundler/setup"
6
+
7
+ require "parseconfig"
8
+ require "statsdserver"
9
+ require "statsdserver/output/stdout"
10
+ require "statsdserver/output/tcp"
11
+ require "sysexits"
12
+
13
+ include Sysexits
14
+ progname = File.basename($0)
15
+
16
+ if ARGV.length != 1
17
+ $stderr.puts "usage: #{progname} path_to_config"
18
+ exit EX_USAGE
19
+ end
20
+
21
+ config_path = ARGV.shift
22
+ begin
23
+ config_file = ParseConfig.new(config_path)
24
+ rescue
25
+ $stderr.puts "#{progname}: error reading #{config_path}: #{$!}"
26
+ exit EX_DATAERR
27
+ end
28
+
29
+ config = {}
30
+ %w(daemonize inputs flush_interval outputs prefix percentile
31
+ suffix).each do |key|
32
+ config[key.to_sym] = config_file[key] if config_file[key]
33
+ end
34
+
35
+ if config[:inputs].nil? || config[:inputs].empty?
36
+ $stderr.puts "#{progname}: no inputs specified"
37
+ exit EX_DATAERR
38
+ end
39
+
40
+ if config[:outputs].nil? || config[:outputs].empty?
41
+ $stderr.puts "#{progname}: no outputs specified"
42
+ exit EX_DATAERR
43
+ end
44
+
45
+ input_config = {}
46
+ config[:inputs].split(/, */).each do |input|
47
+ input_config[input] = config_file["input:#{input}"] || {}
48
+ end
49
+
50
+ output_config = {}
51
+ config[:outputs].split(/, */).each do |output|
52
+ output_config[output] = config_file["output:#{output}"] || {}
53
+ end
54
+
55
+ EM.run do
56
+ s = StatsdServer.new(config, input_config, output_config)
57
+ s.run
58
+ end
@@ -0,0 +1,216 @@
1
+ require "logger"
2
+ require "statsdserver/input/udp"
3
+ require "statsdserver/input/zeromq"
4
+ require "statsdserver/stats"
5
+
6
+ # Hack because the latest amqp gem uses String#bytesize, and not everyone
7
+ # is running ruby 1.8.7.
8
+ if !String.instance_methods.include?(:bytesize)
9
+ class String
10
+ alias :bytesize :length
11
+ end
12
+ end
13
+
14
+ class StatsdServer
15
+ attr_accessor :logger
16
+ attr_accessor :stats
17
+
18
+ public
19
+ def initialize(opts, input_config, output_config)
20
+ @stats = StatsdServer::Stats.new
21
+ @logger = Logger.new(STDERR)
22
+ @logger.progname = File.basename($0)
23
+
24
+ @opts = {
25
+ :bind => "127.0.0.1",
26
+ :port => 8125,
27
+ :percentile => 90,
28
+ :flush_interval => 30,
29
+ :prefix => "stats"
30
+ }.merge(opts)
31
+ @input_config = input_config
32
+ @output_config = output_config
33
+
34
+ # argument checking
35
+ [:port, :percentile, :flush_interval].each do |key|
36
+ begin
37
+ @opts[key] = Float(@opts[key])
38
+ rescue
39
+ raise "#{key}: #{@opts[key].inspect}: must be a valid number"
40
+ end
41
+ end
42
+ end # def initialize
43
+
44
+ public
45
+ def run
46
+ # initialize outputs
47
+ @outputs = []
48
+ @output_config.each do |output, config|
49
+ klass = StatsdServer::Output.const_get(output.capitalize)
50
+ if klass.nil?
51
+ @logger.fatal("unknown output #{output.inspect}")
52
+ exit EX_DATAERR
53
+ end
54
+ @outputs << klass.new(config)
55
+ end # @output_config.each
56
+
57
+ # start inputs
58
+ @input_config.each do |input, config|
59
+ case input
60
+ when "udp"
61
+ EM.open_datagram_socket(config["bind"], config["port"].to_i,
62
+ Input::Udp) do |s|
63
+ s.logger = @logger
64
+ s.stats = @stats
65
+ end # EM.open_datagram_socket
66
+ when "zeromq"
67
+ s = Input::ZeroMQ.new
68
+ s.logger = @logger
69
+ s.stats = @stats
70
+ $ctx = EM::ZeroMQ::Context.new(1)
71
+ sock = $ctx.socket(ZMQ::PULL, s)
72
+ sock.setsockopt(ZMQ::HWM, 100)
73
+ sock.bind(config["bind"])
74
+ else
75
+ @logger.fatal("unknown input #{input.inspect}")
76
+ exit EX_DATAERR
77
+ end # case input
78
+ end # @inputs.each
79
+
80
+ # start flusher
81
+ Thread.abort_on_exception = true
82
+ @flusher = Thread.new do
83
+ while sleep(@opts[:flush_interval])
84
+ begin
85
+ flush
86
+ rescue => e
87
+ @logger.warn("trouble flushing: #{$!}")
88
+ @logger.debug(e.backtrace.join("\n"))
89
+ end
90
+ end
91
+ end
92
+ end # def run
93
+
94
+ #private
95
+ #def setup_amqp
96
+ # begin
97
+ # require "amqp"
98
+ # rescue LoadError
99
+ # @logger.fatal("missing amqp ruby module. try gem install amqp")
100
+ # exit(1)
101
+ # end
102
+ #
103
+ # user = @output_url.user || ""
104
+ # user, vhost = user.split("@", 2)
105
+ # _, mqtype, mqname = @output_url.path.split("/", 3)
106
+ # amqp_settings = {
107
+ # :host => @output_url.host,
108
+ # :port => @output_url.port || 5672,
109
+ # :user => user,
110
+ # :pass => @output_url.password,
111
+ # :vhost => vhost || "/",
112
+ # }
113
+ #
114
+ #
115
+ # @amqp = AMQP.connect(amqp_settings)
116
+ # @channel = AMQP::Channel.new(@amqp)
117
+ #
118
+ # opts = {
119
+ # :durable => true,
120
+ # :auto_delete => false,
121
+ # }
122
+ #
123
+ # if @output_url.query
124
+ # @output_url.query.split("&").each do |param|
125
+ # k, v = param.split("=", 2)
126
+ # opts[:durable] = false if k == "durable" and v == "false"
127
+ # opts[:auto_delete] = true if k == "autodelete" and v == "true"
128
+ # end
129
+ # end
130
+ #
131
+ # @exchange = case mqtype
132
+ # when "fanout"
133
+ # @channel.fanout(mqname, opts)
134
+ # when "direct"
135
+ # @channel.exchange(mqname, opts)
136
+ # when "topic"
137
+ # @channel.topic(mqname, opts)
138
+ # else
139
+ # raise TypeError, "unknown amqp output type #{mqtype}"
140
+ # end
141
+ # end # def setup_amqp
142
+
143
+ public
144
+ def carbon_update_str
145
+ updates = []
146
+ now = Time.now.to_i
147
+
148
+ timers = {}
149
+ @stats.timers.each do |k, v|
150
+ timers[k] = @stats.timers.delete(k)
151
+ end
152
+
153
+ counters = {}
154
+ @stats.counters.each do |k, v|
155
+ counters[k] = @stats.counters.delete(k)
156
+ end
157
+
158
+ timers.each do |key, values|
159
+ next if values.length == 0
160
+ values.sort!
161
+
162
+ # do some basic summarizing of our timer data
163
+ min = values[0]
164
+ max = values[-1]
165
+ mean = min
166
+ maxAtThreshold = min
167
+ if values.length > 1
168
+ threshold_index = ((100 - @opts[:percentile]) / 100.0) \
169
+ * values.length
170
+ threshold_count = values.length - threshold_index.round
171
+ valid_values = values.slice(0, threshold_count)
172
+ maxAtThreshold = valid_values[-1]
173
+ sum = 0
174
+ valid_values.each { |v| sum += v }
175
+ mean = sum / valid_values.length
176
+ end
177
+
178
+ prefix = @opts[:prefix] ? "#{@opts[:prefix]}." : ""
179
+ suffix = @opts[:suffix] ? ".#{@opts[:suffix]}" : ""
180
+ updates << "#{prefix}timers.#{key}.mean#{suffix} #{mean} #{now}"
181
+ updates << "#{prefix}timers.#{key}.upper#{suffix} #{max} #{now}"
182
+ updates << "#{prefix}timers.#{key}.upper_#{@opts[:percentile]}#{suffix} " \
183
+ "#{maxAtThreshold} #{now}"
184
+ updates << "#{prefix}timers.#{key}.lower#{suffix} #{min} #{now}"
185
+ updates << "#{prefix}timers.#{key}.count#{suffix} #{values.length} #{now}"
186
+ end # timers.each
187
+
188
+ # Keep sending a 0 for counters (even if we don't get updates)
189
+ counters.keys.each do |k|
190
+ @stats.counters[k] ||= 0 # Keep sending a 0 if we don't get updates
191
+ end
192
+
193
+ counters.each do |key, value|
194
+ prefix = @opts[:prefix] ? "#{@opts[:prefix]}." : ""
195
+ suffix = @opts[:suffix] ? ".#{@opts[:suffix]}" : ""
196
+ updates << "#{prefix}#{key}#{suffix} #{value / @opts[:flush_interval]} #{now}"
197
+ end # counters.each
198
+
199
+ return updates.length == 0 ? nil : updates.join("\n") + "\n"
200
+ end # def carbon_update_str
201
+
202
+ public
203
+ def flush
204
+ s = carbon_update_str
205
+ return unless s
206
+
207
+ if @outputs.nil? or @outputs.length == 0
208
+ @logger.warn("no outputs configured, can't flush data")
209
+ return
210
+ end
211
+
212
+ @outputs.each do |output|
213
+ output.send(s)
214
+ end
215
+ end # def flush
216
+ end # class StatsdServer
@@ -0,0 +1,28 @@
1
+ require "eventmachine"
2
+ require "logger"
3
+ require "statsdserver/proto/v1"
4
+
5
+ class StatsdServer
6
+ class Input
7
+ class Udp < EventMachine::Connection
8
+ attr_accessor :logger,
9
+ :stats
10
+
11
+ public
12
+ def initialize
13
+ @logger = Logger.new(STDOUT)
14
+ end
15
+
16
+ public
17
+ def receive_data(packet)
18
+ raise "@stats must be set" unless @stats
19
+
20
+ begin
21
+ StatsdServer::Proto::V1.parse(packet, @stats)
22
+ rescue StatsdServer::Proto::ParseError => e
23
+ @logger.warn(e.message)
24
+ end
25
+ end # def receive_data
26
+ end # class Ucp
27
+ end # class Input
28
+ end # class StatsdServer
@@ -0,0 +1,51 @@
1
+ require "em-zeromq"
2
+ require "logger"
3
+ require "statsdserver/proto/v1"
4
+
5
+ class StatsdServer
6
+ class Input
7
+ class ZeroMQ
8
+ attr_accessor :logger,
9
+ :stats
10
+
11
+ public
12
+ def initialize
13
+ @logger = Logger.new(STDOUT)
14
+ end
15
+
16
+ public
17
+ def on_readable(socket, parts)
18
+ parts.each do |part|
19
+ str = part.copy_out_string
20
+ receive_data(str)
21
+ end
22
+ end
23
+
24
+ public
25
+ def receive_data(packet)
26
+ raise "@stats must be set" unless @stats
27
+
28
+ sep = packet.index(";")
29
+ if sep.nil?
30
+ @logger.warn("received unversioned update: #{packet}")
31
+ return
32
+ end
33
+
34
+ proto_ver = packet[0 .. sep - 1]
35
+ payload = packet[sep + 1 .. -1]
36
+ case proto_ver
37
+ when "1"
38
+ begin
39
+ StatsdServer::Proto::V1.parse(payload, @stats)
40
+ rescue StatsdServer::Proto::ParseError => e
41
+ @logger.warn(e.message)
42
+ end
43
+ else
44
+ @logger.warn("unknown protocol version #{proto_ver} in update #{packet}")
45
+ return
46
+ end
47
+
48
+ end # def receive_data
49
+ end # class Ucp
50
+ end # class Input
51
+ end # class StatsdServer
@@ -0,0 +1,16 @@
1
+ class StatsdServer::Output
2
+ class Stdout
3
+ attr_accessor :logger
4
+
5
+ public
6
+ def initialize(opts = {})
7
+ @logger = Logger.new(STDERR)
8
+ $stdout.sync = true # autoflush
9
+ end
10
+
11
+ public
12
+ def send(str)
13
+ puts str
14
+ end
15
+ end # class Stdout
16
+ end # class StatsdServer::Output
@@ -0,0 +1,33 @@
1
+ require "logger"
2
+ require "socket"
3
+
4
+ class StatsdServer::Output
5
+ class Tcp
6
+ attr_accessor :logger
7
+
8
+ public
9
+ def initialize(opts = {})
10
+ if opts["host"].nil?
11
+ raise ArgumentError, "missing host in [output:tcp] config section"
12
+ end
13
+
14
+ if opts["port"].nil?
15
+ raise ArgumentError, "missing port in [output:tcp] config section"
16
+ end
17
+
18
+ @opts = opts
19
+ @logger = Logger.new(STDOUT)
20
+ end
21
+
22
+ public
23
+ def send(str)
24
+ @socket ||= connect
25
+ @socket.write("#{str}\n")
26
+ end
27
+
28
+ private
29
+ def connect
30
+ TCPSocket.new(@opts["host"], @opts["port"].to_i)
31
+ end
32
+ end # class Tcp
33
+ end # class StatsdServer::Output
@@ -0,0 +1,6 @@
1
+ class StatsdServer
2
+ class Proto
3
+ class ParseError < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,64 @@
1
+ require "statsdserver/proto/parseerror"
2
+
3
+ class StatsdServer
4
+ class Proto
5
+ module V1
6
+ def self.parse(data, stats)
7
+ data.split("\n").each do |update|
8
+ self.parse_update(update, stats)
9
+ end
10
+ end # def parse
11
+
12
+ def self.parse_update(update, stats)
13
+ bits = update.split(":")
14
+ # TODO: optimize into single regexp & compile?
15
+ key = bits.shift.gsub(/\s+/, "_") \
16
+ .gsub(/\//, "-") \
17
+ .gsub(/[^a-zA-Z_\-0-9\.]/, "")
18
+ bits << "1" if bits.length == 0
19
+ bits.each do |bit|
20
+ fields = bit.split("|")
21
+ if fields.length != 2
22
+ raise ParseError, "invalid update: #{bit}"
23
+ end
24
+
25
+ if fields[1] == "ms" # timer update
26
+ if fields[0].index(",")
27
+ fields[0].split(",").each do |value_str|
28
+ value = Integer(value_str) rescue nil
29
+ stats.timers[key] << value if value
30
+ end
31
+ else
32
+ value = Integer(fields[0]) rescue nil
33
+ if value.nil?
34
+ raise ParseError, "invalid timer value: #{fields[0]}"
35
+ end
36
+ stats.timers[key] << fields[0].to_i
37
+ end
38
+ elsif fields[1] == "c" # counter update
39
+ count_str, sample_rate_str = fields[0].split("@", 2)
40
+
41
+ if sample_rate_str
42
+ sample_rate = Float(sample_rate_str) rescue nil
43
+ if sample_rate.nil?
44
+ raise ParseError, "invalid sample_rate: #{sample_rate_str}"
45
+ end
46
+ else
47
+ sample_rate = 1
48
+ end
49
+
50
+ count = Integer(count_str) rescue nil
51
+ if count.nil?
52
+ raise ParseError, "invalid count: #{count_str}"
53
+ end
54
+
55
+ stats.counters[key] += count.to_i * (1 / sample_rate.to_f)
56
+ else
57
+ raise ParseError,
58
+ "invalid update: #{update}: unknown type #{fields[1]}"
59
+ end
60
+ end
61
+ end # def parse_update
62
+ end # module V1
63
+ end # class Proto
64
+ end # class StatsdServer
@@ -0,0 +1,16 @@
1
+ require "logger"
2
+
3
+ class StatsdServer
4
+ class Stats
5
+ attr_accessor :counters,
6
+ :timers,
7
+ :logger
8
+
9
+ public
10
+ def initialize
11
+ @timers = Hash.new { |h, k| h[k] = Array.new }
12
+ @counters = Hash.new { |h, k| h[k] = 0 }
13
+ @logger = Logger.new(STDERR)
14
+ end
15
+ end # class Stats
16
+ end # class StatsdServer
metadata ADDED
@@ -0,0 +1,168 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: statsdserver
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.4'
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Pete Fritchman
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-08-08 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: rspec
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: amqp
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: eventmachine
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: parseconfig
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: sysexits
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: em-zeromq
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ description: collect and aggregate stats, flush to graphite
127
+ email: petef@databits.net
128
+ executables:
129
+ - statsd
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - lib/statsdserver/output/tcp.rb
134
+ - lib/statsdserver/output/stdout.rb
135
+ - lib/statsdserver/input/zeromq.rb
136
+ - lib/statsdserver/input/udp.rb
137
+ - lib/statsdserver/proto/v1.rb
138
+ - lib/statsdserver/proto/parseerror.rb
139
+ - lib/statsdserver/stats.rb
140
+ - lib/statsdserver.rb
141
+ - bin/statsd
142
+ homepage: https://github.com/fetep/ruby-statsd
143
+ licenses:
144
+ - Apache License 2.0
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ - lib
150
+ required_ruby_version: !ruby/object:Gem::Requirement
151
+ none: false
152
+ requirements:
153
+ - - ! '>='
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ none: false
158
+ requirements:
159
+ - - ! '>='
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubyforge_project:
164
+ rubygems_version: 1.8.24
165
+ signing_key:
166
+ specification_version: 3
167
+ summary: statsd (server) -- stat collector/aggregator
168
+ test_files: []