mouth 0.8.0

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.
Files changed (41) hide show
  1. data/.gitignore +7 -0
  2. data/Capfile +26 -0
  3. data/Gemfile +3 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +138 -0
  6. data/Rakefile +19 -0
  7. data/TODO +32 -0
  8. data/bin/mouth +77 -0
  9. data/bin/mouth-console +18 -0
  10. data/bin/mouth-endoscope +18 -0
  11. data/lib/mouth.rb +61 -0
  12. data/lib/mouth/dashboard.rb +25 -0
  13. data/lib/mouth/endoscope.rb +120 -0
  14. data/lib/mouth/endoscope/public/222222_256x240_icons_icons.png +0 -0
  15. data/lib/mouth/endoscope/public/application.css +464 -0
  16. data/lib/mouth/endoscope/public/application.js +938 -0
  17. data/lib/mouth/endoscope/public/backbone.js +1158 -0
  18. data/lib/mouth/endoscope/public/d3.js +4707 -0
  19. data/lib/mouth/endoscope/public/d3.time.js +687 -0
  20. data/lib/mouth/endoscope/public/jquery-ui-1.8.16.custom.min.js +177 -0
  21. data/lib/mouth/endoscope/public/jquery.js +4 -0
  22. data/lib/mouth/endoscope/public/json2.js +480 -0
  23. data/lib/mouth/endoscope/public/keymaster.js +163 -0
  24. data/lib/mouth/endoscope/public/linen.js +46 -0
  25. data/lib/mouth/endoscope/public/seven.css +68 -0
  26. data/lib/mouth/endoscope/public/seven.js +291 -0
  27. data/lib/mouth/endoscope/public/underscore.js +931 -0
  28. data/lib/mouth/endoscope/views/dashboard.erb +67 -0
  29. data/lib/mouth/graph.rb +58 -0
  30. data/lib/mouth/instrument.rb +56 -0
  31. data/lib/mouth/record.rb +72 -0
  32. data/lib/mouth/runner.rb +89 -0
  33. data/lib/mouth/sequence.rb +284 -0
  34. data/lib/mouth/source.rb +76 -0
  35. data/lib/mouth/sucker.rb +235 -0
  36. data/lib/mouth/version.rb +3 -0
  37. data/mouth.gemspec +28 -0
  38. data/test/sequence_test.rb +163 -0
  39. data/test/sucker_test.rb +55 -0
  40. data/test/test_helper.rb +5 -0
  41. metadata +167 -0
