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.
@@ -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
@@ -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