check 0.2.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/Gemfile +19 -0
- data/LICENSE +22 -0
- data/README.md +75 -0
- data/Rakefile +11 -0
- data/benchmarks/benchmark.rb +9 -0
- data/benchmarks/metric_check.rb +51 -0
- data/benchmarks/metric_crud.rb +43 -0
- data/check.gemspec +29 -0
- data/examples/config.ru +4 -0
- data/examples/example.rb +14 -0
- data/examples/metric_check.rb +36 -0
- data/examples/metric_crud.rb +21 -0
- data/examples/unicorn.conf.rb +41 -0
- data/lib/check.rb +17 -0
- data/lib/check/api.rb +50 -0
- data/lib/check/api/metrics.rb +64 -0
- data/lib/check/metric.rb +225 -0
- data/lib/check/notifications.rb +65 -0
- data/test/check/api/metrics_test.rb +109 -0
- data/test/check/metric_test.rb +255 -0
- data/test/check_test.rb +12 -0
- data/test/test_helper.rb +24 -0
- metadata +190 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
require_relative '../../check/metric'
|
2
|
+
|
3
|
+
require 'grape'
|
4
|
+
|
5
|
+
module Check
|
6
|
+
class Metrics < Grape::API
|
7
|
+
default_format :json
|
8
|
+
error_format :json
|
9
|
+
format :json
|
10
|
+
|
11
|
+
helpers do
|
12
|
+
# We'll do pretty some other time, promise!
|
13
|
+
def metric_params
|
14
|
+
params.inject({}) do |result, (k, v)|
|
15
|
+
unless (k == "route_info")
|
16
|
+
typecast_value = if v.match(/^\d+$/)
|
17
|
+
v.to_i
|
18
|
+
elsif v.index(',')
|
19
|
+
v.split(',')
|
20
|
+
else
|
21
|
+
v
|
22
|
+
end
|
23
|
+
result[k.to_sym] = typecast_value
|
24
|
+
end
|
25
|
+
result
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
resources :metrics do
|
31
|
+
desc 'Create metric check'
|
32
|
+
post '/' do
|
33
|
+
metric_check = Metric.new(metric_params).save
|
34
|
+
|
35
|
+
if metric_check.valid?
|
36
|
+
[metric_check.name]
|
37
|
+
else
|
38
|
+
error!({:errors => metric_check.errors}, 409)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
desc 'Delete specific metric check'
|
43
|
+
delete '/' do
|
44
|
+
Metric.find(metric_params).delete
|
45
|
+
end
|
46
|
+
|
47
|
+
desc 'Delete all matching metric checks'
|
48
|
+
delete '/:name' do
|
49
|
+
Metric.delete_all(params[:name])
|
50
|
+
end
|
51
|
+
|
52
|
+
desc 'List all matching metric checks'
|
53
|
+
get '/:name' do
|
54
|
+
metric_check = Metric.find(metric_params)
|
55
|
+
|
56
|
+
if metric_check.persisted?
|
57
|
+
metric_check.similar.to_json
|
58
|
+
else
|
59
|
+
[]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/check/metric.rb
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
require_relative '../check'
|
2
|
+
|
3
|
+
require 'hashr'
|
4
|
+
require 'redis/set'
|
5
|
+
require 'redis/list'
|
6
|
+
require 'redis/value'
|
7
|
+
require 'msgpack'
|
8
|
+
|
9
|
+
module Check
|
10
|
+
class Metric < Hashr
|
11
|
+
# If you really want to overwrite the defaults completely, inherit this
|
12
|
+
# class and re-define self.defaults. Don't forget to invoke the define
|
13
|
+
# method with the new defaults as this is Hashr's way of defining the hash
|
14
|
+
# blueprint.
|
15
|
+
#
|
16
|
+
# It would be interesting to go further with positives and categorize them
|
17
|
+
# as consistent (>50% matches are lower or higher) or fluctuating (50/50).
|
18
|
+
#
|
19
|
+
def self.defaults
|
20
|
+
{
|
21
|
+
lower: 1,
|
22
|
+
upper: 10,
|
23
|
+
matches_for_positive: 2,
|
24
|
+
over_seconds: 60,
|
25
|
+
suspend_after_positives: 1,
|
26
|
+
keep_positives: 10,
|
27
|
+
suspend_for_seconds: 1800
|
28
|
+
}
|
29
|
+
end
|
30
|
+
define self.defaults
|
31
|
+
|
32
|
+
# In this example, we are overwriting the defaults so that all new
|
33
|
+
# configs will consider 5 matches over a period of 60 seconds to
|
34
|
+
# be a positive. The metric check will be suspended for 1h after 3
|
35
|
+
# positives. The lower and upper bounds are also adjusted.
|
36
|
+
#
|
37
|
+
# Check::Metric.defaults = {
|
38
|
+
# lower: 10,
|
39
|
+
# upper: 100,
|
40
|
+
# matches_for_positive: 5,
|
41
|
+
# over_seconds: 60,
|
42
|
+
# suspend_after_positives: 3,
|
43
|
+
# suspend_for_seconds: 3600
|
44
|
+
# }
|
45
|
+
#
|
46
|
+
def self.defaults=(params={})
|
47
|
+
define self.defaults.merge(params)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.delete_all(name)
|
51
|
+
Redis.current.del(name)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.find(params={})
|
55
|
+
Metric.new(params)
|
56
|
+
end
|
57
|
+
|
58
|
+
def set
|
59
|
+
Redis::Set.new(self.fetch(:name)) if valid_name?
|
60
|
+
end
|
61
|
+
|
62
|
+
def similar
|
63
|
+
set.members.map do |member|
|
64
|
+
Metric.new(unpack(member))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def pack
|
69
|
+
self.to_hash.to_msgpack
|
70
|
+
end
|
71
|
+
alias :packed :pack
|
72
|
+
|
73
|
+
def unpack(value)
|
74
|
+
MessagePack.unpack(value)
|
75
|
+
end
|
76
|
+
|
77
|
+
def save
|
78
|
+
set.add(packed) if valid?
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
def delete
|
83
|
+
delete_associated
|
84
|
+
set.delete(packed)
|
85
|
+
end
|
86
|
+
|
87
|
+
def id
|
88
|
+
hash.abs
|
89
|
+
end
|
90
|
+
|
91
|
+
def namespace(string)
|
92
|
+
"#{self.fetch(:name)}:#{string}:#{self.id}"
|
93
|
+
end
|
94
|
+
|
95
|
+
def matches_key
|
96
|
+
namespace("matches")
|
97
|
+
end
|
98
|
+
|
99
|
+
def positives_key
|
100
|
+
namespace("positives")
|
101
|
+
end
|
102
|
+
|
103
|
+
def disable_key
|
104
|
+
namespace("disable")
|
105
|
+
end
|
106
|
+
|
107
|
+
def matches
|
108
|
+
Redis::List.new(matches_key, maxlength: self.fetch(:matches_for_positive), marshal: true)
|
109
|
+
end
|
110
|
+
|
111
|
+
def delete_matches
|
112
|
+
Redis.current.del(matches_key)
|
113
|
+
end
|
114
|
+
# removing all elements from a list is the same as deleting that list altogether
|
115
|
+
alias :clear_matches :delete_matches
|
116
|
+
|
117
|
+
def positives
|
118
|
+
Redis::List.new(positives_key, maxlength: self.fetch(:keep_positives), marshal: true)
|
119
|
+
end
|
120
|
+
|
121
|
+
def delete_positives
|
122
|
+
Redis.current.del(positives_key)
|
123
|
+
end
|
124
|
+
# removing all elements from a list is the same as deleting that list altogether
|
125
|
+
alias :clear_positives :delete_positives
|
126
|
+
|
127
|
+
def delete_associated
|
128
|
+
Redis.current.del([matches_key, positives_key, disable_key])
|
129
|
+
end
|
130
|
+
|
131
|
+
def suspended?(timestamp=Time.now.utc.to_i)
|
132
|
+
last_positive = positives.last
|
133
|
+
|
134
|
+
if last_positive
|
135
|
+
timestamp - last_positive.fetch(:timestamp) <= self.fetch(:suspend_for_seconds)
|
136
|
+
else
|
137
|
+
false
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def disable
|
142
|
+
Redis::Value.new(disable_key, marshal: true)
|
143
|
+
end
|
144
|
+
|
145
|
+
def disable!
|
146
|
+
disable.value = true
|
147
|
+
end
|
148
|
+
|
149
|
+
def enable!
|
150
|
+
disable.delete
|
151
|
+
end
|
152
|
+
alias :delete_disable :enable!
|
153
|
+
|
154
|
+
def disabled?
|
155
|
+
disable.exists?
|
156
|
+
end
|
157
|
+
|
158
|
+
def trigger_positive?
|
159
|
+
first_match = matches.first
|
160
|
+
last_match = matches.last
|
161
|
+
|
162
|
+
if first_match and last_match
|
163
|
+
matches.length == self.fetch(:matches_for_positive) and
|
164
|
+
last_match.fetch(:timestamp) - first_match.fetch(:timestamp) <= self.fetch(:over_seconds)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def notify?
|
169
|
+
REDIS_NOTIFICATIONS.strip != ""
|
170
|
+
end
|
171
|
+
|
172
|
+
def notify(message)
|
173
|
+
Redis.current.publish(REDIS_NOTIFICATIONS, message.to_msgpack) if notify?
|
174
|
+
end
|
175
|
+
|
176
|
+
def check(params)
|
177
|
+
timestamp = params.fetch(:timestamp) { Time.now.utc }.to_i
|
178
|
+
value = params.fetch(:value)
|
179
|
+
|
180
|
+
similar.each do |metric|
|
181
|
+
next if metric.suspended?(timestamp) or metric.disabled?
|
182
|
+
|
183
|
+
unless value.between?(metric.fetch(:lower), metric.fetch(:upper))
|
184
|
+
metric.matches.push({
|
185
|
+
value: value,
|
186
|
+
timestamp: timestamp
|
187
|
+
})
|
188
|
+
end
|
189
|
+
|
190
|
+
if metric.trigger_positive?
|
191
|
+
new_positive = {
|
192
|
+
timestamp: metric.matches.last.fetch(:timestamp),
|
193
|
+
matches: metric.matches.values
|
194
|
+
}
|
195
|
+
metric.positives.push(new_positive)
|
196
|
+
notify(new_positive.merge(metric))
|
197
|
+
metric.clear_matches
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def errors
|
203
|
+
return @errors if @errors
|
204
|
+
@errors = {}
|
205
|
+
end
|
206
|
+
|
207
|
+
def valid_name?
|
208
|
+
if (self.fetch(:name) { "" }.strip != "")
|
209
|
+
errors.delete(:name)
|
210
|
+
return true
|
211
|
+
else
|
212
|
+
errors[:name] = "can't be blank"
|
213
|
+
return false
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def valid?
|
218
|
+
valid_name?
|
219
|
+
end
|
220
|
+
|
221
|
+
def persisted?
|
222
|
+
set.include?(packed)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'check'
|
3
|
+
require 'msgpack'
|
4
|
+
|
5
|
+
module Check
|
6
|
+
class Notifications
|
7
|
+
def initialize(redis_notifications=REDIS_NOTIFICATIONS)
|
8
|
+
shutdown_gracefully
|
9
|
+
|
10
|
+
Redis.current.subscribe(redis_notifications) do |on|
|
11
|
+
if block_given?
|
12
|
+
yield(on)
|
13
|
+
else
|
14
|
+
on_subscribe.(on)
|
15
|
+
on_message.(on)
|
16
|
+
on_unsubscribe.(on)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def on_subscribe
|
22
|
+
Proc.new do |on|
|
23
|
+
on.subscribe do |channel, subscriptions|
|
24
|
+
puts "Subscribed to #{channel} (#{subscriptions} subscriptions)"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def on_message
|
30
|
+
Proc.new do |on|
|
31
|
+
on.message do |channel, message|
|
32
|
+
puts "#{channel}: #{unpack(message)}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def on_unsubscribe
|
38
|
+
Proc.new do |on|
|
39
|
+
on.unsubscribe do |channel, subscriptions|
|
40
|
+
puts "Unsubscribed from ##{channel} (#{subscriptions} subscriptions)"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def unpack(message)
|
46
|
+
MessagePack.unpack(message)
|
47
|
+
rescue MessagePack::UnpackError
|
48
|
+
puts "#{message.inspect} could not be unpacked by MessagePack"
|
49
|
+
end
|
50
|
+
|
51
|
+
def shutdown_gracefully
|
52
|
+
Signal.trap "SIGTERM", shutdown
|
53
|
+
Signal.trap "SIGINT", shutdown
|
54
|
+
Signal.trap "SIGQUIT", shutdown
|
55
|
+
end
|
56
|
+
|
57
|
+
def shutdown
|
58
|
+
Proc.new do
|
59
|
+
Redis.current.disconnect
|
60
|
+
puts "\n#{self.class} shutting down..."
|
61
|
+
exit 0
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require_relative '../../test_helper'
|
2
|
+
|
3
|
+
require 'check/api/metrics'
|
4
|
+
|
5
|
+
module Check
|
6
|
+
describe Metrics do
|
7
|
+
include Rack::Test::Methods
|
8
|
+
|
9
|
+
def app
|
10
|
+
Check::Metrics
|
11
|
+
end
|
12
|
+
|
13
|
+
def body
|
14
|
+
JSON.parse(last_response.body)
|
15
|
+
end
|
16
|
+
|
17
|
+
before do
|
18
|
+
Redis.current.flushdb
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "GET /metrics/:metric_name" do
|
22
|
+
describe "when there are no metrics" do
|
23
|
+
it "returns empty array" do
|
24
|
+
get("/metrics/foo")
|
25
|
+
last_response.content_type.must_equal "application/json"
|
26
|
+
last_response.status.must_equal 200
|
27
|
+
body.must_equal([])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe "when there are metrics" do
|
32
|
+
before do
|
33
|
+
matching_metric = Metric.new(name: "foo").save
|
34
|
+
other_metric = Metric.new(name: "bar").save
|
35
|
+
end
|
36
|
+
|
37
|
+
it "returns only matching ones" do
|
38
|
+
get("/metrics/foo")
|
39
|
+
last_response.content_type.must_equal "application/json"
|
40
|
+
last_response.status.must_equal 200
|
41
|
+
body.size.must_equal 1
|
42
|
+
body.first.fetch('name').must_equal "foo"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
describe "POST /metrics" do
|
48
|
+
it "returns a 409 if params are invalid" do
|
49
|
+
post("/metrics")
|
50
|
+
last_response.content_type.must_equal "application/json"
|
51
|
+
last_response.status.must_equal 409
|
52
|
+
body["errors"].must_equal({
|
53
|
+
'name' => "can't be blank"
|
54
|
+
})
|
55
|
+
end
|
56
|
+
|
57
|
+
it "creates a new metric if params are valid" do
|
58
|
+
post("/metrics/", { name: "metric1" })
|
59
|
+
last_response.content_type.must_equal "application/json"
|
60
|
+
last_response.status.must_equal 201
|
61
|
+
body.must_equal ["metric1"]
|
62
|
+
Metric.find(name: "metric1").must_be :persisted?
|
63
|
+
end
|
64
|
+
|
65
|
+
it "persists values with , as arrays" do
|
66
|
+
post("/metrics", { name: "metric2", emails: "foo@bar.com,baz@qux.com" })
|
67
|
+
metric = Metric.find(name: "metric2").similar.first
|
68
|
+
(%w[foo@bar.com baz@qux.com] - metric.emails).must_equal []
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe "DELETE /metrics/:metric_name" do
|
73
|
+
describe "when metric does not exist" do
|
74
|
+
it "doesn't delete anything" do
|
75
|
+
delete("/metrics/foo")
|
76
|
+
last_response.status.must_equal 200
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "when metric exists" do
|
81
|
+
it "deletes all similar" do
|
82
|
+
Metric.new(name: "foo").save
|
83
|
+
Metric.new(name: "foo", lower: 2).save
|
84
|
+
Metric.new(name: "bar").save
|
85
|
+
delete("/metrics/foo")
|
86
|
+
last_response.status.must_equal 200
|
87
|
+
Redis.current.keys.must_equal ["bar"]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
describe "DELETE /metrics" do
|
93
|
+
it "returns 200 if metric doesn't exist" do
|
94
|
+
delete("/metrics", { name: "foo" })
|
95
|
+
last_response.status.must_equal 200
|
96
|
+
end
|
97
|
+
|
98
|
+
it "deletes a specific metric" do
|
99
|
+
Metric.new(name: "foo", lower: 10).save
|
100
|
+
Metric.new(name: "foo", lower: 9).save
|
101
|
+
|
102
|
+
delete("/metrics", { name: "foo", lower: 10 })
|
103
|
+
last_response.content_type.must_equal "application/json"
|
104
|
+
last_response.status.must_equal 200
|
105
|
+
Metric.find(name: "foo").similar.size.must_equal 1
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|