@@ -0,0 +1,76 @@
1
+ module Mouth
2
+ class Source
3
+
4
+ attr_accessor :all_attributes
5
+
6
+ def initialize(tuple)
7
+ self.all_attributes = tuple
8
+ end
9
+
10
+ # Returns an array of tuples:
11
+ # [{source: "auth.inline_logged_in", kind: "counter|timer"}, ...]
12
+ def self.all
13
+ col_names = Mouth.mongo.collections.collect(&:name) - %w(dashboards graphs system.indexes)
14
+ col_names.select! {|c| c =~ /^mouth_.+/ }
15
+
16
+ tuples = []
17
+ col_names.each do |col_name|
18
+ namespace = col_name.match(/mouth_(.+)/)[1]
19
+ counters, timers = all_for_collection(Mouth.collection(col_name))
20
+ counters.each {|s| tuples << {:source => "#{namespace}.#{s}", :kind => "counter"} }
21
+ timers.each {|s| tuples << {:source => "#{namespace}.#{s}", :kind => "timer"} }
22
+ end
23
+
24
+ tuples.sort_by {|t| "#{t[:kind]}#{t[:source]}" }.collect {|t| new(t) }
25
+ end
26
+
27
+ def self.all_for_collection(col, window = Time.now.to_i / 60 - 120)
28
+ map_function = <<-JS
29
+ function() {
30
+ var k, vh;
31
+ for (k in this.c) {
32
+ vh = {};
33
+ vh[k] = true;
34
+ emit("counters", {ks: vh});
35
+ }
36
+ for (k in this.m) {
37
+ vh = {};
38
+ vh[k] = true;
39
+ emit("timers", {ks: vh});
40
+ }
41
+ }
42
+ JS
43
+
44
+ reduce_function = <<-JS
45
+ function(key, values) {
46
+ var ret = {ks: {}}, k;
47
+
48
+ values.forEach(function(value) {
49
+ for (k in value.ks) {
50
+ ret.ks[k] = true
51
+ }
52
+ });
53
+ return ret;
54
+ }
55
+ JS
56
+
57
+ result = col.map_reduce(map_function, reduce_function, :out => {:inline => true}, :raw => true, :query => {"t" => {"$gte" => window}})
58
+
59
+ counters = []
60
+ timers = []
61
+ if result && result["results"].is_a?(Array)
62
+ result["results"].each do |r|
63
+ k, v = r["_id"], r["value"]
64
+ if k == "timers"
65
+ timers << v["ks"].keys
66
+ elsif k == "counters"
67
+ counters << v["ks"].keys
68
+ end
69
+ end
70
+ end
71
+
72
+ [counters.flatten.compact.uniq, timers.flatten.compact.uniq]
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,235 @@
1
+ require 'em-mongo'
2
+ require 'eventmachine'
3
+
4
+ module Mouth
5
+
6
+ class SuckerConnection < EM::Connection
7
+ attr_accessor :sucker
8
+
9
+ def receive_data(data)
10
+ Mouth.logger.debug "UDP packet: '#{data}'"
11
+
12
+ sucker.store!(data)
13
+ end
14
+ end
15
+
16
+ class Sucker
17
+
18
+ # Host/Port to suck UDP packets on
19
+ attr_accessor :host
20
+ attr_accessor :port
21
+
22
+ # Actual EM::Mongo connection
23
+ attr_accessor :mongo_db
24
+
25
+ # Info to connect to mongo
26
+ attr_accessor :mongo_db_name
27
+ attr_accessor :mongo_hosts
28
+
29
+ # Accumulators of our data
30
+ attr_accessor :counters
31
+ attr_accessor :timers
32
+
33
+ # Stats
34
+ attr_accessor :udp_packets_received
35
+ attr_accessor :mongo_flushes
36
+
37
+ def initialize(options = {})
38
+ self.host = options[:host] || "localhost"
39
+ self.port = options[:port] || 8889
40
+ self.mongo_db_name = options[:mongo_db_name] || "mouth"
41
+ self.mongo_hosts = options[:mongo_hosts] || ["localhost"]
42
+
43
+ self.udp_packets_received = 0
44
+ self.mongo_flushes = 0
45
+
46
+ self.counters = {}
47
+ self.timers = {}
48
+ end
49
+
50
+ def suck!
51
+ EM.run do
52
+ # Connect to mongo now
53
+ self.mongo_db
54
+
55
+ EM.open_datagram_socket host, port, SuckerConnection do |conn|
56
+ conn.sucker = self
57
+ end
58
+
59
+ EM.add_periodic_timer(10) do
60
+ Mouth.logger.info "Counters: #{self.counters.inspect}"
61
+ Mouth.logger.info "Timers: #{self.timers.inspect}"
62
+ self.flush!
63
+ self.set_procline!
64
+ end
65
+
66
+ EM.next_tick do
67
+ Mouth.logger.info "Mouth reactor started..."
68
+ self.set_procline!
69
+ end
70
+ end
71
+ end
72
+
73
+ # counter: gorets:1|c
74
+ # counter w/ sampling: gorets:1|c|@0.1
75
+ # timer: glork:320|ms
76
+ # (future) gauge: gaugor:333|g
77
+ def store!(data)
78
+ key_value, command_sampling = data.split("|", 2)
79
+ key, value = key_value.to_s.split(":")
80
+ command, sampling = command_sampling.split("|")
81
+
82
+ key = Mouth.parse_key(key).join(".")
83
+ value = value.to_f
84
+
85
+ ts = minute_timestamps
86
+
87
+ if command == "ms"
88
+ self.timers[ts] ||= {}
89
+ self.timers[ts][key] ||= []
90
+ self.timers[ts][key] << value
91
+ elsif command == "c"
92
+ factor = 1.0
93
+ if sampling
94
+ factor = sampling.sub("@", "").to_f
95
+ factor = (factor == 0.0 || factor > 1.0) ? 1.0 : 1.0 / factor
96
+ end
97
+ self.counters[ts] ||= {}
98
+ self.counters[ts][key] ||= 0.0
99
+ self.counters[ts][key] += value * factor
100
+ end
101
+
102
+ self.udp_packets_received += 1
103
+ end
104
+
105
+ def flush!
106
+ ts = minute_timestamps
107
+ limit_ts = ts - 1
108
+ mongo_docs = {}
109
+
110
+ # We're going to construct mongo_docs which look like this:
111
+ # "mycollections:234234": { # NOTE: this timpstamp will be popped into .t = 234234
112
+ # c: {
113
+ # happenings: 37,
114
+ # affairs: 3
115
+ # },
116
+ # m: {
117
+ # occasions: {...}
118
+ # }
119
+ # }
120
+
121
+ self.counters.each do |cur_ts, counters_to_save|
122
+ if cur_ts <= limit_ts
123
+ counters_to_save.each do |counter_key, value|
124
+ ns, sub_key = Mouth.parse_key(counter_key)
125
+ mongo_key = "#{ns}:#{ts}"
126
+ mongo_docs[mongo_key] ||= {}
127
+
128
+ cur_mongo_doc = mongo_docs[mongo_key]
129
+ cur_mongo_doc["c"] ||= {}
130
+ cur_mongo_doc["c"][sub_key] = value
131
+ end
132
+
133
+ self.counters.delete(cur_ts)
134
+ end
135
+ end
136
+
137
+ self.timers.each do |cur_ts, timers_to_save|
138
+ if cur_ts <= limit_ts
139
+ timers_to_save.each do |timer_key, values|
140
+ ns, sub_key = Mouth.parse_key(timer_key)
141
+ mongo_key = "#{ns}:#{ts}"
142
+ mongo_docs[mongo_key] ||= {}
143
+
144
+ cur_mongo_doc = mongo_docs[mongo_key]
145
+ cur_mongo_doc["m"] ||= {}
146
+ cur_mongo_doc["m"][sub_key] = analyze_timer(values)
147
+ end
148
+
149
+ self.timers.delete(cur_ts)
150
+ end
151
+ end
152
+
153
+ save_documents!(mongo_docs)
154
+ end
155
+
156
+ def save_documents!(mongo_docs)
157
+ Mouth.logger.info "Saving Docs: #{mongo_docs.inspect}"
158
+
159
+ mongo_docs.each do |key, doc|
160
+ ns, ts = key.split(":")
161
+ collection_name = "mouth_#{ns}"
162
+ doc["t"] = ts.to_i
163
+
164
+ self.mongo_db.collection(collection_name).insert(doc)
165
+ end
166
+
167
+ self.mongo_flushes += 1 if mongo_docs.any?
168
+ end
169
+
170
+ def mongo_db
171
+ @mongo_db ||= begin
172
+ if self.mongo_hosts.length == 1
173
+ EM::Mongo::Connection.new(self.mongo_hosts.first).db(self.mongo_db_name)
174
+ else
175
+ raise "TODO: ability to connect to a replica set."
176
+ end
177
+ end
178
+ end
179
+
180
+ def set_procline!
181
+ $0 = "mouth [started] [UDP Recv: #{self.udp_packets_received}] [Mongo saves: #{self.mongo_flushes}]"
182
+ end
183
+
184
+ private
185
+
186
+ def minute_timestamps
187
+ Time.now.to_i / 60
188
+ end
189
+
190
+ def analyze_timer(values)
191
+ values.sort!
192
+
193
+ count = values.length
194
+ min = values[0]
195
+ max = values[-1]
196
+ mean = nil
197
+ sum = 0.0
198
+ median = median_for(values)
199
+ stddev = 0.0
200
+
201
+ values.each {|v| sum += v }
202
+ mean = sum / count
203
+
204
+ values.each do |v|
205
+ devi = v - mean
206
+ stddev += (devi * devi)
207
+ end
208
+
209
+ stddev = Math.sqrt(stddev / count)
210
+
211
+ {
212
+ "count" => count,
213
+ "min" => min,
214
+ "max" => max,
215
+ "mean" => mean,
216
+ "sum" => sum,
217
+ "median" => median,
218
+ "stddev" => stddev,
219
+ }
220
+ end
221
+
222
+ def median_for(values)
223
+ count = values.length
224
+ middle = count / 2
225
+ if count == 0
226
+ return 0
227
+ elsif count % 2 == 0
228
+ return (values[middle] + values[middle - 1]).to_f / 2
229
+ else
230
+ return values[middle]
231
+ end
232
+ end
233
+
234
+ end # class Sucker
235
+ end # module
@@ -0,0 +1,3 @@
1
+ module Mouth
2
+ VERSION = "0.8.0"
3
+ end
data/mouth.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ require './lib/mouth/version'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'mouth'
5
+ s.version = Mouth::VERSION
6
+ s.author = 'Jonathan Novak'
7
+ s.email = 'jnovak@gmail.com'
8
+ s.homepage = 'http://github.com/cypriss/mouth'
9
+ s.summary = 'Collect and view stats in real time.'
10
+ s.description = 'Ruby Daemon collects statistics via UDP packets, stores them in Mongo, and then views them via a user friendly Sintra app.'
11
+
12
+ s.files = `git ls-files`.split("\n")
13
+ s.test_files = `git ls-files test`.split("\n")
14
+ s.require_path = 'lib'
15
+ s.bindir = 'bin'
16
+ s.executables << 'mouth' << 'mouth-endoscope'
17
+
18
+ s.add_runtime_dependency 'em-mongo', '~> 0.4.2' # For the EM collector
19
+ s.add_runtime_dependency 'mongo', '~> 1.6' # For the sinatra app
20
+ s.add_runtime_dependency 'bson_ext', '~> 1.6'
21
+ s.add_runtime_dependency 'eventmachine', '~> 0.12.10'
22
+ s.add_runtime_dependency 'vegas', '~> 0.1.8'
23
+ s.add_runtime_dependency 'sinatra', '~> 1.3.1'
24
+ s.add_runtime_dependency 'yajl-ruby', '~> 1.1.0'
25
+
26
+ s.required_ruby_version = '>= 1.9.2'
27
+ s.required_rubygems_version = '>= 1.3.4'
28
+ end
@@ -0,0 +1,163 @@
1
+ $LOAD_PATH.unshift 'test'
2
+ require 'test_helper'
3
+
4
+ require 'mouth/sequence'
5
+
6
+ class SequenceTest < Test::Unit::TestCase
7
+ def setup
8
+ @start_time = Time.new(2012, 4, 1, 9, 30, 0, "-07:00")
9
+ @end_time = Time.new(2012, 4, 1, 11, 30, 0, "-07:00")
10
+ @namespace, @metric = Mouth.parse_key("test.test")
11
+
12
+ @start_timestamp = @start_time.to_i / 60
13
+ @end_timestamp = @end_time.to_i / 60
14
+
15
+ counter = 1
16
+ timer = {"count" => 5, "min" => 1, "max" => 10, "mean" => 5, "sum" => 20, "median" => 4, "stddev" => 2.3}
17
+ (@start_timestamp..@end_timestamp).each do |t|
18
+ # Insert the document into mongo
19
+ Mouth.collection(Mouth.mongo_collection_name(@namespace)).update({"t" => t}, {"$set" => {"c.#{@metric}" => counter, "m.#{@metric}" => timer}}, :upsert => true)
20
+ end
21
+ end
22
+
23
+ def test_minute_counter_sequences
24
+ seq = Mouth::Sequence.new("#{@namespace}.#{@metric}", :start_time => @start_time, :end_time => @end_time)
25
+
26
+ sequences = seq.sequences
27
+
28
+ assert_equal ["test"], sequences.keys
29
+ assert_equal 1, sequences.values.length
30
+
31
+ values = sequences.values.first
32
+
33
+ assert_equal 121, values.length
34
+
35
+ assert values.all? {|v| v == 1} # The 'test' sequence is all ones
36
+ end
37
+
38
+ def test_15_minute_counter_sequences
39
+ seq = Mouth::Sequence.new("#{@namespace}.#{@metric}", :granularity_in_minutes => 15, :start_time => @start_time, :end_time => @end_time)
40
+
41
+ sequences = seq.sequences
42
+
43
+ assert_equal ["test"], sequences.keys
44
+ assert_equal 1, sequences.values.length
45
+
46
+ values = sequences.values.first
47
+
48
+ assert_equal 9, values.length
49
+ assert_equal [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 1.0], values
50
+ end
51
+
52
+ def test_15_minute_timer_sequences_basic
53
+ seq = Mouth::Sequence.new("#{@namespace}.#{@metric}", :kind => :timer, :granularity_in_minutes => 15, :start_time => @start_time, :end_time => @end_time)
54
+
55
+ sequences = seq.sequences
56
+ assert_equal ["test"], sequences.keys
57
+ assert_equal 1, sequences.values.length
58
+
59
+ values = sequences.values.first
60
+
61
+ assert_equal 9, values.length
62
+
63
+ timer = {"count"=>75,
64
+ "min"=>1,
65
+ "max"=>10,
66
+ "mean"=>4,
67
+ "sum"=>300,
68
+ "median"=>4,
69
+ "stddev"=>3.780211634287159}
70
+ assert_equal timer, values.first
71
+ end
72
+
73
+ def test_15_minute_timer_sequences_one_grouping
74
+ @start_time = Time.new(2012, 4, 1, 9, 30, 0, "-07:00")
75
+ @end_time = Time.new(2012, 4, 1, 9, 44, 0, "-07:00")
76
+
77
+ @start_timestamp = @start_time.to_i / 60
78
+ @end_timestamp = @end_time.to_i / 60
79
+
80
+ col = Mouth.collection(Mouth.mongo_collection_name("test"))
81
+ col.remove
82
+
83
+ # NOTE: this data is totally fake, maybe we can get some real data
84
+ timers = [
85
+ {"count"=>3, "min"=>15, "max"=>30, "mean"=>100, "sum"=>300, "median"=>4, "stddev"=>1},
86
+ {"count"=>6, "min"=>14, "max"=>32, "mean"=>90, "sum"=>540, "median"=>5, "stddev"=>2},
87
+ {"count"=>9, "min"=>13, "max"=>30, "mean"=>85, "sum"=>765, "median"=>6, "stddev"=>3},
88
+ {"count"=>12, "min"=>12, "max"=>30, "mean"=>80, "sum"=>960, "median"=>7, "stddev"=>4},
89
+ {"count"=>15, "min"=>11, "max"=>30, "mean"=>70, "sum"=>1050, "median"=>8, "stddev"=>5},
90
+ {"count"=>18, "min"=>10, "max"=>30, "mean"=>65, "sum"=>1170, "median"=>9, "stddev"=>6},
91
+ {"count"=>21, "min"=>9, "max"=>30, "mean"=>60, "sum"=>1260, "median"=>10, "stddev"=>7},
92
+ {"count"=>24, "min"=>8, "max"=>30, "mean"=>55, "sum"=>1320, "median"=>11, "stddev"=>8},
93
+ {"count"=>27, "min"=>7, "max"=>30, "mean"=>50, "sum"=>1350, "median"=>12, "stddev"=>9},
94
+ {"count"=>30, "min"=>6, "max"=>30, "mean"=>40, "sum"=>1200, "median"=>13, "stddev"=>10},
95
+ {"count"=>33, "min"=>16, "max"=>30, "mean"=>30, "sum"=>990, "median"=>14, "stddev"=>11},
96
+ {"count"=>36, "min"=>17, "max"=>30, "mean"=>20, "sum"=>720, "median"=>15, "stddev"=>12},
97
+ {"count"=>39, "min"=>18, "max"=>30, "mean"=>10, "sum"=>390, "median"=>16, "stddev"=>13},
98
+ {"count"=>42, "min"=>19, "max"=>30, "mean"=>5, "sum"=>210, "median"=>17, "stddev"=>14},
99
+ {"count"=>45, "min"=>20, "max"=>30, "mean"=>1, "sum"=>45, "median"=>18, "stddev"=>15}
100
+ ]
101
+ i = 0
102
+ (@start_timestamp..@end_timestamp).each do |t|
103
+ col.update({"t" => t}, {"$set" => {"m.test" => timers[i]}}, :upsert => true)
104
+ i += 1
105
+ end
106
+
107
+ seq = Mouth::Sequence.new("test.test", :kind => :timer, :granularity_in_minutes => 15, :start_time => @start_time, :end_time => @end_time)
108
+ values = seq.sequence
109
+
110
+ assert_equal 1, values.length
111
+
112
+ value = values.first
113
+
114
+ expected_count = timers.collect {|t| t["count"] }.inject(0) {|s,c| s + c }
115
+ expected_sum = timers.collect {|t| t["sum"] }.inject(0) {|s,c| s + c }
116
+
117
+ assert_equal expected_count, value["count"]
118
+ assert_equal expected_sum, value["sum"]
119
+ assert_equal timers.collect {|t| t["min"] }.min, value["min"]
120
+ assert_equal timers.collect {|t| t["max"] }.max, value["max"]
121
+ assert_equal expected_sum / expected_count, value["mean"].to_i
122
+ assert_equal 11, value["median"]
123
+ assert_equal 29, value["stddev"].to_i # NOTE: i don't actually know if this is correct
124
+
125
+ end
126
+
127
+ def test_empty_timer
128
+ @start_time = Time.new(2012, 4, 1, 9, 30, 0, "-07:00")
129
+ @end_time = Time.new(2012, 4, 1, 9, 44, 0, "-07:00")
130
+
131
+ @start_timestamp = @start_time.to_i / 60
132
+ @end_timestamp = @end_time.to_i / 60
133
+
134
+ col = Mouth.collection(Mouth.mongo_collection_name("test"))
135
+ col.remove
136
+ seq = Mouth::Sequence.new("test.test", :kind => :timer, :granularity_in_minutes => 15, :start_time => @start_time, :end_time => @end_time)
137
+ values = seq.sequence
138
+
139
+ assert_equal [{"count"=>0,
140
+ "min"=>nil,
141
+ "max"=>nil,
142
+ "mean"=>nil,
143
+ "sum"=>0,
144
+ "median"=>nil,
145
+ "stddev"=>nil}], values
146
+ end
147
+
148
+ def test_empty_sequence
149
+ @start_time = Time.new(2012, 4, 1, 9, 30, 0, "-07:00")
150
+ @end_time = Time.new(2012, 4, 1, 9, 44, 0, "-07:00")
151
+
152
+ @start_timestamp = @start_time.to_i / 60
153
+ @end_timestamp = @end_time.to_i / 60
154
+
155
+ col = Mouth.collection(Mouth.mongo_collection_name("test"))
156
+ col.remove
157
+ seq = Mouth::Sequence.new("test.test", :kind => :counter, :granularity_in_minutes => 15, :start_time => @start_time, :end_time => @end_time)
158
+ values = seq.sequence
159
+
160
+ assert_equal [0], values
161
+ end
162
+
163
+ end