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,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class QueueSize < 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
+ threshold_result(
10
+ target: queue.name,
11
+ value: queue.size,
12
+ warn: thresholds.fetch(:warn, 1_000),
13
+ critical: thresholds.fetch(:critical, 10_000),
14
+ message: "#{queue.size} jobs waiting"
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class RedisHealth < Base
6
+ def initialize(
7
+ storage:,
8
+ options: {},
9
+ clock: -> { Time.now },
10
+ logger: SidekiqVigil.logger,
11
+ api: SidekiqApi.new,
12
+ monotonic_clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
13
+ )
14
+ super(storage:, options:, clock:, logger:, api:)
15
+ @monotonic_clock = monotonic_clock
16
+ end
17
+
18
+ def call
19
+ started_at = monotonic_clock.call
20
+ storage.ping
21
+ latency_ms = (monotonic_clock.call - started_at) * 1_000
22
+ [latency_result(latency_ms), memory_result]
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :monotonic_clock
28
+
29
+ def latency_result(latency_ms)
30
+ threshold_result(
31
+ target: "latency",
32
+ value: latency_ms,
33
+ warn: options.fetch(:latency_ms, 100),
34
+ critical: options.fetch(:critical_latency_ms, options.fetch(:latency_ms, 100) * 2),
35
+ message: format("%.2fms PING latency", latency_ms)
36
+ )
37
+ end
38
+
39
+ def memory_result
40
+ info = storage.info("memory")
41
+ used = info.fetch("used_memory", 0).to_f
42
+ maximum = info.fetch("maxmemory", 0).to_f
43
+ ratio = maximum.positive? ? used / maximum : 0.0
44
+ threshold_result(
45
+ target: "memory",
46
+ value: ratio,
47
+ warn: options.fetch(:memory_pct, 0.9),
48
+ critical: options.fetch(:critical_memory_pct, 0.98),
49
+ message: maximum.positive? ? format("%.1f%% Redis maxmemory used", ratio * 100) : "Redis maxmemory is unlimited",
50
+ metadata: { used_memory: used.to_i, maxmemory: maximum.to_i }
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class Registry
6
+ def initialize
7
+ @checks = {}
8
+ end
9
+
10
+ def register(name, klass)
11
+ @checks[name.to_sym] = klass
12
+ end
13
+
14
+ def fetch(name)
15
+ @checks.fetch(name.to_sym)
16
+ end
17
+
18
+ def names
19
+ @checks.keys
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class ScheduledBacklog < Base
6
+ def call
7
+ overdue_count = api.scheduled.count do |job|
8
+ timestamp = job.respond_to?(:at) ? job.at : Time.at(job.score.to_f)
9
+ timestamp <= clock.call - options.fetch(:overdue, 300)
10
+ end
11
+ threshold_result(
12
+ target: "global",
13
+ value: overdue_count,
14
+ warn: options.fetch(:warn, 1),
15
+ critical: options.fetch(:critical, 100),
16
+ message: "#{overdue_count} overdue scheduled jobs"
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class SetSize < Base
6
+ STATE_TTL = 8 * 24 * 60 * 60
7
+
8
+ def call
9
+ total = current_size
10
+ value = growth_value(total)
11
+ threshold_result(
12
+ target: "global",
13
+ value:,
14
+ warn: options.fetch(:warn, default_warn),
15
+ critical: options.fetch(:critical, default_critical),
16
+ message: message(total, value),
17
+ metadata: { total: }
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def growth_value(total)
24
+ return total unless options.fetch(:growth_only, false)
25
+
26
+ previous = storage.get(state_key)
27
+ storage.set(state_key, total, ttl: STATE_TTL)
28
+ return 0 unless previous
29
+
30
+ [total - previous.to_i, 0].max
31
+ end
32
+
33
+ def state_key
34
+ "check_state:#{check_name}"
35
+ end
36
+
37
+ def message(total, value)
38
+ return "#{total} entries" unless options.fetch(:growth_only, false)
39
+
40
+ "#{value} new entries (#{total} total)"
41
+ end
42
+ end
43
+
44
+ class RetrySet < SetSize
45
+ private
46
+
47
+ def current_size
48
+ api.retry_size
49
+ end
50
+
51
+ def default_warn
52
+ 100
53
+ end
54
+
55
+ def default_critical
56
+ 1_000
57
+ end
58
+ end
59
+
60
+ class DeadSet < SetSize
61
+ private
62
+
63
+ def current_size
64
+ api.dead_size
65
+ end
66
+
67
+ def default_warn
68
+ 1
69
+ end
70
+
71
+ def default_critical
72
+ 50
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class StuckJobs < Base
6
+ def call
7
+ stuck = api.workers.filter_map { |worker| stuck_result(worker) }
8
+ return stuck unless stuck.empty?
9
+
10
+ [Result.new(check_name:, severity: :ok, value: 0, threshold: threshold, message: "no stuck jobs")]
11
+ end
12
+
13
+ private
14
+
15
+ def stuck_result(worker)
16
+ payload = worker.fetch(:work)
17
+ started_at = payload["run_at"] || payload[:run_at]
18
+ return unless started_at
19
+
20
+ age = clock.call.to_f - started_at.to_f
21
+ return if age < threshold
22
+
23
+ Result.new(
24
+ check_name:,
25
+ target: "#{worker[:process_id]}:#{worker[:thread_id]}",
26
+ severity: :critical,
27
+ value: age,
28
+ threshold:,
29
+ message: "job has run for #{age.round}s",
30
+ metadata: { payload: safe_payload(payload) }
31
+ )
32
+ end
33
+
34
+ def safe_payload(payload)
35
+ raw = payload["payload"] || payload[:payload] || {}
36
+ { class: raw["class"] || raw[:class], queue: raw["queue"] || raw[:queue] }
37
+ end
38
+
39
+ def threshold
40
+ options.fetch(:threshold, 1_800)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class ThroughputAnomaly < Base
6
+ WINDOW_MINUTES = 60
7
+
8
+ def call
9
+ baseline = baseline_samples
10
+ return insufficient_result(baseline.length) if baseline.length < options.fetch(:min_samples, 3)
11
+
12
+ expected = median(baseline)
13
+ current = window_total(clock.call)
14
+ drop = expected.positive? ? 1 - current.fdiv(expected) : 0.0
15
+ severity = drop >= options.fetch(:drop_pct, 0.5) ? :warn : :ok
16
+ Result.new(
17
+ check_name:,
18
+ severity:,
19
+ value: current,
20
+ threshold: expected * (1 - options.fetch(:drop_pct, 0.5)),
21
+ message: format(
22
+ "%<current>d jobs vs %<expected>.1f baseline (%<drop>.1f%% drop)",
23
+ current:,
24
+ expected:,
25
+ drop: drop * 100
26
+ ),
27
+ metadata: { baseline: expected, samples: baseline.length, drop_pct: drop }
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def baseline_samples
34
+ days = options.fetch(:baseline_days, 7)
35
+ (1..days).filter_map do |days_ago|
36
+ sample_time = Timezone.same_local_days_ago(clock.call, days_ago, options.fetch(:timezone, "UTC"))
37
+ sample = window_total(sample_time)
38
+ sample if sample.positive?
39
+ end
40
+ end
41
+
42
+ def window_total(time)
43
+ local = Timezone.local_time(time, options.fetch(:timezone, "UTC"))
44
+ WINDOW_MINUTES.times.sum do |offset|
45
+ minute = local - (offset * 60)
46
+ storage.hash_get_all("stats:#{minute.getutc.strftime('%Y%m%d%H%M')}").fetch("processed", 0).to_i
47
+ end
48
+ end
49
+
50
+ def median(values)
51
+ sorted = values.sort
52
+ middle = sorted.length / 2
53
+ return sorted[middle].to_f if sorted.length.odd?
54
+
55
+ (sorted[middle - 1] + sorted[middle]).fdiv(2)
56
+ end
57
+
58
+ def insufficient_result(samples)
59
+ Result.new(
60
+ check_name:,
61
+ severity: :ok,
62
+ value: nil,
63
+ message: "insufficient baseline samples (#{samples}/#{options.fetch(:min_samples, 3)})",
64
+ metadata: { insufficient_samples: true, samples: }
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Check
5
+ class Utilization < Base
6
+ STATE_TTL_PADDING = 300
7
+
8
+ def call
9
+ busy, concurrency = totals
10
+ ratio = concurrency.zero? ? 0.0 : busy.fdiv(concurrency)
11
+ severity, threshold = sustained_severity(ratio)
12
+ Result.new(
13
+ check_name:,
14
+ severity:,
15
+ value: ratio,
16
+ threshold:,
17
+ message: format(
18
+ "%<ratio>.1f%% utilized (%<busy>d/%<concurrency>d)",
19
+ ratio: ratio * 100,
20
+ busy:,
21
+ concurrency:
22
+ )
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def totals
29
+ api.processes.each_with_object([0, 0]) do |process, totals|
30
+ totals[0] += process_value(process, "busy").to_i
31
+ totals[1] += process_value(process, "concurrency").to_i
32
+ end
33
+ end
34
+
35
+ def process_value(process, key)
36
+ process[key] || process[key.to_sym]
37
+ end
38
+
39
+ def sustained_severity(ratio)
40
+ warn = options.fetch(:warn, 0.85)
41
+ critical = options.fetch(:critical, 0.95)
42
+ return clear_sustained_state(warn) if ratio < warn
43
+
44
+ started_at = storage.get("check_state:utilization_since")
45
+ storage.set("check_state:utilization_since", started_at || clock.call.to_f, ttl: state_ttl)
46
+ return [:ok, warn] if started_at.nil? || clock.call.to_f - started_at.to_f < options.fetch(:sustained, 300)
47
+
48
+ severity_and_threshold(ratio, warn, critical)
49
+ end
50
+
51
+ def clear_sustained_state(warn)
52
+ storage.delete("check_state:utilization_since")
53
+ [:ok, warn]
54
+ end
55
+
56
+ def state_ttl
57
+ options.fetch(:sustained, 300) + STATE_TTL_PADDING
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module SidekiqVigil
7
+ class Checker
8
+ attr_reader :thread
9
+
10
+ def initialize(
11
+ storage:,
12
+ config:,
13
+ leader_election: nil,
14
+ alert_manager: nil,
15
+ notifier_manager: nil,
16
+ logger: SidekiqVigil.logger,
17
+ clock: -> { Time.now },
18
+ sleeper: ->(seconds) { sleep(seconds) }
19
+ )
20
+ @storage = storage
21
+ @config = config
22
+ @logger = logger
23
+ @clock = clock
24
+ @sleeper = sleeper
25
+ @leader_election = leader_election || LeaderElection.new(storage:, ttl: config.interval * 3)
26
+ @notifier_manager = notifier_manager || build_notifier_manager
27
+ @alert_manager = alert_manager || build_alert_manager
28
+ @running = false
29
+ @direct_redis_notified = false
30
+ end
31
+
32
+ def start
33
+ return thread if thread&.alive?
34
+
35
+ @running = true
36
+ @thread = Thread.new { run }
37
+ @thread.name = "sidekiq-vigil-checker" if @thread.respond_to?(:name=)
38
+ @thread
39
+ end
40
+
41
+ def stop
42
+ @running = false
43
+ thread&.wakeup if thread&.status == "sleep"
44
+ thread&.join(config.interval + 1)
45
+ leader_election.release
46
+ @thread = nil
47
+ end
48
+
49
+ def run_once
50
+ return false unless become_or_remain_leader
51
+
52
+ results = configured_checks.flat_map(&:execute)
53
+ events = alert_manager.process(results)
54
+ write_snapshot(results)
55
+ warn_config_drift
56
+ notifier_manager.notify(events)
57
+ @direct_redis_notified = false
58
+ results
59
+ rescue StandardError => e
60
+ logger.error("[sidekiq-vigil] checker cycle failed: #{e.class}: #{e.message}")
61
+ notify_redis_outage(e) if redis_error?(e)
62
+ false
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :storage, :config, :leader_election, :alert_manager, :notifier_manager, :logger, :clock, :sleeper
68
+
69
+ def run
70
+ while @running
71
+ run_once
72
+ sleeper.call(config.interval)
73
+ end
74
+ rescue StandardError => e
75
+ logger.error("[sidekiq-vigil] checker thread failed: #{e.class}: #{e.message}")
76
+ ensure
77
+ leader_election.release
78
+ end
79
+
80
+ def become_or_remain_leader
81
+ return leader_election.extend if leader_election.leader?
82
+
83
+ leader_election.acquire
84
+ end
85
+
86
+ def configured_checks
87
+ config.checks.map do |definition|
88
+ klass = definition.klass || BUILT_IN_CHECKS.fetch(definition.name)
89
+ options = definition.options.merge(timezone: config.timezone)
90
+ klass.new(storage:, options:, clock:, logger:)
91
+ end
92
+ end
93
+
94
+ def write_snapshot(results)
95
+ payload = JSON.generate(
96
+ timestamp: clock.call.utc.iso8601,
97
+ results: results.map(&:to_h),
98
+ alerts: alert_states
99
+ )
100
+ storage.set("snapshot", payload, ttl: config.interval * 4)
101
+ end
102
+
103
+ def alert_states
104
+ storage.hash_get_all("alerts").transform_values { |payload| JSON.parse(payload) }
105
+ end
106
+
107
+ def warn_config_drift
108
+ digests = storage.hash_get_all("config_digest")
109
+ return if digests.values.uniq.length <= 1
110
+
111
+ logger.warn("[sidekiq-vigil] configuration digest mismatch across processes")
112
+ end
113
+
114
+ def build_notifier_manager
115
+ Notifier::Manager.new(notifiers: Notifier::Factory.build(config, logger:), logger:)
116
+ end
117
+
118
+ def build_alert_manager
119
+ mute = Alert::Mute.new(
120
+ storage:,
121
+ schedules: config.alerting.mutes,
122
+ clock:,
123
+ timezone: config.timezone
124
+ )
125
+ Alert::Manager.new(storage:, config: config.alerting, mute:, clock:)
126
+ end
127
+
128
+ def notify_redis_outage(error)
129
+ return if @direct_redis_notified
130
+
131
+ @direct_redis_notified = true
132
+ result = Result.new(
133
+ check_name: "redis_health",
134
+ target: "connection",
135
+ severity: :error,
136
+ message: "Redis unavailable (#{error.class})"
137
+ )
138
+ state = Alert::State.new(status: "firing", severity: "error", message: result.message)
139
+ event = Alert::Event.new(result:, transition: :firing, state:, history: [], timestamp: clock.call)
140
+ notifier_manager.notify([event])
141
+ end
142
+
143
+ def redis_error?(error)
144
+ return true if defined?(RedisClient::Error) && error.is_a?(RedisClient::Error)
145
+
146
+ error.message.match?(/redis|connection|socket|timeout/i)
147
+ end
148
+ end
149
+ end