anomonitor 0.6.1
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/CHANGELOG.md +90 -0
- data/MIT-LICENSE +20 -0
- data/README.md +275 -0
- data/RELEASE.md +32 -0
- data/Rakefile +13 -0
- data/SECURITY.md +24 -0
- data/app/assets/config/anomonitor_manifest.js +1 -0
- data/app/assets/stylesheets/anomonitor/application.css +370 -0
- data/app/controllers/anomonitor/anomalies_controller.rb +44 -0
- data/app/controllers/anomonitor/application_controller.rb +21 -0
- data/app/controllers/anomonitor/dashboard_controller.rb +69 -0
- data/app/controllers/anomonitor/jobs_controller.rb +61 -0
- data/app/controllers/anomonitor/metrics_controller.rb +18 -0
- data/app/controllers/anomonitor/mutes_controller.rb +41 -0
- data/app/helpers/anomonitor/application_helper.rb +71 -0
- data/app/models/anomonitor/anomaly.rb +105 -0
- data/app/models/anomonitor/application_record.rb +5 -0
- data/app/models/anomonitor/metric_sample.rb +25 -0
- data/app/models/anomonitor/mute.rb +56 -0
- data/app/views/anomonitor/anomalies/index.html.erb +54 -0
- data/app/views/anomonitor/anomalies/show.html.erb +54 -0
- data/app/views/anomonitor/dashboard/show.html.erb +124 -0
- data/app/views/anomonitor/jobs/index.html.erb +135 -0
- data/app/views/anomonitor/metrics/index.html.erb +33 -0
- data/app/views/anomonitor/mutes/index.html.erb +56 -0
- data/app/views/layouts/anomonitor/application.html.erb +39 -0
- data/config/routes.rb +15 -0
- data/db/migrate/20260807100000_create_anomonitor_metric_samples.rb +17 -0
- data/db/migrate/20260807100001_create_anomonitor_anomalies.rb +23 -0
- data/db/migrate/20260808100000_add_resolved_at_to_anomonitor_anomalies.rb +6 -0
- data/db/migrate/20260808120000_create_anomonitor_mutes.rb +16 -0
- data/lib/anomonitor/collectors/base.rb +25 -0
- data/lib/anomonitor/collectors/delayed_job.rb +55 -0
- data/lib/anomonitor/collectors/schema_drift.rb +144 -0
- data/lib/anomonitor/collectors/sidekiq.rb +47 -0
- data/lib/anomonitor/collectors/solid_queue.rb +57 -0
- data/lib/anomonitor/collectors/table.rb +116 -0
- data/lib/anomonitor/configuration.rb +184 -0
- data/lib/anomonitor/configuration_validator.rb +40 -0
- data/lib/anomonitor/detector.rb +195 -0
- data/lib/anomonitor/digester.rb +79 -0
- data/lib/anomonitor/engine.rb +35 -0
- data/lib/anomonitor/jobs/browser.rb +89 -0
- data/lib/anomonitor/jobs/browsers/delayed_job.rb +143 -0
- data/lib/anomonitor/jobs/browsers/sidekiq.rb +164 -0
- data/lib/anomonitor/jobs/browsers/solid_queue.rb +149 -0
- data/lib/anomonitor/jobs/browsers/table.rb +206 -0
- data/lib/anomonitor/jobs/row.rb +30 -0
- data/lib/anomonitor/metric_point.rb +42 -0
- data/lib/anomonitor/metrics_export.rb +55 -0
- data/lib/anomonitor/notifiers/callable.rb +44 -0
- data/lib/anomonitor/notifiers/composite.rb +33 -0
- data/lib/anomonitor/notifiers/rate_limited.rb +44 -0
- data/lib/anomonitor/notifiers/webhook.rb +108 -0
- data/lib/anomonitor/notifiers.rb +59 -0
- data/lib/anomonitor/poll_lock.rb +67 -0
- data/lib/anomonitor/poller.rb +164 -0
- data/lib/anomonitor/tenancy.rb +46 -0
- data/lib/anomonitor/version.rb +3 -0
- data/lib/anomonitor.rb +52 -0
- data/lib/generators/anomonitor/install/install_generator.rb +27 -0
- data/lib/generators/anomonitor/install/templates/anomonitor.rb +87 -0
- data/lib/tasks/anomonitor_tasks.rake +27 -0
- metadata +126 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :webhook_url, :poll_interval, :cooldown, :retention_days,
|
|
6
|
+
:dashboard_path, :dashboard_base_url,
|
|
7
|
+
:tenants, :exclude_tenants, :tenant_switch,
|
|
8
|
+
:schema_drift_exclude, :schema_drift_interval,
|
|
9
|
+
:authenticate, :notifier, :poll_lock,
|
|
10
|
+
:digest_interval, :digest_last_flushed_at, :notifier_rate_limit
|
|
11
|
+
|
|
12
|
+
attr_reader :collectors, :tables, :alerts, :poll_mode, :auto_start
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@webhook_url = nil
|
|
16
|
+
@notifier = nil
|
|
17
|
+
@poll_interval = 60
|
|
18
|
+
@cooldown = 15 * 60
|
|
19
|
+
@retention_days = 7
|
|
20
|
+
@dashboard_path = "/anomonitor"
|
|
21
|
+
@dashboard_base_url = nil
|
|
22
|
+
@schema_drift_interval = 15 * 60
|
|
23
|
+
@authenticate = nil
|
|
24
|
+
@poll_lock = true
|
|
25
|
+
@digest_interval = nil
|
|
26
|
+
@digest_last_flushed_at = nil
|
|
27
|
+
@notifier_rate_limit = 0
|
|
28
|
+
@poll_mode = :thread
|
|
29
|
+
@auto_start = true
|
|
30
|
+
@collectors = CollectorsConfig.new
|
|
31
|
+
@tables = []
|
|
32
|
+
@alerts = []
|
|
33
|
+
|
|
34
|
+
@tenants = nil
|
|
35
|
+
@exclude_tenants = %w[public]
|
|
36
|
+
@tenant_switch = nil
|
|
37
|
+
|
|
38
|
+
@schema_drift_exclude = %w[
|
|
39
|
+
schema_migrations
|
|
40
|
+
ar_internal_metadata
|
|
41
|
+
anomonitor_*
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def anomaly_dashboard_url(anomaly_id)
|
|
46
|
+
path = "#{dashboard_path.to_s.sub(%r{/+\z}, "")}/anomalies/#{anomaly_id}"
|
|
47
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
48
|
+
base = dashboard_base_url.to_s.strip.sub(%r{/+\z}, "")
|
|
49
|
+
base.empty? ? path : "#{base}#{path}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_notifier
|
|
53
|
+
Notifiers.build(self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def digest_enabled?
|
|
57
|
+
digest_interval.to_i.positive?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Persist a mute (DB) — metric/rule/source/tenant are optional matchers.
|
|
61
|
+
# duration: seconds or ActiveSupport duration (e.g. 24.hours)
|
|
62
|
+
def mute(duration:, metric: nil, rule: nil, source: nil, tenant: nil, reason: nil)
|
|
63
|
+
seconds = duration.respond_to?(:to_i) ? duration.to_i : duration
|
|
64
|
+
Mute.create!(
|
|
65
|
+
metric: metric&.to_s,
|
|
66
|
+
rule: rule&.to_s,
|
|
67
|
+
source: source&.to_s,
|
|
68
|
+
tenant: tenant&.to_s,
|
|
69
|
+
muted_until: Time.current + seconds,
|
|
70
|
+
reason: reason
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def poll_mode=(mode)
|
|
75
|
+
mode = mode.to_sym
|
|
76
|
+
unless %i[thread cron].include?(mode)
|
|
77
|
+
raise ArgumentError, "poll_mode must be :thread or :cron (got #{mode.inspect})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@poll_mode = mode
|
|
81
|
+
@auto_start = (mode == :thread)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auto_start=(value)
|
|
85
|
+
@auto_start = !!value
|
|
86
|
+
@poll_mode = @auto_start ? :thread : :cron
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def table(name, &block)
|
|
90
|
+
source = TableSource.new(name)
|
|
91
|
+
yield source if block_given?
|
|
92
|
+
@tables << source
|
|
93
|
+
source
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def alert(metric, **options)
|
|
97
|
+
rule = AlertRule.new(metric, **options)
|
|
98
|
+
@alerts << rule
|
|
99
|
+
rule
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class CollectorsConfig
|
|
103
|
+
attr_accessor :sidekiq, :delayed_job, :solid_queue, :schema_drift
|
|
104
|
+
|
|
105
|
+
def initialize
|
|
106
|
+
@sidekiq = true
|
|
107
|
+
@delayed_job = true
|
|
108
|
+
@solid_queue = true
|
|
109
|
+
@schema_drift = false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class TableSource
|
|
114
|
+
attr_accessor :name, :model, :timestamp, :status, :active, :tenant, :style
|
|
115
|
+
|
|
116
|
+
def initialize(name)
|
|
117
|
+
@name = name
|
|
118
|
+
@timestamp = :created_at
|
|
119
|
+
@status = :status
|
|
120
|
+
@active = %w[pending running]
|
|
121
|
+
@tenant = nil
|
|
122
|
+
@style = :status
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def model_class
|
|
126
|
+
model.to_s.constantize
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def delayed_job_style?
|
|
130
|
+
style.to_s == "delayed_job"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class AlertRule
|
|
135
|
+
attr_reader :metric, :max, :window, :multiplier, :severity, :match
|
|
136
|
+
|
|
137
|
+
def initialize(metric, max: nil, window: nil, multiplier: nil, severity: "high", match: {})
|
|
138
|
+
@metric = metric.to_sym
|
|
139
|
+
@max = max
|
|
140
|
+
@window = normalize_duration(window)
|
|
141
|
+
@multiplier = multiplier
|
|
142
|
+
@severity = severity
|
|
143
|
+
@match = (match || {}).transform_keys(&:to_sym)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def threshold?
|
|
147
|
+
!max.nil?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def spike?
|
|
151
|
+
!multiplier.nil?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def window_seconds
|
|
155
|
+
@window || (5 * 60)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# match: { queue: true } => tag present; { queue: nil } => tag absent; { queue: "x" } => exact
|
|
159
|
+
def matches_point?(point)
|
|
160
|
+
return true if match.nil? || match.empty?
|
|
161
|
+
|
|
162
|
+
match.all? do |key, expected|
|
|
163
|
+
tag = point.tags[key] || point.tags[key.to_s]
|
|
164
|
+
case expected
|
|
165
|
+
when true then !tag.nil? && tag.to_s != ""
|
|
166
|
+
when false, nil then tag.nil? || tag.to_s == ""
|
|
167
|
+
else
|
|
168
|
+
tag.to_s == expected.to_s
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def normalize_duration(value)
|
|
176
|
+
return nil if value.nil?
|
|
177
|
+
return value if value.is_a?(Numeric)
|
|
178
|
+
return value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
|
|
179
|
+
|
|
180
|
+
value
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
class ConfigurationValidator
|
|
5
|
+
def self.warnings(config = Anomonitor.config)
|
|
6
|
+
new(config).warnings
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def warnings
|
|
14
|
+
list = []
|
|
15
|
+
if @config.notifier.nil? && @config.webhook_url.to_s.strip.empty?
|
|
16
|
+
list << "No notifier configured (set c.webhook_url or c.notifier) — anomalies will record as webhook failed"
|
|
17
|
+
end
|
|
18
|
+
if @config.collectors.schema_drift
|
|
19
|
+
tenants = begin
|
|
20
|
+
Tenancy.tenant_names
|
|
21
|
+
rescue StandardError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
if tenants.size < 2
|
|
25
|
+
list << "schema_drift is enabled but fewer than 2 tenants are configured"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
if @config.poll_mode == :thread
|
|
29
|
+
list << "poll_mode=:thread can double-collect under multi-worker Puma/Unicorn — prefer :cron or enable poll_lock"
|
|
30
|
+
end
|
|
31
|
+
path = @config.dashboard_path.to_s
|
|
32
|
+
list << "dashboard_path should start with / (got #{path.inspect})" if path != "" && !path.start_with?("/")
|
|
33
|
+
list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def log!
|
|
37
|
+
warnings.each { |w| Anomonitor.logger.warn("[Anomonitor] Config: #{w}") }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
class Detector
|
|
5
|
+
def initialize(notifier: nil)
|
|
6
|
+
@notifier = notifier || Anomonitor.config.build_notifier
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def evaluate(points)
|
|
10
|
+
@active_sticky_keys = []
|
|
11
|
+
@observed_sticky_bases = []
|
|
12
|
+
anomalies = []
|
|
13
|
+
|
|
14
|
+
unless points.empty?
|
|
15
|
+
Anomonitor.config.alerts.each do |rule|
|
|
16
|
+
points.each do |point|
|
|
17
|
+
next unless metric_matches?(rule, point)
|
|
18
|
+
next unless rule.matches_point?(point)
|
|
19
|
+
|
|
20
|
+
anomaly = detect(rule, point)
|
|
21
|
+
anomalies << anomaly if anomaly
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
resolve_sticky_anomalies(@active_sticky_keys, @observed_sticky_bases)
|
|
27
|
+
Digester.flush_if_due!(notifier: @notifier) if Anomonitor.config.digest_enabled?
|
|
28
|
+
anomalies
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def metric_matches?(rule, point)
|
|
34
|
+
point.metric.to_s == rule.metric.to_s ||
|
|
35
|
+
(rule.spike? && %w[growth_rate queue_depth].include?(point.metric.to_s) && rule.metric.to_s == "growth_spike") ||
|
|
36
|
+
(rule.threshold? && point.metric.to_s == rule.metric.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def detect(rule, point)
|
|
40
|
+
if rule.threshold? && point.metric.to_s == rule.metric.to_s
|
|
41
|
+
note_sticky_observation(point) if point.sticky?
|
|
42
|
+
return nil unless point.value > rule.max
|
|
43
|
+
|
|
44
|
+
create_anomaly(rule, point, threshold: rule.max, reason: "threshold")
|
|
45
|
+
elsif rule.spike?
|
|
46
|
+
detect_spike(rule, point)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def detect_spike(rule, point)
|
|
51
|
+
previous = previous_value(point)
|
|
52
|
+
return nil if previous.nil? || previous <= 0
|
|
53
|
+
|
|
54
|
+
ratio = point.value / previous.to_f
|
|
55
|
+
return nil unless ratio >= rule.multiplier
|
|
56
|
+
|
|
57
|
+
create_anomaly(
|
|
58
|
+
rule,
|
|
59
|
+
point,
|
|
60
|
+
threshold: previous * rule.multiplier,
|
|
61
|
+
reason: "growth_spike",
|
|
62
|
+
extra: { previous: previous, ratio: ratio.round(2) }
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def previous_value(point)
|
|
67
|
+
tags_previous = point.tags[:previous] || point.tags["previous"]
|
|
68
|
+
return tags_previous.to_f if tags_previous
|
|
69
|
+
|
|
70
|
+
window = spike_window_seconds
|
|
71
|
+
queue = point.tags[:queue] || point.tags["queue"]
|
|
72
|
+
tenant = point.tags[:tenant] || point.tags["tenant"]
|
|
73
|
+
|
|
74
|
+
scope = Anomonitor::MetricSample
|
|
75
|
+
.where(source: point.source, metric: point.metric)
|
|
76
|
+
.where("sampled_at >= ? AND sampled_at < ?", (window * 2).seconds.ago, window.seconds.ago)
|
|
77
|
+
.order(sampled_at: :desc)
|
|
78
|
+
|
|
79
|
+
# Pull a wider window then filter tags in Ruby (portable across JSON/text adapters)
|
|
80
|
+
candidates = scope.limit(100).to_a
|
|
81
|
+
if queue
|
|
82
|
+
candidates.select! { |s| (s.tags || {})["queue"] == queue || (s.tags || {})[:queue] == queue }
|
|
83
|
+
end
|
|
84
|
+
if tenant
|
|
85
|
+
candidates.select! { |s| (s.tags || {})["tenant"] == tenant || (s.tags || {})[:tenant] == tenant }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
values = candidates.first(5).map(&:value)
|
|
89
|
+
return nil if values.empty?
|
|
90
|
+
|
|
91
|
+
values.sum / values.size.to_f
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def spike_window_seconds
|
|
97
|
+
rule = Anomonitor.config.alerts.find(&:spike?)
|
|
98
|
+
rule ? rule.window_seconds.to_i : 300
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create_anomaly(rule, point, threshold:, reason:, extra: {})
|
|
102
|
+
key = "#{reason}:#{point.cooldown_key}"
|
|
103
|
+
@active_sticky_keys << key if point.sticky?
|
|
104
|
+
|
|
105
|
+
return nil if cooling_down?(key, point)
|
|
106
|
+
|
|
107
|
+
tenant = point.tags[:tenant] || point.tags["tenant"]
|
|
108
|
+
if Mute.muted?(metric: point.metric, rule: reason, source: point.source, tenant: tenant)
|
|
109
|
+
Anomonitor.logger.info("[Anomonitor] Muted anomaly #{reason}:#{point.source}/#{point.metric}")
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
anomaly = Anomonitor::Anomaly.create!(
|
|
114
|
+
rule: reason,
|
|
115
|
+
source: point.source,
|
|
116
|
+
metric: point.metric,
|
|
117
|
+
value: point.value,
|
|
118
|
+
threshold: threshold,
|
|
119
|
+
severity: rule.severity,
|
|
120
|
+
cooldown_key: key,
|
|
121
|
+
tags: point.tags.merge(extra),
|
|
122
|
+
sampled_at: point.sampled_at,
|
|
123
|
+
webhook_status: "pending",
|
|
124
|
+
resolved_at: nil
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if Anomonitor.config.digest_enabled?
|
|
128
|
+
Digester.record(anomaly)
|
|
129
|
+
else
|
|
130
|
+
delivered = @notifier.deliver(anomaly)
|
|
131
|
+
anomaly.update!(
|
|
132
|
+
webhook_status: delivered ? "delivered" : "failed",
|
|
133
|
+
webhook_delivered_at: delivered ? Time.current : nil
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
anomaly
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
Anomonitor.logger.warn("[Anomonitor] Failed to record anomaly: #{e.message}")
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def cooling_down?(key, point = nil)
|
|
143
|
+
if point&.sticky?
|
|
144
|
+
return true if Anomonitor::Anomaly.where(cooldown_key: key, resolved_at: nil).exists?
|
|
145
|
+
|
|
146
|
+
# Manual ack: stay silent for this fingerprint until a clear poll marks cleared_at
|
|
147
|
+
latest = Anomonitor::Anomaly.where(cooldown_key: key).order(created_at: :desc).first
|
|
148
|
+
latest&.manual_resolve? && !latest.cleared_after_ack?
|
|
149
|
+
else
|
|
150
|
+
cooldown = Anomonitor.config.cooldown.to_i
|
|
151
|
+
Anomonitor::Anomaly
|
|
152
|
+
.where(cooldown_key: key)
|
|
153
|
+
.where("created_at >= ?", cooldown.seconds.ago)
|
|
154
|
+
.exists?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def note_sticky_observation(point)
|
|
159
|
+
@observed_sticky_bases << sticky_base(point)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sticky_base(point)
|
|
163
|
+
tenant = point.tags[:tenant] || point.tags["tenant"]
|
|
164
|
+
[point.source, point.metric, tenant].compact.join(":")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def anomaly_sticky_base(anomaly)
|
|
168
|
+
tags = anomaly.tags.is_a?(Hash) ? anomaly.tags : {}
|
|
169
|
+
tenant = tags["tenant"] || tags[:tenant]
|
|
170
|
+
[anomaly.source, anomaly.metric, tenant].compact.join(":")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Resolve open schema-drift alerts only when that tenant/metric was observed this tick
|
|
174
|
+
# and the fingerprint is no longer active (cleared or items changed).
|
|
175
|
+
def resolve_sticky_anomalies(active_keys, observed_bases)
|
|
176
|
+
return if observed_bases.empty?
|
|
177
|
+
|
|
178
|
+
bases = observed_bases.uniq
|
|
179
|
+
Anomonitor::Anomaly.where(source: "schema_drift").find_each do |anomaly|
|
|
180
|
+
next unless bases.include?(anomaly_sticky_base(anomaly))
|
|
181
|
+
next if active_keys.include?(anomaly.cooldown_key)
|
|
182
|
+
|
|
183
|
+
if anomaly.open?
|
|
184
|
+
anomaly.update!(resolved_at: Time.current)
|
|
185
|
+
@notifier.deliver(anomaly, event: Notifiers::RESOLVED)
|
|
186
|
+
elsif anomaly.manual_resolve? && !anomaly.cleared_after_ack?
|
|
187
|
+
anomaly.merge_tags!("cleared_at" => Time.current.iso8601)
|
|
188
|
+
anomaly.save!
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
Anomonitor.logger.warn("[Anomonitor] Failed to resolve sticky anomalies: #{e.message}")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
# Batches anomaly.detected notifications into a single anomaly.digest payload.
|
|
5
|
+
class Digester
|
|
6
|
+
def self.record(anomaly)
|
|
7
|
+
new.record(anomaly)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.flush_if_due!(notifier: nil)
|
|
11
|
+
new(notifier: notifier).flush_if_due!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(notifier: nil)
|
|
15
|
+
@notifier = notifier || Anomonitor.config.build_notifier
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(anomaly)
|
|
19
|
+
anomaly.update!(webhook_status: "queued")
|
|
20
|
+
true
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
Anomonitor.logger.warn("[Anomonitor] Digester record failed: #{e.message}")
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flush_if_due!
|
|
27
|
+
interval = Anomonitor.config.digest_interval.to_i
|
|
28
|
+
return 0 if interval <= 0
|
|
29
|
+
|
|
30
|
+
last = Anomonitor.config.digest_last_flushed_at
|
|
31
|
+
return 0 if last && Time.current - last < interval
|
|
32
|
+
|
|
33
|
+
flush!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def flush!
|
|
37
|
+
queued = Anomonitor::Anomaly.where(webhook_status: "queued").order(:created_at).limit(100).to_a
|
|
38
|
+
return 0 if queued.empty?
|
|
39
|
+
|
|
40
|
+
path = Anomonitor.config.dashboard_path.to_s.sub(%r{/+\z}, "")
|
|
41
|
+
base = Anomonitor.config.dashboard_base_url.to_s.strip.sub(%r{/+\z}, "")
|
|
42
|
+
dash = base.empty? ? "#{path}/anomalies" : "#{base}#{path}/anomalies"
|
|
43
|
+
|
|
44
|
+
payload = {
|
|
45
|
+
gem: "anomonitor",
|
|
46
|
+
event: Notifiers::DIGEST,
|
|
47
|
+
count: queued.size,
|
|
48
|
+
dashboard_url: dash,
|
|
49
|
+
anomalies: queued.map { |a| Notifiers.payload(a, event: Notifiers::DETECTED) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
delivered = deliver_digest(payload)
|
|
53
|
+
queued.each do |anomaly|
|
|
54
|
+
anomaly.update!(
|
|
55
|
+
webhook_status: delivered ? "delivered" : "failed",
|
|
56
|
+
webhook_delivered_at: delivered ? Time.current : nil
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
Anomonitor.config.digest_last_flushed_at = Time.current
|
|
60
|
+
queued.size
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Anomonitor.logger.warn("[Anomonitor] Digester flush failed: #{e.message}")
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def deliver_digest(payload)
|
|
69
|
+
if @notifier.respond_to?(:deliver_digest)
|
|
70
|
+
@notifier.deliver_digest(payload)
|
|
71
|
+
else
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
Anomonitor.logger.warn("[Anomonitor] Digest deliver error: #{e.message}")
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Anomonitor
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :minitest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "anomonitor.assets" do |app|
|
|
12
|
+
if app.config.respond_to?(:assets)
|
|
13
|
+
app.config.assets.precompile += %w[anomonitor/application.css]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Migrations are installed into the host via:
|
|
18
|
+
# rails anomonitor:install:migrations
|
|
19
|
+
# Do not also append the engine migrate path (duplicates + breaks
|
|
20
|
+
# Apartment apps that wrap migrations with public-only guards).
|
|
21
|
+
|
|
22
|
+
config.after_initialize do
|
|
23
|
+
Anomonitor::ConfigurationValidator.new(Anomonitor.config).log!
|
|
24
|
+
|
|
25
|
+
# :thread starts an in-process poller; :cron relies on `rails anomonitor:poll`
|
|
26
|
+
next unless Anomonitor.config.poll_mode == :thread
|
|
27
|
+
next unless Anomonitor.config.auto_start
|
|
28
|
+
next if defined?(Rails::Console)
|
|
29
|
+
next if File.basename($PROGRAM_NAME).include?("rake")
|
|
30
|
+
next if Rails.env.test?
|
|
31
|
+
|
|
32
|
+
Anomonitor::Poller.instance.start
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
# Fetches live job rows from every enabled job collector backend.
|
|
6
|
+
class Browser
|
|
7
|
+
DEFAULT_LIMIT = 100
|
|
8
|
+
STATUSES = %w[all pending failed locked].freeze
|
|
9
|
+
|
|
10
|
+
def self.fetch(filters = {})
|
|
11
|
+
new(filters).fetch
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.enabled_sources
|
|
15
|
+
new.enabled_sources
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.status_options(source = nil)
|
|
19
|
+
if source.to_s.start_with?("table:")
|
|
20
|
+
name = source.to_s.delete_prefix("table:")
|
|
21
|
+
table = Anomonitor.config.tables.find { |t| t.name.to_s == name }
|
|
22
|
+
return %w[all pending failed] if table && !table.delayed_job_style?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
STATUSES
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(filters = {})
|
|
29
|
+
@filters = normalize(filters)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fetch
|
|
33
|
+
rows = []
|
|
34
|
+
adapters.each do |adapter|
|
|
35
|
+
next if @filters[:source] && adapter.source_key != @filters[:source]
|
|
36
|
+
|
|
37
|
+
rows.concat(Array(adapter.fetch(@filters)))
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Anomonitor.logger.warn("[Anomonitor] Jobs browser (#{adapter.source_key}) error: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
rows = filter_query(rows)
|
|
43
|
+
rows.sort_by { |r| r.sort_at }.reverse.first(@filters[:limit])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enabled_sources
|
|
47
|
+
adapters.map(&:source_key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def filter_query(rows)
|
|
53
|
+
q = @filters[:q]
|
|
54
|
+
return rows if q.nil? || q.empty?
|
|
55
|
+
|
|
56
|
+
needle = q.downcase
|
|
57
|
+
rows.select { |r| r.name.to_s.downcase.include?(needle) || r.id.to_s.downcase.include?(needle) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize(filters)
|
|
61
|
+
raw = filters.respond_to?(:to_unsafe_h) ? filters.to_unsafe_h : filters
|
|
62
|
+
raw = raw.to_h.transform_keys(&:to_sym)
|
|
63
|
+
status = raw[:status].to_s
|
|
64
|
+
status = "all" unless STATUSES.include?(status)
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
source: raw[:source].presence,
|
|
68
|
+
status: status,
|
|
69
|
+
tenant: raw[:tenant].presence,
|
|
70
|
+
queue: raw[:queue].presence,
|
|
71
|
+
q: raw[:q].to_s.strip.presence,
|
|
72
|
+
limit: (raw[:limit] || DEFAULT_LIMIT).to_i.clamp(1, 500)
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def adapters
|
|
77
|
+
list = []
|
|
78
|
+
cfg = Anomonitor.config.collectors
|
|
79
|
+
list << Browsers::Sidekiq.new if cfg.sidekiq
|
|
80
|
+
list << Browsers::DelayedJob.new if cfg.delayed_job
|
|
81
|
+
list << Browsers::SolidQueue.new if cfg.solid_queue
|
|
82
|
+
Anomonitor.config.tables.each do |table|
|
|
83
|
+
list << Browsers::Table.new(table)
|
|
84
|
+
end
|
|
85
|
+
list
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|