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