spectator-rb 0.1.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.
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