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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +237 -0
  5. data/Rakefile +10 -0
  6. data/docs/redis_keys.md +18 -0
  7. data/docs/webhook_event.schema.json +41 -0
  8. data/docs/webhook_schema.md +64 -0
  9. data/exe/vigil +6 -0
  10. data/lib/generators/sidekiq_vigil/install_generator.rb +32 -0
  11. data/lib/generators/sidekiq_vigil/templates/sidekiq_vigil.rb +49 -0
  12. data/lib/sidekiq/vigil/version.rb +7 -0
  13. data/lib/sidekiq/vigil.rb +7 -0
  14. data/lib/sidekiq_vigil/alert/cron.rb +106 -0
  15. data/lib/sidekiq_vigil/alert/event.rb +110 -0
  16. data/lib/sidekiq_vigil/alert/grouping.rb +15 -0
  17. data/lib/sidekiq_vigil/alert/manager.rb +227 -0
  18. data/lib/sidekiq_vigil/alert/mute.rb +69 -0
  19. data/lib/sidekiq_vigil/alert/state.rb +51 -0
  20. data/lib/sidekiq_vigil/check/base.rb +52 -0
  21. data/lib/sidekiq_vigil/check/failure_rate.rb +45 -0
  22. data/lib/sidekiq_vigil/check/memory.rb +41 -0
  23. data/lib/sidekiq_vigil/check/process_alive.rb +64 -0
  24. data/lib/sidekiq_vigil/check/queue_latency.rb +21 -0
  25. data/lib/sidekiq_vigil/check/queue_size.rb +20 -0
  26. data/lib/sidekiq_vigil/check/redis_health.rb +55 -0
  27. data/lib/sidekiq_vigil/check/registry.rb +23 -0
  28. data/lib/sidekiq_vigil/check/scheduled_backlog.rb +21 -0
  29. data/lib/sidekiq_vigil/check/set_size.rb +76 -0
  30. data/lib/sidekiq_vigil/check/stuck_jobs.rb +44 -0
  31. data/lib/sidekiq_vigil/check/throughput_anomaly.rb +69 -0
  32. data/lib/sidekiq_vigil/check/utilization.rb +61 -0
  33. data/lib/sidekiq_vigil/checker.rb +149 -0
  34. data/lib/sidekiq_vigil/cli.rb +205 -0
  35. data/lib/sidekiq_vigil/collector.rb +74 -0
  36. data/lib/sidekiq_vigil/config.rb +267 -0
  37. data/lib/sidekiq_vigil/health_app.rb +64 -0
  38. data/lib/sidekiq_vigil/leader_election.rb +54 -0
  39. data/lib/sidekiq_vigil/middleware/server.rb +37 -0
  40. data/lib/sidekiq_vigil/notifier/base.rb +22 -0
  41. data/lib/sidekiq_vigil/notifier/http_transport.rb +32 -0
  42. data/lib/sidekiq_vigil/notifier/log.rb +14 -0
  43. data/lib/sidekiq_vigil/notifier/manager.rb +45 -0
  44. data/lib/sidekiq_vigil/notifier/slack.rb +192 -0
  45. data/lib/sidekiq_vigil/notifier/webhook.rb +25 -0
  46. data/lib/sidekiq_vigil/reporter.rb +103 -0
  47. data/lib/sidekiq_vigil/result.rb +43 -0
  48. data/lib/sidekiq_vigil/runtime.rb +44 -0
  49. data/lib/sidekiq_vigil/sidekiq_api.rb +40 -0
  50. data/lib/sidekiq_vigil/storage.rb +167 -0
  51. data/lib/sidekiq_vigil/timezone.rb +39 -0
  52. data/lib/sidekiq_vigil/version.rb +5 -0
  53. data/lib/sidekiq_vigil.rb +128 -0
  54. 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