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,67 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Mouth Endoscope</title>
5
+
6
+ <link rel="stylesheet" href="/application.css" type="text/css" media="all">
7
+ <link rel="stylesheet" href="/seven.css" type="text/css" media="all">
8
+ <script src="/jquery.js" type="text/javascript"></script>
9
+ <script src="/jquery-ui-1.8.16.custom.min.js" type="text/javascript"></script>
10
+ <script src="/json2.js" type="text/javascript"></script>
11
+ <script src="/underscore.js" type="text/javascript"></script>
12
+ <script src="/backbone.js" type="text/javascript"></script>
13
+ <script src="/d3.js" type="text/javascript"></script>
14
+ <script src="/d3.time.js" type="text/javascript"></script>
15
+ <script src="/seven.js" type="text/javascript"></script>
16
+ <script src="/keymaster.js" type="text/javascript"></script>
17
+ <script src="/application.js" type="text/javascript"></script>
18
+ <script src="/linen.js" type="text/javascript"></script>
19
+ </head>
20
+ <body>
21
+
22
+ <section id="mouth-canvas">
23
+ <header id="mouth-header">
24
+ <h1>Mouth</h1>
25
+
26
+ <nav id="dashboards" data-dashboard>
27
+ <ul></ul>
28
+
29
+ <a href="#" class="add-dashboard">+</a>
30
+ </nav>
31
+ </header>
32
+
33
+ <section id="info-panel">
34
+ <div id="date-range-picker" class="current">
35
+ <h2>Time Span</h2>
36
+ <ul>
37
+ <li><label><input type="radio" name="time_span" value="2_hours" checked> 2 Hours</label></li>
38
+ <li><label><input type="radio" name="time_span" value="24_hours"> 24 Hours</label></li>
39
+ <li><label><input type="radio" name="time_span" value="7_days"> 7 Days</label></li>
40
+ </ul>
41
+
42
+ <h2>Starting At</h2>
43
+ <div class="starting-at">
44
+ <div class="if-current">
45
+ <a href="#" class="custom-date">2 hours ago</a>
46
+ </div>
47
+ <div class="unless-current">
48
+ <input type="text" name="starting_at" class="date-starting-at">
49
+ <div class="example">(Format: 2012-04-26 11:22)</div>
50
+ <div><a href="#" class="date-reset">Reset</a></div>
51
+ </div>
52
+ </div>
53
+
54
+ <h2>Ending At</h2>
55
+ <div class="ending-at">
56
+ <span>Now</span>
57
+ </div>
58
+ </div>
59
+ </section>
60
+ <section id="current-dashboard">
61
+ <ul id="grid"></ul>
62
+ <a href="#" class="add-graph">Add Graph</a>
63
+ </section>
64
+ </section>
65
+
66
+ </body>
67
+ </html>
@@ -0,0 +1,58 @@
1
+ module Mouth
2
+
3
+ # {
4
+ # :dashboard_id => BSON::ObjectId,
5
+ # :position => {
6
+ # :top => 0,
7
+ # :left => 0,
8
+ # :height => 7,
9
+ # :width => 20
10
+ # },
11
+ # :kind => 'counters' || 'timer',
12
+ # :sources => ["auth.authentications"]
13
+ # }
14
+ class Graph < Record
15
+
16
+ #
17
+ def save
18
+ bson_object_id_ize(:dashboard_id) do
19
+ super
20
+ end
21
+ end
22
+
23
+ # Options:
24
+ # - :start_time (Time object)
25
+ # - :end_time (Time object)
26
+ # - :granularity_in_minutes
27
+ def data(opts = {})
28
+ sources = self.attributes[:sources] || []
29
+ seq_opts = {:kind => self.attributes[:kind].to_sym}.merge(opts)
30
+ sequence = Sequence.new(sources, seq_opts)
31
+ seqs = sequence.sequences
32
+ seqs.map do |seq|
33
+ {
34
+ :data => seq[1],
35
+ :start_time => sequence.start_time_epoch,
36
+ :source => seq[0]
37
+ }
38
+ end
39
+ end
40
+
41
+ #
42
+ def bson_object_id_ize(*args)
43
+ orig_attrs = self.attributes.dup
44
+ args.each do |a|
45
+ self.attributes[a] = BSON::ObjectId(self.attributes[a])
46
+ end
47
+ res = yield
48
+ orig_attrs[:id] = self.attributes[:id]
49
+ self.attributes = orig_attrs
50
+
51
+ res
52
+ end
53
+
54
+ def self.for_dashboard(dashboard_id)
55
+ collection.find({:dashboard_id => BSON::ObjectId(dashboard_id.to_s)}).to_a.collect {|g| new(g) }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ require 'socket'
2
+ require 'benchmark'
3
+
4
+ module Mouth
5
+ unless self.respond_to?(:measure)
6
+ class << self
7
+ attr_accessor :host, :port, :disabled
8
+
9
+ # Mouth.server = 'localhost:1234'
10
+ def server=(conn)
11
+ self.host, port = conn.split(':')
12
+ self.port = port.to_i
13
+ end
14
+
15
+ def host
16
+ @host || "localhost"
17
+ end
18
+
19
+ def port
20
+ @port || 8889
21
+ end
22
+
23
+ def measure(key, milli = nil)
24
+ result = nil
25
+ ms = milli || (Benchmark.realtime { result = yield } * 1000).to_i
26
+
27
+ write(key, ms, :ms)
28
+
29
+ result
30
+ end
31
+
32
+ def increment(key, delta = 1, sample_rate = nil)
33
+ write(key, delta, :c, sample_rate)
34
+ end
35
+
36
+ protected
37
+
38
+ def socket
39
+ @socket ||= UDPSocket.new
40
+ end
41
+
42
+ def write(k, v, op, sample_rate = nil)
43
+ return if self.disabled
44
+ if sample_rate
45
+ sample_rate = 1 if sample_rate > 1
46
+ return if rand > sample_rate
47
+ end
48
+
49
+ command = "#{k}:#{v}|#{op}"
50
+ command << "|@#{sample_rate}" if sample_rate
51
+
52
+ socket.send(command, 0, self.host, self.port)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,72 @@
1
+ module Mouth
2
+
3
+ class Record
4
+
5
+ # Keys are symbols
6
+ # id is :id, not _id
7
+ attr_accessor :attributes
8
+
9
+ def initialize(attrs = {})
10
+ self.attributes = normalize_attributes(attrs)
11
+ end
12
+
13
+ def all_attributes
14
+ self.attributes
15
+ end
16
+
17
+ def save
18
+ if self.attributes[:id]
19
+ attrs = self.attributes.dup
20
+ the_id = attrs.delete(:id).to_s
21
+ doc = self.class.collection.update({"_id" => BSON::ObjectId(the_id)}, attrs)
22
+ else
23
+ self.class.collection.insert(self.attributes)
24
+ self.attributes[:id] = self.attributes.delete(:_id).to_s
25
+ end
26
+ true
27
+ end
28
+
29
+ def update(new_attrs)
30
+ self.attributes = normalize_attributes(new_attrs)
31
+ self.save
32
+ end
33
+
34
+ def destroy
35
+ self.class.collection.remove({"_id" => BSON::ObjectId(self.attributes[:id])})
36
+ end
37
+
38
+ def normalize_attributes(attrs)
39
+ normalize = lambda do |h|
40
+ hd = {}
41
+ h.each_pair do |key, val|
42
+ val = normalize.call(val) if val.is_a?(Hash)
43
+ val = val.to_s if val.is_a?(BSON::ObjectId)
44
+ # TODO: arrays :(
45
+ hd[key.to_s == "_id" ? :id : key.to_sym] = val
46
+ end
47
+ hd
48
+ end
49
+ normalize.call attrs
50
+ end
51
+
52
+ def self.collection
53
+ demodularized = self.to_s.match(/(.+::)?(.+)$/)[2] || "record"
54
+ tableized = demodularized.downcase + "s" # (: lol :)
55
+ @collection ||= Mouth.mongo.collection(tableized)
56
+ end
57
+
58
+ def self.find(id)
59
+ collection.find({"_id" => BSON::ObjectId(id)}).to_a.collect {|d| new(d) }.first
60
+ end
61
+
62
+ def self.create(attributes)
63
+ r = new(attributes)
64
+ r.save
65
+ r
66
+ end
67
+
68
+ def self.all
69
+ collection.find.to_a.collect {|d| new(d) }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ require 'fileutils'
2
+ require 'logger'
3
+ require 'mouth/sucker'
4
+
5
+ module Mouth
6
+ class Runner
7
+
8
+ attr_accessor :log_file
9
+ attr_accessor :pid_file
10
+ attr_accessor :logger
11
+ attr_accessor :verbosity # 0: Only errors/warnings 1: informational 2: debug/all incomding UDP packets
12
+ attr_accessor :options
13
+
14
+ def initialize(opts={})
15
+ puts "Starting Mouth..."
16
+
17
+ self.log_file = opts[:log_file]
18
+ self.pid_file = opts[:pid_file]
19
+ self.verbosity = opts[:verbosity]
20
+ self.options = opts
21
+ end
22
+
23
+ def run!
24
+ kill! if self.options[:kill]
25
+
26
+ daemonize!
27
+ save_pid!
28
+ setup_logging!
29
+
30
+ # Start the reactor!
31
+ sucker = Mouth::Sucker.new(self.options)
32
+ sucker.suck!
33
+ end
34
+
35
+ def kill!
36
+ if @pid_file
37
+ pid = File.read(@pid_file)
38
+ #logger.warn "Sending #{kill_command} to #{pid.to_i}"
39
+ Process.kill(:INT, pid.to_i)
40
+ else
41
+ #logger.warn "No pid_file specified"
42
+ end
43
+ ensure
44
+ exit(0)
45
+ end
46
+
47
+ def daemonize!
48
+ # Fork and continue in forked process
49
+ # Also calls setsid
50
+ # Also redirects all output to /dev/null
51
+ Process.daemon(true)
52
+
53
+ # Reset umask
54
+ File.umask(0000)
55
+
56
+ # Set the procline
57
+ $0 = "mouth [initializing]"
58
+ end
59
+
60
+ def save_pid!
61
+ if @pid_file
62
+ pid = Process.pid
63
+ FileUtils.mkdir_p(File.dirname(@pid_file))
64
+ File.open(@pid_file, 'w') { |f| f.write(pid) }
65
+ end
66
+ end
67
+
68
+ def setup_logging!
69
+ if @log_file
70
+ STDERR.reopen(@log_file, 'a')
71
+
72
+ # Open a logger
73
+ self.logger = Logger.new(@log_file)
74
+ self.logger.level = case self.verbosity
75
+ when 0
76
+ Logger::WARN
77
+ when 1
78
+ Logger::INFO
79
+ else
80
+ Logger::DEBUG
81
+ end
82
+ Mouth.logger = self.logger
83
+
84
+ self.logger.info "Mouth Initialized..."
85
+ end
86
+ end
87
+
88
+ end # class Runner
89
+ end # module Mouth
@@ -0,0 +1,284 @@
1
+ module Mouth
2
+
3
+ # Usage:
4
+ # Sequence.new(["namespace.foobar_occurances"]).sequences
5
+ # # => {"foobar_occurances" => [4, 9, 0, ...]}
6
+ #
7
+ # Sequence.new(["namespace.foobar_occurances", "namespace.baz"], :kind => :timer).sequences
8
+ # # => {"foobar_occurances" => [{:count => 3, :min => 1, ...}, ...], "baz" => [...]}
9
+ #
10
+ # s = Sequence.new(...)
11
+ # s.time_sequence
12
+ # # => [Time.new(first datapoint), Time.new(second datapoint), ..., Time.new(last datapoint)]
13
+ class Sequence
14
+
15
+ attr_accessor :keys
16
+ attr_accessor :kind
17
+ attr_accessor :granularity_in_minutes
18
+ attr_accessor :start_time
19
+ attr_accessor :end_time
20
+ attr_accessor :namespace
21
+ attr_accessor :metrics
22
+
23
+ def initialize(keys, opts = {})
24
+ opts = {
25
+ :kind => :counter,
26
+ :granularity_in_minutes => 1,
27
+ :start_time => Time.now - (119 * 60),
28
+ :end_time => Time.now,
29
+ }.merge(opts)
30
+
31
+ self.keys = Array(keys)
32
+ self.kind = opts[:kind]
33
+ self.granularity_in_minutes = opts[:granularity_in_minutes]
34
+ self.start_time = opts[:start_time]
35
+ self.end_time = opts[:end_time]
36
+
37
+ self.metrics = []
38
+ namespaces = []
39
+ self.keys.each do |k|
40
+ namespace, metric = Mouth.parse_key(k)
41
+ namespaces << namespace
42
+ self.metrics << metric
43
+ end
44
+ raise StandardError.new("Batch calculation must come from the same namespace") if namespaces.uniq.length > 1
45
+ self.namespace = namespaces.first
46
+ end
47
+
48
+ def sequence
49
+ sequences.values.first
50
+ end
51
+
52
+ def sequences
53
+ return sequences_for_minute if self.granularity_in_minutes == 1
54
+ sequences_for_x_minutes(self.granularity_in_minutes)
55
+ end
56
+
57
+ def start_time_epoch
58
+ if self.granularity_in_minutes == 1
59
+ (self.start_time.to_i / 60) * 60
60
+ else
61
+ timestamp_to_nearest(self.start_time, self.granularity_in_minutes, :down) * 60
62
+ end
63
+ end
64
+
65
+ def time_sequence
66
+ end
67
+
68
+ # Epoch in seconds
69
+ def epoch_sequence
70
+ end
71
+
72
+ protected
73
+
74
+ def sequences_for_x_minutes(minutes)
75
+ start_timestamp = timestamp_to_nearest(self.start_time, minutes, :down)
76
+ end_timestamp = timestamp_to_nearest(self.end_time, minutes, :up)
77
+ # Then, for a timestamp xyz, it's in bucket (xyz - start_timestamp) / minutes
78
+
79
+ ###
80
+ map_function = <<-JS
81
+ function() {
82
+ emit(Math.floor((this.t - #{start_timestamp}) / #{minutes}), this);
83
+ }
84
+ JS
85
+
86
+ reduce_function = <<-JS
87
+ function(key, values) {
88
+ var result = {c: {}, m: {}}
89
+ , k
90
+ , existing
91
+ , current
92
+ ;
93
+
94
+ values.forEach(function(value) {
95
+ if (value.c) {
96
+ for (k in value.c) {
97
+ existing = result.c[k] || 0;
98
+ result.c[k] = existing + value.c[k]
99
+ }
100
+ }
101
+ if (value.m) {
102
+ for (k in value.m) {
103
+ current = value.m[k]
104
+ existing = result.m[k];
105
+ if (!existing) {
106
+ current.median = [current.median];
107
+ current.stddev = [current.stddev];
108
+ current.mean = [current.mean];
109
+ current.count = [current.count];
110
+
111
+ result.m[k] = current;
112
+ } else {
113
+ // {"count" => 0, "min" => nil, "max" => nil, "mean" => nil, "sum" => 0, "median" => nil, "stddev" => nil}
114
+ // Ok here existing is a non null one of these ^, and so is current. We just need to merge them.
115
+ existing.min = existing.min < current.min ? existing.min : current.min;
116
+ existing.max = existing.max > current.max ? existing.max : current.max;
117
+ existing.sum = existing.sum + current.sum;
118
+
119
+ // Save the individual stuff for later. Need it later for proper merge.
120
+ existing.median.push(current.median);
121
+ existing.stddev.push(current.stddev);
122
+ existing.mean.push(current.mean);
123
+ existing.count.push(current.count);
124
+ }
125
+ }
126
+ }
127
+ });
128
+
129
+ for (k in result.m) {
130
+ existing = result.m[k];
131
+
132
+ var count = existing.median.length
133
+ , middle = Math.floor(count / 2)
134
+ , overallCount = 0
135
+ , secondMoment = 0
136
+ , mean
137
+ , i
138
+ ;
139
+
140
+ // Total datapoints
141
+ for (i = 0; i < count; i += 1) {
142
+ overallCount = overallCount + existing.count[i];
143
+ }
144
+
145
+ // Mean
146
+ mean = existing.sum / overallCount;
147
+
148
+ // Median: approximate and take the median median.
149
+ if (count % 2 == 0) {
150
+ existing.median = (existing.median[middle] + existing.median[middle - 1]) / 2;
151
+ } else {
152
+ existing.median = existing.median[middle];
153
+ }
154
+
155
+ // Stddev
156
+ // weighted average of "second moments": M2 += count(i)/overallCount * (stddev(i)^2 + mean(i)^2)
157
+ // Then stddev = sqrt(M2 - mean^2)
158
+ for (i = 0; i < count; i += 1) {
159
+ var stddev_i = existing.stddev[i]
160
+ , mean_i = existing.mean[i]
161
+ ;
162
+
163
+ secondMoment = secondMoment + (existing.count[i] / overallCount) * (stddev_i * stddev_i + mean_i * mean_i)
164
+ }
165
+ existing.stddev = Math.sqrt(secondMoment - mean * mean);
166
+
167
+ // Mean:
168
+ existing.mean = mean;
169
+
170
+ // Count
171
+ existing.count = overallCount;
172
+ }
173
+
174
+ return result;
175
+ }
176
+ JS
177
+
178
+ result = collection.map_reduce(map_function, reduce_function, :out => {:inline => true}, :raw => true, :query => {"t" => {"$gte" => start_timestamp, "$lte" => end_timestamp}})
179
+
180
+ docs = result["results"].collect do |r|
181
+ ordinal = r["_id"]
182
+ doc = r["value"]
183
+ doc["t"] = (start_timestamp + ordinal * minutes).to_i
184
+ doc
185
+ end
186
+
187
+ sequences_from_documents(docs, start_timestamp, end_timestamp - 1, minutes)
188
+ end
189
+
190
+ def sequences_for_minute
191
+ start_timestamp = self.start_time.to_i / 60
192
+ end_timestamp = self.end_time.to_i / 60
193
+
194
+ docs = self.collection.find({"t" => {"$gte" => start_timestamp, "$lte" => end_timestamp}}, :fields => self.fields).to_a
195
+
196
+ sequences_from_documents(docs, start_timestamp, end_timestamp, 1)
197
+ end
198
+
199
+ def sequences_from_documents(docs, start_timestamp, end_timestamp, minutes)
200
+ timestamp_to_metrics = docs.inject({}) do |h, e|
201
+ h[e["t"]] = e[self.kind_letter]
202
+ h
203
+ end
204
+
205
+ default = self.kind == :counter ? 0 : {"count" => 0, "min" => nil, "max" => nil, "mean" => nil, "sum" => 0, "median" => nil, "stddev" => nil}
206
+
207
+ seqs = {}
208
+ self.metrics.each do |m|
209
+ seq = []
210
+ (start_timestamp..end_timestamp).step(minutes) do |t|
211
+ mets = timestamp_to_metrics[t]
212
+ seq << ((mets && mets[m]) || default)
213
+ end
214
+ seqs[m] = seq
215
+ end
216
+
217
+ seqs
218
+ end
219
+
220
+ def collection
221
+ @collection ||= Mouth.collection(Mouth.mongo_collection_name(self.namespace))
222
+ end
223
+
224
+ def kind_letter
225
+ @kind_letter ||= self.kind == :counter ? "c" : "m"
226
+ end
227
+
228
+ def fields
229
+ @fields ||= ["t"].concat(self.metrics.map {|m| "#{kind_letter}.#{m}" })
230
+ end
231
+
232
+ # timestamp_to_nearest(Time.now, 15, :down)
233
+ # => t = 22122825 such that t * 60 is a second-epoch time on a 15-minute boundary, eg, 2012-01-23 17:45:00
234
+ def timestamp_to_nearest(time, minute, rounded = :down)
235
+ start_timestamp = time.to_i / 60 # This is minute granularity
236
+ if rounded == :down
237
+ start_timestamp -= time.min % minute
238
+ else
239
+ start_timestamp += minute - time.min % minute
240
+ end
241
+ end
242
+
243
+ public
244
+
245
+ # Generates a sample sequence of both counter and timing
246
+ def self.generate_sample(opts = {})
247
+ opts = {
248
+ :namespace => "sample",
249
+ :metric => "sample",
250
+ :start_time => (Time.now.to_i / 60 - 300),
251
+ :end_time => (Time.now.to_i / 60),
252
+ }.merge(opts)
253
+
254
+ collection_name = Mouth.mongo_collection_name(opts[:namespace])
255
+
256
+ counter = 99
257
+ (opts[:start_time]..opts[:end_time]).each do |t|
258
+
259
+ # Generate garbage data for the sample
260
+ # NOTE: candidate for improvement
261
+ m_count = rand(20) + 1
262
+ m_mean = rand(20) + 40
263
+ m_doc = {
264
+ "count" => m_count,
265
+ "min" => rand(20),
266
+ "max" => rand(20) + 80,
267
+ "mean" => m_mean,
268
+ "sum" => m_mean * m_count,
269
+ "median" => m_mean + rand(5),
270
+ "stddev" => rand(10)
271
+ }
272
+
273
+ # Insert the document into mongo
274
+ Mouth.collection(collection_name).update({"t" => t}, {"$set" => {"c.#{opts[:metric]}" => counter, "m.#{opts[:metric]}" => m_doc}}, :upsert => true)
275
+
276
+ # Update counter randomly
277
+ counter += rand(10) - 5
278
+ counter = 0 if counter < 0
279
+ end
280
+
281
+ true
282
+ end
283
+ end
284
+ end