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,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SidekiqVigil
|
|
7
|
+
class CLI
|
|
8
|
+
EXIT_OK = 0
|
|
9
|
+
EXIT_ALERT = 2
|
|
10
|
+
EXIT_UNAVAILABLE = 3
|
|
11
|
+
|
|
12
|
+
def initialize(config: SidekiqVigil.config, storage: nil, out: $stdout, err: $stderr, checker_factory: nil)
|
|
13
|
+
@config = config
|
|
14
|
+
@resolved_storage = storage
|
|
15
|
+
@out = out
|
|
16
|
+
@err = err
|
|
17
|
+
@checker_factory = checker_factory || -> { Checker.new(storage: resolved_storage, config:) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(arguments)
|
|
21
|
+
args = arguments.dup
|
|
22
|
+
load_config(extract_config_path(args))
|
|
23
|
+
configure_sidekiq_api
|
|
24
|
+
command = args.shift || "run"
|
|
25
|
+
handler = command_handlers(args)[command]
|
|
26
|
+
return unknown_command(command) unless handler
|
|
27
|
+
|
|
28
|
+
handler.call
|
|
29
|
+
rescue OptionParser::ParseError, ConfigError, ArgumentError => e
|
|
30
|
+
err.puts(e.message)
|
|
31
|
+
EXIT_UNAVAILABLE
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :config, :out, :err, :checker_factory
|
|
37
|
+
|
|
38
|
+
def resolved_storage
|
|
39
|
+
@resolved_storage ||= SidekiqVigil.build_storage(config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def command_handlers(args)
|
|
43
|
+
{
|
|
44
|
+
"run" => -> { run_forever(args) },
|
|
45
|
+
"check" => -> { run_check(args) },
|
|
46
|
+
"status" => -> { print_status(args) },
|
|
47
|
+
"test-notify" => -> { test_notify(args) },
|
|
48
|
+
"mute" => -> { mute(args) },
|
|
49
|
+
"unmute" => -> { unmute(args) }
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_config_path(args)
|
|
54
|
+
index = args.index("--config") || args.index("-c")
|
|
55
|
+
return unless index
|
|
56
|
+
|
|
57
|
+
option = args.delete_at(index)
|
|
58
|
+
path = args.delete_at(index)
|
|
59
|
+
raise OptionParser::MissingArgument, option unless path
|
|
60
|
+
|
|
61
|
+
path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_config(path)
|
|
65
|
+
return unless path
|
|
66
|
+
raise ConfigError, "config file not found: #{path}" unless File.file?(path)
|
|
67
|
+
|
|
68
|
+
Kernel.load(File.expand_path(path))
|
|
69
|
+
config.validate!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def configure_sidekiq_api
|
|
73
|
+
return unless config.redis
|
|
74
|
+
|
|
75
|
+
options = config.redis.compact.reject { |key, _value| %i[pool_size pool_timeout].include?(key.to_sym) }
|
|
76
|
+
Sidekiq.configure_client { |sidekiq| sidekiq.redis = options }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run_forever(_args = [])
|
|
80
|
+
checker = checker_factory.call
|
|
81
|
+
reader, writer = IO.pipe
|
|
82
|
+
previous = install_signal_handlers(writer)
|
|
83
|
+
checker.start
|
|
84
|
+
reader.read(1)
|
|
85
|
+
checker.stop
|
|
86
|
+
EXIT_OK
|
|
87
|
+
ensure
|
|
88
|
+
restore_signal_handlers(previous) if previous
|
|
89
|
+
reader&.close
|
|
90
|
+
writer&.close
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def install_signal_handlers(writer)
|
|
94
|
+
%w[INT TERM].to_h do |signal|
|
|
95
|
+
previous = Signal.trap(signal) { writer.write_nonblock(signal, exception: false) }
|
|
96
|
+
[signal, previous]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def restore_signal_handlers(previous)
|
|
101
|
+
previous.each { |signal, handler| Signal.trap(signal, handler) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_check(args)
|
|
105
|
+
parser = OptionParser.new
|
|
106
|
+
once = false
|
|
107
|
+
parser.on("--once") { once = true }
|
|
108
|
+
parser.parse!(args)
|
|
109
|
+
raise OptionParser::InvalidOption, "check requires --once" unless once
|
|
110
|
+
|
|
111
|
+
results = checker_factory.call.run_once
|
|
112
|
+
return EXIT_UNAVAILABLE if results == false
|
|
113
|
+
|
|
114
|
+
print_results(results)
|
|
115
|
+
results.all?(&:ok?) ? EXIT_OK : EXIT_ALERT
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def print_results(results)
|
|
119
|
+
results.each do |result|
|
|
120
|
+
out.puts([result.severity.to_s.upcase, result.check_name, result.target, result.message].compact.join("\t"))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def print_status(_args = [])
|
|
125
|
+
raw = resolved_storage.get("snapshot")
|
|
126
|
+
return unavailable("No snapshot available") unless raw
|
|
127
|
+
|
|
128
|
+
snapshot = JSON.parse(raw)
|
|
129
|
+
out.puts("Snapshot: #{snapshot.fetch('timestamp')}")
|
|
130
|
+
snapshot.fetch("results").each do |result|
|
|
131
|
+
alert_id = "#{result.fetch('check_name')}:#{result.fetch('target')}"
|
|
132
|
+
alert_status = snapshot.fetch("alerts", {}).dig(alert_id, "status") || "ok"
|
|
133
|
+
out.puts(
|
|
134
|
+
[result.fetch("severity").upcase, result.fetch("check_name"), result.fetch("target"), alert_status.upcase].join("\t")
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
EXIT_OK
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_notify(_args = [])
|
|
141
|
+
result = Result.new(
|
|
142
|
+
check_name: "test_notification",
|
|
143
|
+
severity: :warn,
|
|
144
|
+
message: "Sidekiq Vigil test notification"
|
|
145
|
+
)
|
|
146
|
+
state = Alert::State.new(status: "firing", severity: "warn")
|
|
147
|
+
event = Alert::Event.new(result:, transition: :firing, state:, history: [], timestamp: Time.now)
|
|
148
|
+
manager = Notifier::Manager.new(notifiers: Notifier::Factory.build(config))
|
|
149
|
+
manager.notify([event])
|
|
150
|
+
out.puts("Test notification sent to #{config.runnable_notifiers.length} notifier(s)")
|
|
151
|
+
EXIT_OK
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def mute(args)
|
|
155
|
+
reason = nil
|
|
156
|
+
parser = OptionParser.new
|
|
157
|
+
parser.on("--reason REASON") { |value| reason = value }
|
|
158
|
+
parser.parse!(args)
|
|
159
|
+
duration = parse_duration(args.shift)
|
|
160
|
+
raise OptionParser::InvalidArgument, "mute duration is required" unless duration
|
|
161
|
+
|
|
162
|
+
alert_mute.mute(duration, reason:)
|
|
163
|
+
out.puts("Muted for #{duration} seconds")
|
|
164
|
+
EXIT_OK
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def unmute(_args = [])
|
|
168
|
+
alert_mute.unmute
|
|
169
|
+
out.puts("Mute cleared")
|
|
170
|
+
EXIT_OK
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def alert_mute
|
|
174
|
+
Alert::Mute.new(
|
|
175
|
+
storage: resolved_storage,
|
|
176
|
+
schedules: config.alerting.mutes,
|
|
177
|
+
timezone: config.timezone
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_duration(value)
|
|
182
|
+
return unless value
|
|
183
|
+
|
|
184
|
+
match = value.match(/\A(\d+)([smhd]?)\z/)
|
|
185
|
+
raise OptionParser::InvalidArgument, "invalid duration: #{value}" unless match
|
|
186
|
+
|
|
187
|
+
Integer(match[1]) * { "" => 1, "s" => 1, "m" => 60, "h" => 3_600, "d" => 86_400 }.fetch(match[2])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def unavailable(message)
|
|
191
|
+
err.puts(message)
|
|
192
|
+
EXIT_UNAVAILABLE
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def unknown_command(command)
|
|
196
|
+
err.puts("Unknown command: #{command}")
|
|
197
|
+
err.puts(usage)
|
|
198
|
+
EXIT_UNAVAILABLE
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def usage
|
|
202
|
+
"Usage: vigil [--config FILE] [run|check --once|status|test-notify|mute DURATION|unmute]"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
class Collector
|
|
5
|
+
Snapshot = Data.define(:stats, :executions)
|
|
6
|
+
|
|
7
|
+
def initialize(clock: -> { Time.now })
|
|
8
|
+
@clock = clock
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@stats = empty_stats
|
|
11
|
+
@executions = empty_executions
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(worker:, queue:, duration:, failed:)
|
|
15
|
+
minute = clock.call.utc.strftime("%Y%m%d%H%M")
|
|
16
|
+
worker_name = worker.respond_to?(:name) ? worker.name : worker.class.name
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
update_stats(minute, worker_name, failed)
|
|
19
|
+
update_execution(queue.to_s, duration)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def drain
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
snapshot = Snapshot.new(stats: @stats, executions: @executions)
|
|
26
|
+
@stats = empty_stats
|
|
27
|
+
@executions = empty_executions
|
|
28
|
+
snapshot
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merge(snapshot)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
snapshot.stats.each do |minute, fields|
|
|
35
|
+
fields.each { |field, value| @stats[minute][field] += value }
|
|
36
|
+
end
|
|
37
|
+
snapshot.executions.each do |queue, values|
|
|
38
|
+
current = @executions[queue]
|
|
39
|
+
current[:count] += values[:count]
|
|
40
|
+
current[:sum] += values[:sum]
|
|
41
|
+
current[:max] = [current[:max], values[:max]].max
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :clock
|
|
49
|
+
|
|
50
|
+
def empty_stats
|
|
51
|
+
Hash.new { |hash, key| hash[key] = Hash.new(0) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty_executions
|
|
55
|
+
Hash.new { |hash, key| hash[key] = { count: 0, sum: 0.0, max: 0.0 } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def update_stats(minute, worker_name, failed)
|
|
59
|
+
@stats[minute]["processed"] += 1
|
|
60
|
+
@stats[minute]["processed:#{worker_name}"] += 1
|
|
61
|
+
return unless failed
|
|
62
|
+
|
|
63
|
+
@stats[minute]["failed"] += 1
|
|
64
|
+
@stats[minute]["failed:#{worker_name}"] += 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_execution(queue, duration)
|
|
68
|
+
execution = @executions[queue]
|
|
69
|
+
execution[:count] += 1
|
|
70
|
+
execution[:sum] += duration
|
|
71
|
+
execution[:max] = [execution[:max], duration].max
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module SidekiqVigil
|
|
7
|
+
class ConfigError < Error; end
|
|
8
|
+
|
|
9
|
+
class Config
|
|
10
|
+
CheckDefinition = Data.define(:name, :klass, :options)
|
|
11
|
+
class NotifierDefinition
|
|
12
|
+
SECRET_KEYS = %i[webhook_url routes bot_token url].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :name, :klass, :options
|
|
15
|
+
|
|
16
|
+
def initialize(name:, klass:, options:)
|
|
17
|
+
@name = name
|
|
18
|
+
@klass = klass
|
|
19
|
+
@options = options
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
safe_options = options.transform_values.with_index do |value, _index|
|
|
24
|
+
value.is_a?(Hash) ? value.transform_values { "[FILTERED]" } : value
|
|
25
|
+
end
|
|
26
|
+
SECRET_KEYS.each { |key| safe_options[key] = "[FILTERED]" if safe_options.key?(key) }
|
|
27
|
+
"#<#{self.class} name=#{name.inspect} options=#{safe_options.inspect}>"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
BUILT_IN_CHECKS = %i[
|
|
32
|
+
queue_latency queue_size retry_set dead_set process_alive utilization failure_rate stuck_jobs memory redis_health
|
|
33
|
+
scheduled_backlog throughput_anomaly
|
|
34
|
+
].freeze
|
|
35
|
+
DEFAULT_CHECKS = (BUILT_IN_CHECKS - [:throughput_anomaly]).freeze
|
|
36
|
+
SLACK_MENTION = /\A(?:<@U[A-Z0-9]+>|<!subteam\^S[A-Z0-9]+>|<!here>)\z/
|
|
37
|
+
|
|
38
|
+
attr_accessor :interval, :flush_interval, :key_prefix, :timezone, :redis
|
|
39
|
+
attr_reader :checks, :notifiers
|
|
40
|
+
|
|
41
|
+
def initialize(environment: nil)
|
|
42
|
+
@environment = environment || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
|
|
43
|
+
@enabled = true
|
|
44
|
+
@enabled_explicit = false
|
|
45
|
+
@interval = 30
|
|
46
|
+
@flush_interval = 10
|
|
47
|
+
@key_prefix = "default"
|
|
48
|
+
@timezone = "UTC"
|
|
49
|
+
@redis = nil
|
|
50
|
+
@checks = DEFAULT_CHECKS.map { |name| CheckDefinition.new(name:, klass: nil, options: {}) }
|
|
51
|
+
@notifiers = []
|
|
52
|
+
@alerting = AlertingConfig.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enabled=(value)
|
|
56
|
+
@enabled_explicit = true
|
|
57
|
+
@enabled = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def enabled?
|
|
61
|
+
@enabled == true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def production?
|
|
65
|
+
environment == "production"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def external_notifications_enabled?
|
|
69
|
+
enabled? && (production? || @enabled_explicit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check(name_or_class, **options)
|
|
73
|
+
name, klass = normalize_check(name_or_class)
|
|
74
|
+
checks.reject! { |definition| definition.name == name }
|
|
75
|
+
checks << CheckDefinition.new(name:, klass:, options:)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def disable_check(name)
|
|
79
|
+
checks.reject! { |definition| definition.name == name.to_sym }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def notifier(name_or_class, **options)
|
|
83
|
+
name, klass = normalize_notifier(name_or_class)
|
|
84
|
+
notifiers << NotifierDefinition.new(name:, klass:, options:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def alerting
|
|
88
|
+
yield @alerting if block_given?
|
|
89
|
+
@alerting
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate!
|
|
93
|
+
validate_positive!(:interval, interval)
|
|
94
|
+
validate_positive!(:flush_interval, flush_interval)
|
|
95
|
+
raise ConfigError, "key_prefix must not be empty" if key_prefix.to_s.empty?
|
|
96
|
+
raise ConfigError, "key_prefix contains unsupported characters" unless key_prefix.to_s.match?(/\A[\w.-]+\z/)
|
|
97
|
+
raise ConfigError, "timezone must not be empty" if timezone.to_s.empty?
|
|
98
|
+
|
|
99
|
+
validate_redis!
|
|
100
|
+
checks.each { |definition| validate_options!(definition.options, "check #{definition.name}") }
|
|
101
|
+
notifiers.each { |definition| validate_notifier!(definition) }
|
|
102
|
+
alerting.validate!
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def digest
|
|
107
|
+
payload = {
|
|
108
|
+
enabled: enabled?,
|
|
109
|
+
interval: interval,
|
|
110
|
+
flush_interval: flush_interval,
|
|
111
|
+
key_prefix: key_prefix,
|
|
112
|
+
timezone: timezone,
|
|
113
|
+
checks: checks.map { |item| [item.name, item.options] },
|
|
114
|
+
notifiers: notifiers.map { |item| [item.name, item.options] },
|
|
115
|
+
alerting: alerting.to_h
|
|
116
|
+
}
|
|
117
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def runnable_notifiers
|
|
121
|
+
validate!
|
|
122
|
+
definitions = notifiers.empty? ? [NotifierDefinition.new(name: :log, klass: nil, options: {})] : notifiers
|
|
123
|
+
return definitions if external_notifications_enabled?
|
|
124
|
+
|
|
125
|
+
definitions.select { |definition| definition.name == :log }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
attr_reader :environment
|
|
131
|
+
|
|
132
|
+
def normalize_check(name_or_class)
|
|
133
|
+
if name_or_class.is_a?(Class)
|
|
134
|
+
return [name_or_class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym,
|
|
135
|
+
name_or_class]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
name = name_or_class.to_sym
|
|
139
|
+
raise ConfigError, "unknown check: #{name}" unless BUILT_IN_CHECKS.include?(name)
|
|
140
|
+
|
|
141
|
+
[name, nil]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize_notifier(name_or_class)
|
|
145
|
+
return [name_or_class.name.split("::").last.downcase.to_sym, name_or_class] if name_or_class.is_a?(Class)
|
|
146
|
+
|
|
147
|
+
name = name_or_class.to_sym
|
|
148
|
+
raise ConfigError, "unknown notifier: #{name}" unless %i[log slack webhook].include?(name)
|
|
149
|
+
|
|
150
|
+
[name, nil]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_positive!(name, value)
|
|
154
|
+
raise ConfigError, "#{name} must be positive" unless value.is_a?(Numeric) && value.positive?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_options!(options, context)
|
|
158
|
+
options.each { |key, value| validate_option!(key, value, context) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_option!(key, value, context)
|
|
162
|
+
return value.each_value { |thresholds| validate_options!(thresholds, context) } if key.to_sym == :per_queue && value.is_a?(Hash)
|
|
163
|
+
return unless numeric_threshold?(key)
|
|
164
|
+
return if value.is_a?(Numeric) && !value.negative?
|
|
165
|
+
|
|
166
|
+
raise ConfigError, "#{context} #{key} must not be negative"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def numeric_threshold?(key)
|
|
170
|
+
key.to_s.match?(/(?:warn|critical|threshold|overdue|window|samples|processes|sustained|drop_pct|baseline_days|latency_ms|memory_pct)/)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_notifier!(definition)
|
|
174
|
+
validate_options!(definition.options, "notifier #{definition.name}")
|
|
175
|
+
return unless definition.name == :slack
|
|
176
|
+
|
|
177
|
+
mentions = definition.options.fetch(:mention, {})
|
|
178
|
+
mentions.each_value do |mention|
|
|
179
|
+
next if mention.nil? || mention.match?(SLACK_MENTION)
|
|
180
|
+
|
|
181
|
+
raise ConfigError,
|
|
182
|
+
"invalid Slack mention #{mention.inspect}; use <@U…>, <!subteam^S…>, or <!here>"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_redis!
|
|
187
|
+
return unless redis
|
|
188
|
+
raise ConfigError, "redis must be a Hash" unless redis.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
pool_size = redis_option(:pool_size, 5)
|
|
191
|
+
pool_timeout = redis_option(:pool_timeout, 1)
|
|
192
|
+
raise ConfigError, "redis pool_size must be a positive integer" unless pool_size.is_a?(Integer) && pool_size.positive?
|
|
193
|
+
return if pool_timeout.is_a?(Numeric) && pool_timeout.positive?
|
|
194
|
+
|
|
195
|
+
raise ConfigError, "redis pool_timeout must be positive"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def redis_option(name, default)
|
|
199
|
+
redis.fetch(name) { redis.fetch(name.to_s, default) }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class AlertingConfig
|
|
204
|
+
attr_accessor :pending_cycles, :cooldown, :resolve_notice, :flap_window, :flap_threshold, :escalate_after,
|
|
205
|
+
:group_threshold, :group_top_n, :mutes
|
|
206
|
+
|
|
207
|
+
def initialize
|
|
208
|
+
@pending_cycles = 2
|
|
209
|
+
@cooldown = 600
|
|
210
|
+
@resolve_notice = true
|
|
211
|
+
@flap_window = 120
|
|
212
|
+
@flap_threshold = 4
|
|
213
|
+
@escalate_after = nil
|
|
214
|
+
@group_threshold = 5
|
|
215
|
+
@group_top_n = 5
|
|
216
|
+
@mutes = []
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def validate!
|
|
220
|
+
validate_numeric_fields!
|
|
221
|
+
validate_escalation!
|
|
222
|
+
mutes.each { |mute| validate_mute!(mute) }
|
|
223
|
+
self
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def to_h
|
|
227
|
+
{
|
|
228
|
+
pending_cycles: pending_cycles,
|
|
229
|
+
cooldown: cooldown,
|
|
230
|
+
resolve_notice: resolve_notice,
|
|
231
|
+
flap_window: flap_window,
|
|
232
|
+
flap_threshold: flap_threshold,
|
|
233
|
+
escalate_after: escalate_after,
|
|
234
|
+
group_threshold: group_threshold,
|
|
235
|
+
group_top_n: group_top_n,
|
|
236
|
+
mutes: mutes
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def validate_numeric_fields!
|
|
243
|
+
%i[cooldown flap_window group_threshold].each do |name|
|
|
244
|
+
value = public_send(name)
|
|
245
|
+
raise ConfigError, "#{name} must not be negative" unless value.is_a?(Numeric) && !value.negative?
|
|
246
|
+
end
|
|
247
|
+
%i[pending_cycles flap_threshold group_top_n].each do |name|
|
|
248
|
+
value = public_send(name)
|
|
249
|
+
raise ConfigError, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def validate_escalation!
|
|
254
|
+
return unless escalate_after
|
|
255
|
+
return if escalate_after.is_a?(Numeric) && !escalate_after.negative?
|
|
256
|
+
|
|
257
|
+
raise ConfigError, "escalate_after must not be negative"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_mute!(mute)
|
|
261
|
+
Alert::Cron.new(mute.fetch(:cron))
|
|
262
|
+
|
|
263
|
+
duration = mute.fetch(:duration)
|
|
264
|
+
raise ConfigError, "mute duration must be positive" unless duration.is_a?(Numeric) && duration.positive?
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SidekiqVigil
|
|
7
|
+
class HealthApp
|
|
8
|
+
JSON_HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(storage:, interval:, clock: -> { Time.now })
|
|
11
|
+
@storage = storage
|
|
12
|
+
@interval = interval
|
|
13
|
+
@clock = clock
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
return response(405, error: "method not allowed") unless env["REQUEST_METHOD"] == "GET"
|
|
18
|
+
|
|
19
|
+
case env["PATH_INFO"]
|
|
20
|
+
when "/healthz" then health
|
|
21
|
+
when "/status.json" then status
|
|
22
|
+
else response(404, error: "not found")
|
|
23
|
+
end
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
response(503, healthy: false, error: "snapshot unavailable", detail: e.class.name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :storage, :interval, :clock
|
|
31
|
+
|
|
32
|
+
def health
|
|
33
|
+
report = snapshot_report
|
|
34
|
+
response(report[:healthy] ? 200 : 503, report.slice(:healthy, :fresh, :age_seconds))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def status
|
|
38
|
+
report = snapshot_report
|
|
39
|
+
response(report[:healthy] ? 200 : 503, report)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def snapshot_report
|
|
43
|
+
raw = storage.get("snapshot")
|
|
44
|
+
return { healthy: false, fresh: false, age_seconds: nil, error: "snapshot missing" } unless raw
|
|
45
|
+
|
|
46
|
+
snapshot = JSON.parse(raw)
|
|
47
|
+
age = clock.call - Time.iso8601(snapshot.fetch("timestamp"))
|
|
48
|
+
fresh = age <= interval * 2
|
|
49
|
+
alerts = snapshot.fetch("alerts", {})
|
|
50
|
+
results = snapshot.fetch("results").map { |result| with_alert_state(result, alerts) }
|
|
51
|
+
checks_ok = results.all? { |result| result.fetch("severity") == "ok" }
|
|
52
|
+
snapshot.merge("results" => results, "alerts" => alerts, healthy: checks_ok && fresh, fresh:, age_seconds: age.round(3))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with_alert_state(result, alerts)
|
|
56
|
+
alert_id = "#{result.fetch('check_name')}:#{result.fetch('target')}"
|
|
57
|
+
result.merge("alert_state" => alerts.fetch(alert_id, { "status" => "ok" }))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def response(status, body)
|
|
61
|
+
[status, JSON_HEADERS, [JSON.generate(body)]]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module SidekiqVigil
|
|
6
|
+
class LeaderElection
|
|
7
|
+
EXTEND_SCRIPT = <<~LUA
|
|
8
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
9
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
10
|
+
end
|
|
11
|
+
return 0
|
|
12
|
+
LUA
|
|
13
|
+
RELEASE_SCRIPT = <<~LUA
|
|
14
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
15
|
+
return redis.call("del", KEYS[1])
|
|
16
|
+
end
|
|
17
|
+
return 0
|
|
18
|
+
LUA
|
|
19
|
+
|
|
20
|
+
attr_reader :token
|
|
21
|
+
|
|
22
|
+
def initialize(storage:, ttl:, token: SecureRandom.uuid)
|
|
23
|
+
@storage = storage
|
|
24
|
+
@ttl_ms = (ttl * 1000).to_i
|
|
25
|
+
@token = token
|
|
26
|
+
@leader = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def acquire
|
|
30
|
+
@leader = storage.acquire_lock("leader", token, ttl_ms: ttl_ms)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extend
|
|
34
|
+
@leader = storage.eval(EXTEND_SCRIPT, keys: ["leader"], argv: [token, ttl_ms]).to_i == 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def leader?
|
|
38
|
+
@leader
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def release
|
|
42
|
+
released = storage.eval(RELEASE_SCRIPT, keys: ["leader"], argv: [token]).to_i == 1
|
|
43
|
+
@leader = false
|
|
44
|
+
released
|
|
45
|
+
rescue StandardError
|
|
46
|
+
@leader = false
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :storage, :ttl_ms
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SidekiqVigil
|
|
4
|
+
module Middleware
|
|
5
|
+
class Server
|
|
6
|
+
include Sidekiq::ServerMiddleware if defined?(Sidekiq::ServerMiddleware)
|
|
7
|
+
|
|
8
|
+
def initialize(collector: SidekiqVigil.collector, monotonic_clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
9
|
+
@collector = collector
|
|
10
|
+
@monotonic_clock = monotonic_clock
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(worker, job, queue)
|
|
14
|
+
started_at = monotonic_clock.call
|
|
15
|
+
failed = false
|
|
16
|
+
begin
|
|
17
|
+
yield
|
|
18
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
19
|
+
failed = true
|
|
20
|
+
raise
|
|
21
|
+
ensure
|
|
22
|
+
record(worker, job, queue, started_at, failed)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :collector, :monotonic_clock
|
|
29
|
+
|
|
30
|
+
def record(worker, _job, queue, started_at, failed)
|
|
31
|
+
collector.record(worker:, queue:, duration: monotonic_clock.call - started_at, failed:)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
SidekiqVigil.logger.error("[sidekiq-vigil] collector failed: #{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|