spectator-rb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/buildViaTravis.sh ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+ set -x
5
+
6
+ bundle exec rake test
7
+ bundle exec rake rubocop
data/lib/spectator.rb ADDED
@@ -0,0 +1,22 @@
1
+ require 'spectator/version'
2
+ require 'spectator/registry'
3
+ require 'spectator/meter_id'
4
+ require 'spectator/clock'
5
+ require 'spectator/timer'
6
+ require 'spectator/counter'
7
+ require 'spectator/distribution_summary'
8
+ require 'spectator/gauge'
9
+ require 'logger'
10
+
11
+ # Simple library for instrumenting code to record dimensional time series.
12
+ module Spectator
13
+ class << self
14
+ attr_writer :logger
15
+
16
+ def logger
17
+ @logger ||= Logger.new($stdout).tap do |log|
18
+ log.progname = name
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,49 @@
1
+ module Spectator
2
+ # Thread safe number operations
3
+ class AtomicNumber
4
+ def initialize(init)
5
+ @value = init
6
+ @lock = Mutex.new
7
+ end
8
+
9
+ def set(value)
10
+ @lock.synchronize { @value = value }
11
+ end
12
+
13
+ def get
14
+ @lock.synchronize { @value }
15
+ end
16
+
17
+ def get_and_set(value)
18
+ @lock.synchronize do
19
+ tmp = @value
20
+ @value = value
21
+ tmp
22
+ end
23
+ end
24
+
25
+ def get_and_add(amount)
26
+ @lock.synchronize do
27
+ tmp = @value
28
+ @value += amount
29
+ tmp
30
+ end
31
+ end
32
+
33
+ def add_and_get(amount)
34
+ @lock.synchronize { @value += amount }
35
+ end
36
+
37
+ def max(value)
38
+ v = value.to_f
39
+ @lock.synchronize do
40
+ @value = v if v > @value || @value.nan?
41
+ end
42
+ @value
43
+ end
44
+
45
+ def to_s
46
+ "AtomicNumber{#{@value}}"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,57 @@
1
+ module Spectator
2
+ # A timing source that can be used to access the current time as an object,
3
+ # and a high resolution monotonic time
4
+ class SystemClock
5
+ # A monotonically increasing number of nanoseconds. This is useful for
6
+ # recording times, or benchmarking.
7
+ # Note that this is not guaranteed to be steady.
8
+ # In other words each tick of the underlying clock may not
9
+ # be the same length (e.g. some seconds might be longer than others)
10
+ # @return A monotonic number of nanoseconds
11
+ def monotonic_time
12
+ MonotonicTime.time_in_nanoseconds
13
+ end
14
+
15
+ # @return a time object for the current time
16
+ def wall_time
17
+ Time.now
18
+ end
19
+ end
20
+
21
+ # A timing source useful in unit tests that can be used to mock the methods in
22
+ # SystemClock
23
+ class ManualClock
24
+ attr_accessor :wall_time
25
+ attr_accessor :monotonic_time
26
+
27
+ # Get a new object using 2000-1-1 0:0:0 UTC as the default time,
28
+ # and 0 nanoseconds as the number of nanos reported by monotonic_time
29
+ def initialize(wall_init: Time.utc(2000, 'jan', 1, 0, 0, 0), mono_time: 0)
30
+ @wall_time = wall_init
31
+ @monotonic_time = mono_time
32
+ end
33
+ end
34
+
35
+ # Gather a monotonically increasing number of nanoseconds.
36
+ # If Process::CLOCK_MONOTONIC is available we use that, otherwise we attempt
37
+ # to use java.lang.System.nanoTime if running in jruby, and fallback
38
+ # to the Time.now implementation
39
+ module MonotonicTime
40
+ module_function
41
+
42
+ if defined? Process::CLOCK_MONOTONIC
43
+ def time_in_nanoseconds
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
45
+ end
46
+ elsif (defined?(RUBY_ENGINE) ? RUBY_ENGINE : 'ruby') == 'jruby'
47
+ def time_in_nanoseconds
48
+ java.lang.System.nanoTime
49
+ end
50
+ else
51
+ def time_in_nanoseconds
52
+ t = Time.now
53
+ t.to_i * 10**9 + t.nsec
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,34 @@
1
+ require 'spectator/atomic_number'
2
+ require 'spectator/measure'
3
+
4
+ module Spectator
5
+ # A counter is used to measure the rate at which an event is occurring
6
+ class Counter
7
+ # Initialize a new instance setting its id, and starting the
8
+ # count at 0
9
+ def initialize(id)
10
+ @id = id
11
+ @count = AtomicNumber.new(0)
12
+ end
13
+
14
+ # Increment the counter by delta
15
+ def increment(delta = 1)
16
+ @count.add_and_get(delta)
17
+ end
18
+
19
+ # Get the current count as a list of Measure and reset the count to 0
20
+ def measure
21
+ [Measure.new(@id.with_stat('count'), @count.get_and_set(0))]
22
+ end
23
+
24
+ # Read the current count. Calls to measure will reset it
25
+ def count
26
+ @count.get
27
+ end
28
+
29
+ # Get a string representation for debugging purposes
30
+ def to_s
31
+ "Counter{id=#{@id}, count=#{@count.get}}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ require 'spectator/atomic_number'
2
+
3
+ module Spectator
4
+ # Track the sample distribution of events. An example would be the response
5
+ # sizes for requests hitting an http server.
6
+ #
7
+ # The class will report measurements for the total amount, the count, max,
8
+ # and the total of the square of the amounts recorded
9
+ # (useful for computing a standard deviation)
10
+ class DistributionSummary
11
+ # Initialize a new DistributionSummary instance with a given id
12
+ def initialize(id)
13
+ @id = id
14
+ @count = AtomicNumber.new(0)
15
+ @total_amount = AtomicNumber.new(0)
16
+ @total_sq = AtomicNumber.new(0)
17
+ @max = AtomicNumber.new(Float::NAN)
18
+ end
19
+
20
+ # Update the statistics kept by the summary with the specified amount.
21
+ def record(amount)
22
+ return if amount < 0
23
+ @count.add_and_get(1)
24
+ @total_amount.add_and_get(amount)
25
+ @total_sq.add_and_get(amount * amount)
26
+ @max.max(amount)
27
+ end
28
+
29
+ # Get the current amount
30
+ def count
31
+ @count.get
32
+ end
33
+
34
+ # Return the total amount
35
+ def total_amount
36
+ @total_amount.get
37
+ end
38
+
39
+ # Get a list of measurements, and reset the stats
40
+ # The stats returned are the current count, the total amount,
41
+ # the sum of the square of the amounts recorded, and the max value
42
+ def measure
43
+ cnt = Measure.new(@id.with_stat('count'), @count.get_and_set(0))
44
+ tot = Measure.new(@id.with_stat('totalAmount'),
45
+ @total_amount.get_and_set(0))
46
+ tot_sq = Measure.new(@id.with_stat('totalOfSquares'),
47
+ @total_sq.get_and_set(0))
48
+ mx = Measure.new(@id.with_stat('max'), @max.get_and_set(Float::NAN))
49
+
50
+ [cnt, tot, tot_sq, mx]
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ require 'spectator/atomic_number'
2
+ require 'spectator/measure'
3
+
4
+ module Spectator
5
+ # A meter with a single value that can only be sampled at a point in time.
6
+ # A typical example is a queue size.
7
+ class Gauge
8
+ # Initialize a new instance of a Gauge with the given id
9
+ def initialize(id)
10
+ @id = id
11
+ @value = AtomicNumber.new(Float::NAN)
12
+ end
13
+
14
+ # Get the current value
15
+ def get
16
+ @value.get
17
+ end
18
+
19
+ # Set the current value to the number specified
20
+ def set(value)
21
+ @value.set(value)
22
+ end
23
+
24
+ # Get the current value, and reset it
25
+ def measure
26
+ [Measure.new(@id.with_stat('gauge'), @value.get_and_set(Float::NAN))]
27
+ end
28
+
29
+ # A string representation of this gauge, useful for debugging purposes
30
+ def to_s
31
+ "Gauge{id=#{@id}, value=#{@value.get}}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ require 'json'
2
+ require 'net/http'
3
+
4
+ module Spectator
5
+ # Helper for HTTP requests
6
+ class Http
7
+ # Create a new instance using the given registry
8
+ # to record stats for the requests performed
9
+ def initialize(registry)
10
+ @registry = registry
11
+ end
12
+
13
+ # Send a JSON payload to a given endpoing
14
+ def post_json(endpoint, payload)
15
+ s = payload.to_json
16
+ uri = URI(endpoint)
17
+ http = Net::HTTP.new(uri.host, uri.port)
18
+ req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
19
+ req.body = s
20
+ begin
21
+ res = http.request(req)
22
+ rescue StandardError => e
23
+ Spectator.logger.info("Cause #{e.cause} - msg=#{e.message}")
24
+ return 400
25
+ end
26
+
27
+ res.value
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module Spectator
2
+ # This immutable class represents a measurement sampled from a meter
3
+ class Measure
4
+ attr_reader :id, :value
5
+
6
+ # A meter id and a value
7
+ def initialize(id, value)
8
+ @id = id
9
+ @value = value.to_f
10
+ end
11
+
12
+ # A string representation of this measurement, for debugging purposes
13
+ def to_s
14
+ "Measure{id=#{@id}, value=#{@value}}"
15
+ end
16
+
17
+ # Compare this measurement against another one,
18
+ # taking into account nan values
19
+ def ==(other)
20
+ @id == other.id && (@value == other.value ||
21
+ @value.nan? && other.value.nan?)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,52 @@
1
+ module Spectator
2
+ # Identifier for a meter or Measure
3
+ class MeterId
4
+ attr_reader :name, :tags
5
+ def initialize(name, maybe_tags = nil)
6
+ tags = maybe_tags.nil? ? {} : maybe_tags
7
+ @name = name.to_sym
8
+ @tags = {}
9
+ tags.each { |k, v| @tags[k.to_sym] = v.to_sym }
10
+ @tags.freeze
11
+ @key = nil
12
+ end
13
+
14
+ # Create a new MeterId with a given key and value
15
+ def with_tag(key, value)
16
+ new_tags = @tags.dup
17
+ new_tags[key] = value
18
+ MeterId.new(@name, new_tags)
19
+ end
20
+
21
+ # Create a new MeterId with key=statistic and the given value
22
+ def with_stat(stat_value)
23
+ with_tag(:statistic, stat_value)
24
+ end
25
+
26
+ # lazyily compute a key to be used in hashes for efficiency
27
+ def key
28
+ if @key.nil?
29
+ hash_key = @name.to_s
30
+ @key = hash_key
31
+ keys = @tags.keys
32
+ keys.sort
33
+ keys.each do |k|
34
+ v = tags[k]
35
+ hash_key += "|#{k}|#{v}"
36
+ end
37
+ @key = hash_key
38
+ end
39
+ @key
40
+ end
41
+
42
+ # A string representation for debugging purposes
43
+ def to_s
44
+ "MeterId{name=#{@name}, tags=#{@tags}}"
45
+ end
46
+
47
+ # Compare our id and tags against another MeterId
48
+ def ==(other)
49
+ other.name == @name && other.tags == @tags
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,298 @@
1
+ require 'spectator/clock'
2
+ require 'spectator/counter'
3
+ require 'spectator/distribution_summary'
4
+ require 'spectator/gauge'
5
+ require 'spectator/http'
6
+ require 'spectator/meter_id'
7
+ require 'spectator/timer'
8
+
9
+ module Spectator
10
+ # Registry to manage a set of meters
11
+ class Registry
12
+ attr_reader :config, :clock, :publisher, :common_tags
13
+
14
+ # Initialize the registry using the given config, and clock
15
+ # The default clock is the SystemClock
16
+ # The config is a Hash which should include:
17
+ # :common_tags as a hash with tags that will be added to all metrics
18
+ # :frequency the interval at which metrics will be sent to an
19
+ # aggregator service, expressed in seconds
20
+ # :uri the endpoint for the aggregator service
21
+ def initialize(config, clock = SystemClock.new)
22
+ @config = config
23
+ @clock = clock
24
+ @meters = {}
25
+ @common_tags = to_symbols(config[:common_tags]) || {}
26
+ @lock = Mutex.new
27
+ @publisher = Publisher.new(self)
28
+ end
29
+
30
+ # Create a new MeterId with the given name, and optional tags
31
+ def new_id(name, tags = nil)
32
+ MeterId.new(name, tags)
33
+ end
34
+
35
+ # Create or get a Counter with the given id
36
+ def counter_with_id(id)
37
+ new_meter(id) { |meter_id| Counter.new(meter_id) }
38
+ end
39
+
40
+ # Create or get a Gauge with the given id
41
+ def gauge_with_id(id)
42
+ new_meter(id) { |meter_id| Gauge.new(meter_id) }
43
+ end
44
+
45
+ # Create or get a DistributionSummary with the given id
46
+ def distribution_summary_with_id(id)
47
+ new_meter(id) { |meter_id| DistributionSummary.new(meter_id) }
48
+ end
49
+
50
+ # Create or get a Timer with the given id
51
+ def timer_with_id(id)
52
+ new_meter(id) { |meter_id| Timer.new(meter_id) }
53
+ end
54
+
55
+ # Create or get a Counter with the given name, and optional tags
56
+ def counter(name, tags = nil)
57
+ counter_with_id(MeterId.new(name, tags))
58
+ end
59
+
60
+ # Create or get a Gauge with the given name, and optional tags
61
+ def gauge(name, tags = nil)
62
+ gauge_with_id(MeterId.new(name, tags))
63
+ end
64
+
65
+ # Create or get a DistributionSummary with the given name, and optional tags
66
+ def distribution_summary(name, tags = nil)
67
+ distribution_summary_with_id(MeterId.new(name, tags))
68
+ end
69
+
70
+ # Create or get a Timer with the given name, and optional tags
71
+ def timer(name, tags = nil)
72
+ timer_with_id(MeterId.new(name, tags))
73
+ end
74
+
75
+ # Get the list of measurements from all registered meters
76
+ def measurements
77
+ @lock.synchronize do
78
+ @meters.values.flat_map(&:measure)
79
+ end
80
+ end
81
+
82
+ # Start publishing measurements to the aggregator service
83
+ def start
84
+ @publisher.start
85
+ end
86
+
87
+ # Stop publishing measurements
88
+ def stop
89
+ @publisher.stop
90
+ end
91
+
92
+ private
93
+
94
+ def to_symbols(tags)
95
+ return nil if tags.nil?
96
+
97
+ symbolic_tags = {}
98
+ tags.each { |k, v| symbolic_tags[k.to_sym] = v.to_sym }
99
+ symbolic_tags
100
+ end
101
+
102
+ def new_meter(meter_id)
103
+ @lock.synchronize do
104
+ meter = @meters[meter_id.key]
105
+ if meter.nil?
106
+ meter = yield(meter_id)
107
+ @meters[meter_id.key] = meter
108
+ end
109
+ meter
110
+ end
111
+ end
112
+ end
113
+
114
+ # Internal class used to publish measurements to an aggregator service
115
+ class Publisher
116
+ def initialize(registry)
117
+ @registry = registry
118
+ @started = false
119
+ @should_stop = false
120
+ @frequency = registry.config[:frequency] || 5
121
+ @http = Http.new(registry)
122
+ end
123
+
124
+ def should_start?
125
+ if @started
126
+ Spectator.logger.info('Ignoring start request. ' \
127
+ 'Spectator registry already started')
128
+ return false
129
+ end
130
+
131
+ @started = true
132
+ uri = @registry.config[:uri]
133
+ if uri.nil? || uri.empty?
134
+ Spectator.logger.info('Ignoring start request since Spectator ' \
135
+ 'registry has no valid uri')
136
+ return false
137
+ end
138
+
139
+ true
140
+ end
141
+
142
+ # Start publishing if the config is acceptable:
143
+ # uri is non-nil or empty
144
+ def start
145
+ return unless should_start?
146
+
147
+ Spectator.logger.info 'Starting Spectator registry'
148
+
149
+ @should_stop = false
150
+ @publish_thread = Thread.new do
151
+ publish
152
+ end
153
+ end
154
+
155
+ # Stop publishing measurements
156
+ def stop
157
+ unless @started
158
+ Spectator.logger.info('Attemping to stop Spectator ' \
159
+ 'without a previous call to start')
160
+ return
161
+ end
162
+
163
+ @should_stop = true
164
+ Spectator.logger.info('Stopping spectator')
165
+ @publish_thread.kill if @publish_thread
166
+
167
+ @started = false
168
+ Spectator.logger.info('Sending last batch of metrics before exiting')
169
+ send_metrics_now
170
+ end
171
+
172
+ ADD_OP = 0
173
+ MAX_OP = 10
174
+ UNKNOWN_OP = -1
175
+ OPS = { count: ADD_OP,
176
+ totalAmount: ADD_OP,
177
+ totalTime: ADD_OP,
178
+ totalOfSquares: ADD_OP,
179
+ percentile: ADD_OP,
180
+ max: MAX_OP,
181
+ gauge: MAX_OP,
182
+ activeTasks: MAX_OP,
183
+ duration: MAX_OP }.freeze
184
+ # Get the operation to be used for the given Measure
185
+ # Gauges are aggregated using MAX_OP, counters with ADD_OP
186
+ def op_for_measurement(measure)
187
+ stat = measure.id.tags.fetch(:statistic, :unknown)
188
+ OPS.fetch(stat, UNKNOWN_OP)
189
+ end
190
+
191
+ # Gauges are sent if they have a value
192
+ # Counters if they have a number of increments greater than 0
193
+ def should_send(measure)
194
+ op = op_for_measurement(measure)
195
+ return measure.value > 0 if op == ADD_OP
196
+ return !measure.value.nan? if op == MAX_OP
197
+
198
+ false
199
+ end
200
+
201
+ # Build a string table from the list of measurements
202
+ # Unique words are identified, and assigned a number starting from 0 based
203
+ # on their lexicographical order
204
+ def build_string_table(measurements)
205
+ common_tags = @registry.common_tags
206
+ table = {}
207
+ common_tags.each do |k, v|
208
+ table[k] = 0
209
+ table[v] = 0
210
+ end
211
+ table[:name] = 0
212
+ measurements.each do |m|
213
+ table[m.id.name] = 0
214
+ m.id.tags.each do |k, v|
215
+ table[k] = 0
216
+ table[v] = 0
217
+ end
218
+ end
219
+ keys = table.keys.sort
220
+ keys.each_with_index do |str, index|
221
+ table[str] = index
222
+ end
223
+ table
224
+ end
225
+
226
+ # Add a measurement to our payload table.
227
+ # The serialization for a measurement is:
228
+ # - length of tags
229
+ # - indexes for the tags based on the string table
230
+ # - operation (add (0), max (10))
231
+ # - floating point value
232
+ def append_measurement(payload, table, measure)
233
+ op = op_for_measurement(measure)
234
+ common_tags = @registry.common_tags
235
+ tags = measure.id.tags
236
+ len = tags.length + 1 + common_tags.length
237
+ payload.push(len)
238
+ common_tags.each do |k, v|
239
+ payload.push(table[k])
240
+ payload.push(table[v])
241
+ end
242
+ tags.each do |k, v|
243
+ payload.push(table[k])
244
+ payload.push(table[v])
245
+ end
246
+ payload.push(table[:name])
247
+ payload.push(table[measure.id.name])
248
+ payload.push(op)
249
+ payload.push(measure.value)
250
+ end
251
+
252
+ # Generate a payload from the list of measurements
253
+ # The payload is an array, with the number of elements in the string table
254
+ # The string table, and measurements
255
+ def payload_for_measurements(measurements)
256
+ table = build_string_table(measurements)
257
+ payload = []
258
+ payload.push(table.length)
259
+ strings = table.keys.sort
260
+ payload.concat(strings)
261
+ measurements.each { |m| append_measurement(payload, table, m) }
262
+ payload
263
+ end
264
+
265
+ # Get a list of measurements that should be sent
266
+ def registry_measurements
267
+ @registry.measurements.select { |m| should_send(m) }
268
+ end
269
+
270
+ # Send the current measurements to our aggregator service
271
+ def send_metrics_now
272
+ ms = registry_measurements
273
+ if ms.empty?
274
+ Spectator.logger.debug 'No measurements to send'
275
+ else
276
+ payload = payload_for_measurements(ms)
277
+ uri = @registry.config[:uri]
278
+ Spectator.logger.info "Sending #{ms.length} measurements to #{uri}"
279
+ @http.post_json(uri, payload)
280
+ end
281
+ end
282
+
283
+ # Publish loop:
284
+ # send measurements to the aggregator endpoint ':uri',
285
+ # every ':frequency' seconds
286
+ def publish
287
+ clock = @registry.clock
288
+ until @should_stop
289
+ start = clock.wall_time
290
+ Spectator.logger.info 'Publishing'
291
+ send_metrics_now
292
+ elapsed = clock.wall_time - start
293
+ sleep @frequency - elapsed if elapsed < @frequency
294
+ end
295
+ Spectator.logger.info 'Stopping publishing thread'
296
+ end
297
+ end
298
+ end