sidekiq-vigil 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.
- checksums.yaml +7 -0
- data/.rubocop.yml +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +237 -0
- data/Rakefile +10 -0
- data/docs/redis_keys.md +18 -0
- data/docs/webhook_event.schema.json +41 -0
- data/docs/webhook_schema.md +64 -0
- data/exe/vigil +6 -0
- data/lib/generators/sidekiq_vigil/install_generator.rb +32 -0
- data/lib/generators/sidekiq_vigil/templates/sidekiq_vigil.rb +49 -0
- data/lib/sidekiq/vigil/version.rb +7 -0
- data/lib/sidekiq/vigil.rb +7 -0
- data/lib/sidekiq_vigil/alert/cron.rb +106 -0
- data/lib/sidekiq_vigil/alert/event.rb +110 -0
- data/lib/sidekiq_vigil/alert/grouping.rb +15 -0
- data/lib/sidekiq_vigil/alert/manager.rb +227 -0
- data/lib/sidekiq_vigil/alert/mute.rb +69 -0
- data/lib/sidekiq_vigil/alert/state.rb +51 -0
- data/lib/sidekiq_vigil/check/base.rb +52 -0
- data/lib/sidekiq_vigil/check/failure_rate.rb +45 -0
- data/lib/sidekiq_vigil/check/memory.rb +41 -0
- data/lib/sidekiq_vigil/check/process_alive.rb +64 -0
- data/lib/sidekiq_vigil/check/queue_latency.rb +21 -0
- data/lib/sidekiq_vigil/check/queue_size.rb +20 -0
- data/lib/sidekiq_vigil/check/redis_health.rb +55 -0
- data/lib/sidekiq_vigil/check/registry.rb +23 -0
- data/lib/sidekiq_vigil/check/scheduled_backlog.rb +21 -0
- data/lib/sidekiq_vigil/check/set_size.rb +76 -0
- data/lib/sidekiq_vigil/check/stuck_jobs.rb +44 -0
- data/lib/sidekiq_vigil/check/throughput_anomaly.rb +69 -0
- data/lib/sidekiq_vigil/check/utilization.rb +61 -0
- data/lib/sidekiq_vigil/checker.rb +149 -0
- data/lib/sidekiq_vigil/cli.rb +205 -0
- data/lib/sidekiq_vigil/collector.rb +74 -0
- data/lib/sidekiq_vigil/config.rb +267 -0
- data/lib/sidekiq_vigil/health_app.rb +64 -0
- data/lib/sidekiq_vigil/leader_election.rb +54 -0
- data/lib/sidekiq_vigil/middleware/server.rb +37 -0
- data/lib/sidekiq_vigil/notifier/base.rb +22 -0
- data/lib/sidekiq_vigil/notifier/http_transport.rb +32 -0
- data/lib/sidekiq_vigil/notifier/log.rb +14 -0
- data/lib/sidekiq_vigil/notifier/manager.rb +45 -0
- data/lib/sidekiq_vigil/notifier/slack.rb +192 -0
- data/lib/sidekiq_vigil/notifier/webhook.rb +25 -0
- data/lib/sidekiq_vigil/reporter.rb +103 -0
- data/lib/sidekiq_vigil/result.rb +43 -0
- data/lib/sidekiq_vigil/runtime.rb +44 -0
- data/lib/sidekiq_vigil/sidekiq_api.rb +40 -0
- data/lib/sidekiq_vigil/storage.rb +167 -0
- data/lib/sidekiq_vigil/timezone.rb +39 -0
- data/lib/sidekiq_vigil/version.rb +5 -0
- data/lib/sidekiq_vigil.rb +128 -0
- metadata +111 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Alert
|
|
5
|
+
class Event
|
|
6
|
+
attr_reader :result, :transition, :state, :history, :timestamp
|
|
7
|
+
|
|
8
|
+
def initialize(result:, transition:, state:, history:, timestamp:)
|
|
9
|
+
@result = result
|
|
10
|
+
@transition = transition.to_sym
|
|
11
|
+
@state = state
|
|
12
|
+
@history = history
|
|
13
|
+
@timestamp = timestamp
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def severity
|
|
17
|
+
result.severity
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def alert_id
|
|
21
|
+
result.alert_id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
schema_version: "1.0",
|
|
27
|
+
event: transition,
|
|
28
|
+
alert_id:,
|
|
29
|
+
timestamp: timestamp.utc.iso8601,
|
|
30
|
+
alert: result.to_h,
|
|
31
|
+
state: state.to_h,
|
|
32
|
+
history:
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class DigestEvent
|
|
38
|
+
attr_reader :events, :timestamp
|
|
39
|
+
|
|
40
|
+
def initialize(events:, timestamp:, limit: 5)
|
|
41
|
+
@events = events
|
|
42
|
+
@timestamp = timestamp
|
|
43
|
+
@limit = limit
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def transition
|
|
47
|
+
:digest
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def severity
|
|
51
|
+
severities.include?(:critical) || severities.include?(:error) ? :critical : :warn
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def alert_id
|
|
55
|
+
"digest"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def result
|
|
59
|
+
@result ||= Result.new(
|
|
60
|
+
check_name: "digest",
|
|
61
|
+
severity:,
|
|
62
|
+
value: events.length,
|
|
63
|
+
message: "#{events.length} alerts in this cycle",
|
|
64
|
+
metadata: { counts: counts, top: top }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def history
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def state
|
|
73
|
+
State.new(status: "firing", severity: severity.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
schema_version: "1.0",
|
|
79
|
+
event: "digest",
|
|
80
|
+
alert_id:,
|
|
81
|
+
timestamp: timestamp.utc.iso8601,
|
|
82
|
+
counts:,
|
|
83
|
+
alerts: top
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
attr_reader :limit
|
|
90
|
+
|
|
91
|
+
def severities
|
|
92
|
+
events.map(&:severity)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def counts
|
|
96
|
+
events.group_by(&:severity).transform_values(&:length)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def top
|
|
100
|
+
events.sort_by { |event| severity_rank(event.severity) }.first(limit).map do |event|
|
|
101
|
+
event.result.to_h
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def severity_rank(severity)
|
|
106
|
+
{ error: 0, critical: 1, warn: 2, ok: 3 }.fetch(severity, 4)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Alert
|
|
5
|
+
module Grouping
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def apply(events, threshold:, timestamp:, limit: 5)
|
|
9
|
+
return events if events.length <= threshold
|
|
10
|
+
|
|
11
|
+
[DigestEvent.new(events:, timestamp:, limit:)]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SidekiqVigil
|
|
7
|
+
module Alert
|
|
8
|
+
class Manager
|
|
9
|
+
HISTORY_TTL = 24 * 60 * 60
|
|
10
|
+
|
|
11
|
+
def initialize(storage:, config:, mute:, clock: -> { Time.now })
|
|
12
|
+
@storage = storage
|
|
13
|
+
@config = config
|
|
14
|
+
@mute = mute
|
|
15
|
+
@clock = clock
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def process(results)
|
|
19
|
+
persisted = storage.hash_get_all("alerts")
|
|
20
|
+
active_ids = results.map(&:alert_id)
|
|
21
|
+
muted = mute.active?
|
|
22
|
+
events = results.filter_map { |result| process_result(result, persisted, muted) }
|
|
23
|
+
prune(persisted.keys - active_ids)
|
|
24
|
+
Grouping.apply(events, threshold: config.group_threshold, timestamp: now, limit: config.group_top_n)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :storage, :config, :mute, :clock
|
|
30
|
+
|
|
31
|
+
def process_result(result, persisted, muted)
|
|
32
|
+
state = State.load(persisted.fetch(result.alert_id, "{}"))
|
|
33
|
+
refresh_flapping(state)
|
|
34
|
+
record_history(result)
|
|
35
|
+
event = transition(result, state)
|
|
36
|
+
event ||= unsuppressed_event(result, state) unless muted
|
|
37
|
+
state.suppressed = true if event && muted
|
|
38
|
+
persist(result.alert_id, state)
|
|
39
|
+
muted ? nil : event
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def transition(result, state)
|
|
43
|
+
case state.status
|
|
44
|
+
when "ok" then transition_from_ok(result, state)
|
|
45
|
+
when "pending" then transition_from_pending(result, state)
|
|
46
|
+
when "firing" then transition_from_firing(result, state)
|
|
47
|
+
when "resolved" then transition_from_resolved(result, state)
|
|
48
|
+
else reset_invalid_state(result, state)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def transition_from_ok(result, state)
|
|
53
|
+
return update_ok(state, result) if result.ok?
|
|
54
|
+
|
|
55
|
+
begin_pending(state, result)
|
|
56
|
+
fire(result, state, :firing) if state.cycles >= config.pending_cycles
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def transition_from_pending(result, state)
|
|
60
|
+
return update_ok(state, result) if result.ok?
|
|
61
|
+
|
|
62
|
+
state.cycles += 1
|
|
63
|
+
update_observation(state, result)
|
|
64
|
+
return if flapping_active?(state)
|
|
65
|
+
|
|
66
|
+
fire(result, state, :firing) if state.cycles >= config.pending_cycles
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def transition_from_firing(result, state)
|
|
70
|
+
if result.ok?
|
|
71
|
+
previous_severity = state.severity
|
|
72
|
+
change_status(state, "resolved")
|
|
73
|
+
state.suppressed = false
|
|
74
|
+
state.severity = previous_severity
|
|
75
|
+
return build_event(result, :resolved, state) if config.resolve_notice
|
|
76
|
+
|
|
77
|
+
return nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
state.cycles += 1
|
|
81
|
+
previous_severity = state.severity
|
|
82
|
+
effective = escalated_result(result, state)
|
|
83
|
+
update_observation(state, effective)
|
|
84
|
+
return fire(effective, state, :escalated) if effective.severity.to_s != previous_severity
|
|
85
|
+
return unless cooldown_elapsed?(state)
|
|
86
|
+
|
|
87
|
+
fire(effective, state, :still_firing)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transition_from_resolved(result, state)
|
|
91
|
+
return update_ok(state, result) if result.ok?
|
|
92
|
+
|
|
93
|
+
begin_pending(state, result)
|
|
94
|
+
if flapping?(state)
|
|
95
|
+
state.flapping_until = now.to_f + config.flap_window
|
|
96
|
+
return if state.flap_notified
|
|
97
|
+
|
|
98
|
+
state.flap_notified = true
|
|
99
|
+
return build_event(result, :flapping, state)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
fire(result, state, :firing) if state.cycles >= config.pending_cycles
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def begin_pending(state, result)
|
|
106
|
+
change_status(state, "pending")
|
|
107
|
+
state.cycles = 1
|
|
108
|
+
state.first_seen_at = now.to_f
|
|
109
|
+
update_observation(state, result)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fire(result, state, transition)
|
|
113
|
+
change_status(state, "firing")
|
|
114
|
+
state.last_notified_at = now.to_f
|
|
115
|
+
state.suppressed = false
|
|
116
|
+
state.flapping_until = nil
|
|
117
|
+
build_event(result, transition, state)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def update_ok(state, result)
|
|
121
|
+
change_status(state, "ok")
|
|
122
|
+
state.cycles = 0
|
|
123
|
+
state.first_seen_at = nil
|
|
124
|
+
state.severity = "ok"
|
|
125
|
+
state.suppressed = false
|
|
126
|
+
update_observation(state, result)
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def update_observation(state, result)
|
|
131
|
+
state.severity = result.severity.to_s
|
|
132
|
+
state.value = result.value
|
|
133
|
+
state.threshold = result.threshold
|
|
134
|
+
state.message = result.message
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def escalated_result(result, state)
|
|
138
|
+
threshold = config.escalate_after
|
|
139
|
+
return result unless threshold && result.severity == :warn && state.cycles >= threshold
|
|
140
|
+
|
|
141
|
+
Result.new(
|
|
142
|
+
**result.to_h,
|
|
143
|
+
severity: :critical,
|
|
144
|
+
metadata: result.metadata.merge(escalated: true)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def cooldown_elapsed?(state)
|
|
149
|
+
now.to_f - state.last_notified_at.to_f >= config.cooldown
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def unsuppressed_event(result, state)
|
|
153
|
+
return unless state.firing? && state.suppressed
|
|
154
|
+
|
|
155
|
+
state.suppressed = false
|
|
156
|
+
fire(result, state, :unmuted)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_event(result, transition, state)
|
|
160
|
+
Event.new(
|
|
161
|
+
result:,
|
|
162
|
+
transition:,
|
|
163
|
+
state:,
|
|
164
|
+
history: storage.list_range("history:#{result.alert_id}").map { |item| JSON.parse(item) },
|
|
165
|
+
timestamp: now
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def record_history(result)
|
|
170
|
+
point = JSON.generate(timestamp: now.to_f, value: result.value, severity: result.severity)
|
|
171
|
+
storage.list_push("history:#{result.alert_id}", point, ttl: HISTORY_TTL)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def persist(alert_id, state)
|
|
175
|
+
storage.managed_hash_write("alerts", alert_id, state.dump)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def prune(alert_ids)
|
|
179
|
+
storage.hash_delete("alerts", *alert_ids)
|
|
180
|
+
alert_ids.each { |alert_id| storage.delete("history:#{alert_id}") }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def reset_invalid_state(result, state)
|
|
184
|
+
state.status = "ok"
|
|
185
|
+
transition_from_ok(result, state)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def change_status(state, status)
|
|
189
|
+
return if state.status == status
|
|
190
|
+
|
|
191
|
+
state.status = status
|
|
192
|
+
state.last_transition_at = now.to_f
|
|
193
|
+
state.transition_timestamps << now.to_f
|
|
194
|
+
prune_transition_timestamps(state)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def flapping?(state)
|
|
198
|
+
return false unless config.flap_window.positive? && config.flap_threshold.positive?
|
|
199
|
+
|
|
200
|
+
prune_transition_timestamps(state)
|
|
201
|
+
state.transition_timestamps.length >= config.flap_threshold
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def flapping_active?(state)
|
|
205
|
+
state.flapping_until && now.to_f < state.flapping_until
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def refresh_flapping(state)
|
|
209
|
+
prune_transition_timestamps(state)
|
|
210
|
+
return if flapping_active?(state)
|
|
211
|
+
return if state.transition_timestamps.length >= config.flap_threshold
|
|
212
|
+
|
|
213
|
+
state.flapping_until = nil
|
|
214
|
+
state.flap_notified = false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def prune_transition_timestamps(state)
|
|
218
|
+
cutoff = now.to_f - config.flap_window
|
|
219
|
+
state.transition_timestamps.select! { |timestamp| timestamp.to_f >= cutoff }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def now
|
|
223
|
+
clock.call
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SidekiqVigil
|
|
6
|
+
module Alert
|
|
7
|
+
class Mute
|
|
8
|
+
def initialize(storage:, schedules: [], clock: -> { Time.now }, timezone: "UTC")
|
|
9
|
+
@storage = storage
|
|
10
|
+
@schedules = schedules
|
|
11
|
+
@clock = clock
|
|
12
|
+
@timezone = timezone
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def active?
|
|
16
|
+
!reason.nil?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reason
|
|
20
|
+
dynamic_reason || scheduled_reason
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mute(duration, reason: nil)
|
|
24
|
+
payload = JSON.generate(reason: reason || "manual mute", until: clock.call.to_f + duration)
|
|
25
|
+
storage.set("mute", payload, ttl: duration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unmute
|
|
29
|
+
storage.delete("mute")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :storage, :schedules, :clock, :timezone
|
|
35
|
+
|
|
36
|
+
def dynamic_reason
|
|
37
|
+
payload = storage.get("mute")
|
|
38
|
+
return unless payload
|
|
39
|
+
|
|
40
|
+
JSON.parse(payload).fetch("reason")
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
"manual mute"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scheduled_reason
|
|
46
|
+
schedules.each do |schedule|
|
|
47
|
+
duration = schedule.fetch(:duration)
|
|
48
|
+
matched = active_schedule?(schedule.fetch(:cron), duration)
|
|
49
|
+
return schedule.fetch(:reason, "scheduled maintenance") if matched
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def active_schedule?(expression, duration)
|
|
55
|
+
current = clock.call
|
|
56
|
+
current_minute = Time.at((current.to_f / 60).floor * 60)
|
|
57
|
+
cron = Cron.new(expression)
|
|
58
|
+
minute_count = (duration / 60.0).ceil
|
|
59
|
+
|
|
60
|
+
(0..minute_count).any? do |offset|
|
|
61
|
+
start_time = current_minute - (offset * 60)
|
|
62
|
+
next false unless current.to_f < start_time.to_f + duration
|
|
63
|
+
|
|
64
|
+
cron.match?(Timezone.local_time(start_time, timezone))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SidekiqVigil
|
|
6
|
+
module Alert
|
|
7
|
+
class State
|
|
8
|
+
ATTRIBUTES = %i[
|
|
9
|
+
status cycles first_seen_at last_notified_at last_transition_at severity value threshold message suppressed
|
|
10
|
+
flap_notified flapping_until transition_timestamps
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
attr_accessor(*ATTRIBUTES)
|
|
14
|
+
|
|
15
|
+
def initialize(attributes = {})
|
|
16
|
+
attributes = attributes.transform_keys(&:to_sym)
|
|
17
|
+
@status = attributes.fetch(:status, "ok")
|
|
18
|
+
@cycles = attributes.fetch(:cycles, 0)
|
|
19
|
+
@first_seen_at = attributes[:first_seen_at]
|
|
20
|
+
@last_notified_at = attributes[:last_notified_at]
|
|
21
|
+
@last_transition_at = attributes[:last_transition_at]
|
|
22
|
+
@severity = attributes.fetch(:severity, "ok")
|
|
23
|
+
@value = attributes[:value]
|
|
24
|
+
@threshold = attributes[:threshold]
|
|
25
|
+
@message = attributes[:message]
|
|
26
|
+
@suppressed = attributes.fetch(:suppressed, false)
|
|
27
|
+
@flap_notified = attributes.fetch(:flap_notified, false)
|
|
28
|
+
@flapping_until = attributes[:flapping_until]
|
|
29
|
+
@transition_timestamps = attributes.fetch(:transition_timestamps, [])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.load(json)
|
|
33
|
+
new(JSON.parse(json))
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dump
|
|
43
|
+
JSON.generate(to_h)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def firing?
|
|
47
|
+
status == "firing"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Check
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :storage, :options, :clock, :logger, :api
|
|
7
|
+
|
|
8
|
+
def initialize(storage:, options: {}, clock: -> { Time.now }, logger: SidekiqVigil.logger, api: SidekiqApi.new)
|
|
9
|
+
@storage = storage
|
|
10
|
+
@options = options
|
|
11
|
+
@clock = clock
|
|
12
|
+
@logger = logger
|
|
13
|
+
@api = api
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
Array(call)
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
logger.error("[sidekiq-vigil] #{check_name} failed: #{e.class}: #{e.message}")
|
|
20
|
+
[Result.new(
|
|
21
|
+
check_name:,
|
|
22
|
+
severity: :error,
|
|
23
|
+
message: "#{e.class}: #{e.message}",
|
|
24
|
+
metadata: { exception_class: e.class.name }
|
|
25
|
+
)]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check_name
|
|
33
|
+
class_name = self.class.name || "custom_check"
|
|
34
|
+
class_name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def threshold_result(target:, value:, warn:, critical:, message: nil, metadata: {})
|
|
40
|
+
severity, threshold = severity_and_threshold(value, warn, critical)
|
|
41
|
+
Result.new(check_name:, target:, severity:, value:, threshold:, message:, metadata:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def severity_and_threshold(value, warn, critical)
|
|
45
|
+
return [:critical, critical] if critical && value >= critical
|
|
46
|
+
return [:warn, warn] if warn && value >= warn
|
|
47
|
+
|
|
48
|
+
[:ok, warn]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Check
|
|
5
|
+
class FailureRate < Base
|
|
6
|
+
def call
|
|
7
|
+
processed, failed = counts
|
|
8
|
+
rate = processed.zero? ? 0.0 : failed.fdiv(processed)
|
|
9
|
+
return insufficient_samples(processed, rate) if processed < options.fetch(:min_samples, 20)
|
|
10
|
+
|
|
11
|
+
threshold_result(
|
|
12
|
+
target: "global",
|
|
13
|
+
value: rate,
|
|
14
|
+
warn: options.fetch(:warn, 0.05),
|
|
15
|
+
critical: options.fetch(:critical, 0.20),
|
|
16
|
+
message: format("%<rate>.1f%% failures (%<failed>d/%<processed>d attempts)", rate: rate * 100, failed:, processed:),
|
|
17
|
+
metadata: { processed:, failed: }
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def counts
|
|
24
|
+
minute_count = (options.fetch(:window, 300) / 60.0).ceil
|
|
25
|
+
minute_count.times.each_with_object([0, 0]) do |offset, totals|
|
|
26
|
+
key = "stats:#{(clock.call - (offset * 60)).utc.strftime('%Y%m%d%H%M')}"
|
|
27
|
+
stats = storage.hash_get_all(key)
|
|
28
|
+
totals[0] += stats.fetch("processed", 0).to_i
|
|
29
|
+
totals[1] += stats.fetch("failed", 0).to_i
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def insufficient_samples(processed, rate)
|
|
34
|
+
Result.new(
|
|
35
|
+
check_name:,
|
|
36
|
+
severity: :ok,
|
|
37
|
+
value: rate,
|
|
38
|
+
threshold: options.fetch(:warn, 0.05),
|
|
39
|
+
message: "insufficient samples (#{processed}/#{options.fetch(:min_samples, 20)})",
|
|
40
|
+
metadata: { processed:, insufficient_samples: true }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Check
|
|
5
|
+
class Memory < Base
|
|
6
|
+
def call
|
|
7
|
+
return [skipped_result] if RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
8
|
+
|
|
9
|
+
keys = storage.scan("mem:*")
|
|
10
|
+
return [empty_result] if keys.empty?
|
|
11
|
+
|
|
12
|
+
keys.map do |key|
|
|
13
|
+
process_id = key.delete_prefix(storage.prefix).delete_prefix("mem:")
|
|
14
|
+
value_mb = storage.get("mem:#{process_id}").to_f / 1024
|
|
15
|
+
threshold_result(
|
|
16
|
+
target: process_id,
|
|
17
|
+
value: value_mb,
|
|
18
|
+
warn: options.fetch(:warn_mb, 1_500),
|
|
19
|
+
critical: options.fetch(:critical_mb, 2_500),
|
|
20
|
+
message: format("%.1f MiB RSS", value_mb)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def skipped_result
|
|
28
|
+
Result.new(
|
|
29
|
+
check_name:,
|
|
30
|
+
severity: :ok,
|
|
31
|
+
message: "memory collection is unsupported on Windows",
|
|
32
|
+
metadata: { skipped: true }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty_result
|
|
37
|
+
Result.new(check_name:, severity: :ok, value: 0, message: "no process RSS reports")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Check
|
|
5
|
+
class ProcessAlive < Base
|
|
6
|
+
def call
|
|
7
|
+
processes = api.processes
|
|
8
|
+
minimum = options.fetch(:min_processes, 1)
|
|
9
|
+
active = processes.count { |process| active?(process) }
|
|
10
|
+
severity = active < minimum ? :critical : :ok
|
|
11
|
+
|
|
12
|
+
Result.new(
|
|
13
|
+
check_name:,
|
|
14
|
+
severity:,
|
|
15
|
+
value: active,
|
|
16
|
+
threshold: minimum,
|
|
17
|
+
message: "#{active}/#{minimum} required processes alive",
|
|
18
|
+
metadata: { total: processes.size }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def active?(process)
|
|
25
|
+
quiet_since = process_value(process, "quiet_since")
|
|
26
|
+
return clock.call.to_f - quiet_since.to_f < quiet_threshold if quiet_since
|
|
27
|
+
|
|
28
|
+
unless stopping?(process)
|
|
29
|
+
clear_quiet_state(process)
|
|
30
|
+
return true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
state_key = "check_state:quiet:#{process_identity(process)}"
|
|
34
|
+
observed_at = storage.get(state_key)
|
|
35
|
+
storage.set(state_key, observed_at || clock.call.to_f, ttl: quiet_threshold + 3_600)
|
|
36
|
+
observed_at.nil? || clock.call.to_f - observed_at.to_f < quiet_threshold
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def process_value(process, key)
|
|
40
|
+
process[key] || process[key.to_sym]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stopping?(process)
|
|
44
|
+
return process.stopping? if process.respond_to?(:stopping?)
|
|
45
|
+
|
|
46
|
+
%w[true 1].include?(process_value(process, "quiet").to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear_quiet_state(process)
|
|
50
|
+
storage.delete("check_state:quiet:#{process_identity(process)}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def process_identity(process)
|
|
54
|
+
return process.identity if process.respond_to?(:identity)
|
|
55
|
+
|
|
56
|
+
process_value(process, "identity") || process.object_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def quiet_threshold
|
|
60
|
+
options.fetch(:quiet_threshold, 900)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Check
|
|
5
|
+
class QueueLatency < Base
|
|
6
|
+
def call
|
|
7
|
+
api.queues(options.fetch(:queues, :all)).map do |queue|
|
|
8
|
+
thresholds = options.merge(options.fetch(:per_queue, {}).fetch(queue.name, {}))
|
|
9
|
+
latency = queue.latency.to_f
|
|
10
|
+
threshold_result(
|
|
11
|
+
target: queue.name,
|
|
12
|
+
value: latency,
|
|
13
|
+
warn: thresholds.fetch(:warn, 60),
|
|
14
|
+
critical: thresholds.fetch(:critical, 300),
|
|
15
|
+
message: "oldest job has waited #{latency.round(2)}s"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|