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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Notifier
5
+ class Base
6
+ attr_reader :options, :logger
7
+
8
+ def initialize(options: {}, logger: SidekiqVigil.logger, **_dependencies)
9
+ @options = options
10
+ @logger = logger
11
+ end
12
+
13
+ def notify(_event)
14
+ raise NotImplementedError, "#{self.class} must implement #notify"
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class} options=[FILTERED]>"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module SidekiqVigil
7
+ module Notifier
8
+ class HttpTransport
9
+ Response = Data.define(:code, :body) do
10
+ def success?
11
+ code.to_i.between?(200, 299)
12
+ end
13
+ end
14
+
15
+ def post(url, body, headers: {})
16
+ uri = URI.parse(url)
17
+ raise Error, "notification URL must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme)
18
+
19
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" }.merge(headers))
20
+ request.body = body
21
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
22
+ http.open_timeout = 5
23
+ http.read_timeout = 10
24
+ http.request(request)
25
+ end
26
+ Response.new(code: response.code, body: response.body)
27
+ rescue URI::InvalidURIError, SocketError, SystemCallError, Timeout::Error => e
28
+ raise Error, "notification request failed (#{e.class})"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SidekiqVigil
6
+ module Notifier
7
+ class Log < Base
8
+ def notify(event)
9
+ logger.warn("[sidekiq-vigil] #{JSON.generate(event.to_h)}")
10
+ true
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ module Notifier
5
+ class Manager
6
+ def initialize(notifiers:, logger: SidekiqVigil.logger)
7
+ @notifiers = notifiers
8
+ @logger = logger
9
+ end
10
+
11
+ def notify(events)
12
+ Array(events).each do |event|
13
+ notifiers.each { |notifier| notify_one(notifier, event) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :notifiers, :logger
20
+
21
+ def notify_one(notifier, event)
22
+ notifier.notify(event)
23
+ rescue StandardError => e
24
+ logger.error("[sidekiq-vigil] #{notifier.class} failed: #{e.class}: #{e.message}")
25
+ end
26
+ end
27
+
28
+ module Factory
29
+ CLASSES = {
30
+ log: Log,
31
+ slack: Slack,
32
+ webhook: Webhook
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ def build(config, logger: SidekiqVigil.logger)
38
+ config.runnable_notifiers.map do |definition|
39
+ klass = definition.klass || CLASSES.fetch(definition.name)
40
+ klass.new(options: definition.options, logger:)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module SidekiqVigil
7
+ module Notifier
8
+ class Slack < Base
9
+ SPARKS = "▁▂▃▄▅▆▇█"
10
+
11
+ def initialize(
12
+ options: {},
13
+ logger: SidekiqVigil.logger,
14
+ transport: HttpTransport.new,
15
+ sleeper: ->(seconds) { sleep(seconds) },
16
+ fallback: Log.new(logger:)
17
+ )
18
+ super(options:, logger:)
19
+ @transport = transport
20
+ @sleeper = sleeper
21
+ @fallback = fallback
22
+ end
23
+
24
+ def notify(event)
25
+ attempts = options.fetch(:retries, 3) + 1
26
+ attempts.times do |attempt|
27
+ response = transport.post(webhook_url(event), JSON.generate(payload(event)))
28
+ return true if response.success?
29
+
30
+ raise Error, "Slack returned HTTP #{response.code}"
31
+ rescue StandardError => e
32
+ if attempt == attempts - 1
33
+ logger.error("[sidekiq-vigil] Slack notification failed after #{attempts} attempts: #{safe_error(e)}")
34
+ fallback.notify(event)
35
+ return false
36
+ end
37
+ sleeper.call(2**attempt)
38
+ end
39
+ end
40
+
41
+ def payload(event)
42
+ {
43
+ text: header(event),
44
+ blocks: event.transition == :digest ? digest_blocks(event) : alert_blocks(event)
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :transport, :sleeper, :fallback
51
+
52
+ def webhook_url(event)
53
+ routes = options.fetch(:routes, {})
54
+ routes[event.severity] || routes[event.severity.to_s] || options.fetch(:webhook_url)
55
+ end
56
+
57
+ def header(event)
58
+ return "📦 DIGEST — #{event.result.message}" if event.transition == :digest
59
+ return "✅ RESOLVED — #{event.result.check_name}" if event.transition == :resolved
60
+
61
+ icon = %i[critical error].include?(event.severity) ? "🔥" : "⚠️"
62
+ "#{icon} #{event.severity.to_s.upcase} — #{event.result.check_name}"
63
+ end
64
+
65
+ def fields(event)
66
+ result = event.result
67
+ [
68
+ markdown_field("*Check*\n#{display(result.check_name)}"),
69
+ markdown_field("*Target*\n#{result.target}"),
70
+ markdown_field("*Value / threshold*\n#{display(result.value)} / #{display(result.threshold)}"),
71
+ markdown_field("*Duration*\n#{duration(event)}"),
72
+ markdown_field("*Environment*\n#{environment}"),
73
+ markdown_field("*Host*\n#{host}")
74
+ ]
75
+ end
76
+
77
+ def context_block(event)
78
+ parts = ["Event #{event.transition}"]
79
+ spark = sparkline(event.history)
80
+ parts << "Trend #{spark}" unless spark.empty?
81
+ parts << "<#{options[:web_ui_url]}|Open Sidekiq>" if options[:web_ui_url]
82
+ mentions = options.fetch(:mention, {})
83
+ mention = mentions[event.severity] || mentions[event.severity.to_s]
84
+ parts << mention if mention
85
+
86
+ { type: "context", elements: [{ type: "mrkdwn", text: parts.join(" • ") }] }
87
+ end
88
+
89
+ def alert_blocks(event)
90
+ [
91
+ header_block(event),
92
+ { type: "section", fields: fields(event) },
93
+ message_block(event.result.message),
94
+ context_block(event)
95
+ ].compact
96
+ end
97
+
98
+ def digest_blocks(event)
99
+ metadata = event.result.metadata
100
+ [
101
+ header_block(event),
102
+ { type: "section", text: { type: "mrkdwn", text: digest_counts(metadata.fetch(:counts)) } },
103
+ { type: "section", text: { type: "mrkdwn", text: digest_top(metadata.fetch(:top)) } },
104
+ context_block(event)
105
+ ]
106
+ end
107
+
108
+ def header_block(event)
109
+ { type: "header", text: { type: "plain_text", text: header(event) } }
110
+ end
111
+
112
+ def message_block(message)
113
+ return unless message
114
+
115
+ { type: "section", text: { type: "mrkdwn", text: "*Message*\n#{message}" } }
116
+ end
117
+
118
+ def digest_counts(counts)
119
+ labels = { error: "Error", critical: "Critical", warn: "Warn", ok: "OK" }
120
+ values = labels.filter_map do |severity, label|
121
+ count = counts[severity] || counts[severity.to_s]
122
+ "#{label}: #{count}" if count.to_i.positive?
123
+ end
124
+ "*Severity counts*\n#{values.join(' • ')}"
125
+ end
126
+
127
+ def digest_top(alerts)
128
+ lines = alerts.map do |alert|
129
+ severity = fetch_value(alert, :severity).to_s.upcase
130
+ check = fetch_value(alert, :check_name)
131
+ target = fetch_value(alert, :target)
132
+ value = display(fetch_value(alert, :value))
133
+ threshold = display(fetch_value(alert, :threshold))
134
+ "• *#{severity}* #{check} / #{target} — #{value} / #{threshold}"
135
+ end
136
+ "*Top alerts*\n#{lines.join("\n")}"
137
+ end
138
+
139
+ def markdown_field(text)
140
+ { type: "mrkdwn", text: }
141
+ end
142
+
143
+ def sparkline(history)
144
+ values = history.filter_map { |point| numeric(point["value"] || point[:value]) }
145
+ return "" if values.empty?
146
+ return SPARKS[0] * values.length if values.min == values.max
147
+
148
+ values.map do |value|
149
+ index = ((value - values.min) / (values.max - values.min) * (SPARKS.length - 1)).round
150
+ SPARKS[index]
151
+ end.join
152
+ end
153
+
154
+ def numeric(value)
155
+ Float(value)
156
+ rescue TypeError, ArgumentError
157
+ nil
158
+ end
159
+
160
+ def display(value)
161
+ value.nil? ? "-" : value.to_s
162
+ end
163
+
164
+ def duration(event)
165
+ started_at = event.state.first_seen_at
166
+ return "-" unless started_at
167
+
168
+ seconds = [event.timestamp.to_f - started_at.to_f, 0].max.round
169
+ return "#{seconds}s" if seconds < 60
170
+ return "#{seconds / 60}m #{seconds % 60}s" if seconds < 3_600
171
+
172
+ "#{seconds / 3_600}h #{(seconds % 3_600) / 60}m"
173
+ end
174
+
175
+ def environment
176
+ options.fetch(:environment) { ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "unknown" }
177
+ end
178
+
179
+ def host
180
+ options.fetch(:host) { Socket.gethostname }
181
+ end
182
+
183
+ def fetch_value(hash, key)
184
+ hash[key] || hash[key.to_s]
185
+ end
186
+
187
+ def safe_error(error)
188
+ error.message.gsub(%r{https?://\S+}, "[FILTERED]")
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SidekiqVigil
6
+ module Notifier
7
+ class Webhook < Base
8
+ def initialize(options: {}, logger: SidekiqVigil.logger, transport: HttpTransport.new)
9
+ super(options:, logger:)
10
+ @transport = transport
11
+ end
12
+
13
+ def notify(event)
14
+ response = transport.post(options.fetch(:url), JSON.generate(event.to_h), headers: options.fetch(:headers, {}))
15
+ raise Error, "webhook returned HTTP #{response.code}" unless response.success?
16
+
17
+ true
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :transport
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "socket"
5
+
6
+ module SidekiqVigil
7
+ class Reporter
8
+ STATS_TTL = 8 * 24 * 60 * 60
9
+ EXECUTION_TTL = 60 * 60
10
+ CONFIG_TTL = 60 * 60
11
+
12
+ attr_reader :thread
13
+
14
+ def initialize(storage:, collector:, config:, logger: SidekiqVigil.logger, sleeper: ->(seconds) { sleep(seconds) })
15
+ @storage = storage
16
+ @collector = collector
17
+ @config = config
18
+ @logger = logger
19
+ @sleeper = sleeper
20
+ @running = false
21
+ end
22
+
23
+ def start
24
+ return thread if thread&.alive?
25
+
26
+ @running = true
27
+ @thread = Thread.new { run }
28
+ @thread.name = "sidekiq-vigil-reporter" if @thread.respond_to?(:name=)
29
+ @thread
30
+ end
31
+
32
+ def stop
33
+ @running = false
34
+ thread&.wakeup if thread&.status == "sleep"
35
+ thread&.join(config.flush_interval + 1)
36
+ @thread = nil
37
+ end
38
+
39
+ def flush_once
40
+ snapshot = collector.drain
41
+ flush_snapshot(snapshot)
42
+ report_rss
43
+ storage.hash_write("config_digest", { process_id => config.digest }, ttl: CONFIG_TTL)
44
+ true
45
+ rescue StandardError => e
46
+ collector.merge(snapshot) if snapshot
47
+ logger.error("[sidekiq-vigil] reporter flush failed: #{e.class}: #{e.message}")
48
+ false
49
+ end
50
+
51
+ def rss_kb
52
+ return linux_rss if File.readable?("/proc/self/status")
53
+ return macos_rss if RUBY_PLATFORM.include?("darwin")
54
+
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :storage, :collector, :config, :logger, :sleeper
61
+
62
+ def run
63
+ while @running
64
+ flush_once
65
+ sleeper.call(config.flush_interval)
66
+ end
67
+ rescue StandardError => e
68
+ logger.error("[sidekiq-vigil] reporter thread failed: #{e.class}: #{e.message}")
69
+ end
70
+
71
+ def flush_snapshot(snapshot)
72
+ snapshot.stats.each do |minute, fields|
73
+ storage.hash_increment("stats:#{minute}", fields, ttl: STATS_TTL)
74
+ end
75
+ snapshot.executions.each do |queue, values|
76
+ storage.hash_increment("exec:#{queue}", values.transform_keys(&:to_s), ttl: EXECUTION_TTL)
77
+ end
78
+ end
79
+
80
+ def report_rss
81
+ rss = rss_kb
82
+ return unless rss
83
+
84
+ storage.set("mem:#{process_id}", rss, ttl: config.flush_interval * 3)
85
+ end
86
+
87
+ def process_id
88
+ @process_id ||= "#{Socket.gethostname}:#{Process.pid}"
89
+ end
90
+
91
+ def linux_rss
92
+ match = File.read("/proc/self/status").match(/^VmRSS:\s+(\d+)\s+kB/)
93
+ match && Integer(match[1])
94
+ end
95
+
96
+ def macos_rss
97
+ output = IO.popen(["ps", "-o", "rss=", "-p", Process.pid.to_s], &:read)
98
+ Integer(output.strip, exception: false)
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ class Result
5
+ SEVERITIES = %i[ok warn critical error].freeze
6
+
7
+ attr_reader :check_name, :target, :severity, :value, :threshold, :message, :metadata
8
+
9
+ def initialize(check_name:, severity:, target: "global", value: nil, threshold: nil, message: nil, metadata: {})
10
+ severity = severity.to_sym
11
+ raise ArgumentError, "unknown severity: #{severity}" unless SEVERITIES.include?(severity)
12
+
13
+ @check_name = check_name.to_s
14
+ @target = target.to_s
15
+ @severity = severity
16
+ @value = value
17
+ @threshold = threshold
18
+ @message = message
19
+ @metadata = metadata.freeze
20
+ freeze
21
+ end
22
+
23
+ def ok?
24
+ severity == :ok
25
+ end
26
+
27
+ def alert_id
28
+ "#{check_name}:#{target}"
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ check_name: check_name,
34
+ target: target,
35
+ severity: severity,
36
+ value: value,
37
+ threshold: threshold,
38
+ message: message,
39
+ metadata: metadata
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ class Runtime
5
+ attr_reader :reporter, :checker
6
+
7
+ def initialize(
8
+ config:,
9
+ storage:,
10
+ collector: SidekiqVigil.collector,
11
+ logger: SidekiqVigil.logger,
12
+ reporter: nil,
13
+ checker: nil
14
+ )
15
+ @config = config
16
+ @reporter = reporter || Reporter.new(storage:, collector:, config:, logger:)
17
+ @checker = checker || Checker.new(storage:, config:, logger:)
18
+ end
19
+
20
+ def start
21
+ return false unless config.enabled?
22
+
23
+ reporter.start
24
+ checker.start
25
+ true
26
+ rescue StandardError => e
27
+ SidekiqVigil.logger.error("[sidekiq-vigil] startup failed: #{e.class}: #{e.message}")
28
+ false
29
+ end
30
+
31
+ def quiet
32
+ checker.stop
33
+ end
34
+
35
+ def stop
36
+ checker.stop
37
+ reporter.stop
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :config
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqVigil
4
+ class SidekiqApi
5
+ def queues(names = :all)
6
+ return Sidekiq::Queue.all if names == :all
7
+
8
+ Array(names).map { |name| Sidekiq::Queue.new(name.to_s) }
9
+ end
10
+
11
+ def retry_size
12
+ Sidekiq::RetrySet.new.size
13
+ end
14
+
15
+ def dead_size
16
+ Sidekiq::DeadSet.new.size
17
+ end
18
+
19
+ def processes
20
+ Sidekiq::ProcessSet.new.to_a
21
+ end
22
+
23
+ def workers
24
+ Sidekiq::Workers.new.map do |process_id, thread_id, work|
25
+ {
26
+ process_id:,
27
+ thread_id:,
28
+ work: {
29
+ "run_at" => work.run_at.to_f,
30
+ "payload" => work.payload
31
+ }
32
+ }
33
+ end
34
+ end
35
+
36
+ def scheduled
37
+ Sidekiq::ScheduledSet.new.to_a
38
+ end
39
+ end
40
+ end