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,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