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,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Anomonitor
|
|
6
|
+
MetricPoint = Struct.new(:source, :metric, :value, :tags, :sampled_at, keyword_init: true) do
|
|
7
|
+
def initialize(source:, metric:, value:, tags: {}, sampled_at: Time.current)
|
|
8
|
+
super(
|
|
9
|
+
source: source.to_s,
|
|
10
|
+
metric: metric.to_s,
|
|
11
|
+
value: value.to_f,
|
|
12
|
+
tags: tags || {},
|
|
13
|
+
sampled_at: sampled_at
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sticky?
|
|
18
|
+
source == "schema_drift"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cooldown_key
|
|
22
|
+
tenant = tags[:tenant] || tags["tenant"]
|
|
23
|
+
queue = tags[:queue] || tags["queue"]
|
|
24
|
+
parts = [source, metric, tenant, queue]
|
|
25
|
+
if sticky?
|
|
26
|
+
digest = sticky_items_digest
|
|
27
|
+
parts << digest if digest
|
|
28
|
+
end
|
|
29
|
+
parts.compact.join(":")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sticky_items_digest
|
|
33
|
+
digest = tags[:items_digest] || tags["items_digest"]
|
|
34
|
+
return digest.to_s if digest && !digest.to_s.empty?
|
|
35
|
+
|
|
36
|
+
items = tags[:items] || tags["items"]
|
|
37
|
+
return nil if items.nil? || items.to_s.empty?
|
|
38
|
+
|
|
39
|
+
Digest::SHA256.hexdigest(items.to_s)[0, 16]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module MetricsExport
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def json_payload
|
|
8
|
+
latest = MetricSample.latest_by_source_metric
|
|
9
|
+
open_count = Anomaly.open.count
|
|
10
|
+
{
|
|
11
|
+
gem: "anomonitor",
|
|
12
|
+
version: Anomonitor::VERSION,
|
|
13
|
+
sampled_at: Time.current.iso8601,
|
|
14
|
+
open_anomalies: open_count,
|
|
15
|
+
metrics: latest.map do |s|
|
|
16
|
+
{
|
|
17
|
+
source: s.source,
|
|
18
|
+
metric: s.metric,
|
|
19
|
+
value: s.value,
|
|
20
|
+
tags: s.tags,
|
|
21
|
+
sampled_at: s.sampled_at&.iso8601
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def prometheus_text
|
|
28
|
+
lines = []
|
|
29
|
+
lines << "# HELP anomonitor_open_anomalies Number of unresolved anomalies"
|
|
30
|
+
lines << "# TYPE anomonitor_open_anomalies gauge"
|
|
31
|
+
lines << "anomonitor_open_anomalies #{Anomaly.open.count}"
|
|
32
|
+
|
|
33
|
+
lines << "# HELP anomonitor_metric Latest metric sample value"
|
|
34
|
+
lines << "# TYPE anomonitor_metric gauge"
|
|
35
|
+
MetricSample.latest_by_source_metric.each do |s|
|
|
36
|
+
labels = { source: s.source, metric: s.metric }
|
|
37
|
+
tags = s.tags.is_a?(Hash) ? s.tags : {}
|
|
38
|
+
%w[tenant queue table].each do |key|
|
|
39
|
+
val = tags[key] || tags[key.to_sym]
|
|
40
|
+
labels[key] = val if val && !val.to_s.empty?
|
|
41
|
+
end
|
|
42
|
+
lines << "anomonitor_metric{#{prometheus_labels(labels)}} #{s.value.to_f}"
|
|
43
|
+
end
|
|
44
|
+
"#{lines.join("\n")}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prometheus_labels(hash)
|
|
48
|
+
hash.map { |k, v| "#{k}=\"#{escape_label(v)}\"" }.join(",")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def escape_label(value)
|
|
52
|
+
value.to_s.gsub("\\", "\\\\").gsub("\n", "\\n").gsub('"', '\\"')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Notifiers
|
|
5
|
+
class Callable
|
|
6
|
+
def initialize(callable)
|
|
7
|
+
@callable = callable
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def deliver(anomaly, event: DETECTED)
|
|
11
|
+
result =
|
|
12
|
+
if accepts_event_kw?
|
|
13
|
+
@callable.call(anomaly, event: event.to_s)
|
|
14
|
+
else
|
|
15
|
+
@callable.call(anomaly)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
normalize_result(result)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Anomonitor.logger.warn("[Anomonitor] Custom notifier error: #{e.message}")
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def accepts_event_kw?
|
|
27
|
+
params = @callable.parameters
|
|
28
|
+
params.any? { |type, name| name == :event && %i[key keyreq keyrest].include?(type) } ||
|
|
29
|
+
params.any? { |type, _| type == :keyrest }
|
|
30
|
+
rescue StandardError
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_result(result)
|
|
35
|
+
case result
|
|
36
|
+
when true, false then result
|
|
37
|
+
when Hash then result[:accepted] == true || result["accepted"] == true
|
|
38
|
+
else
|
|
39
|
+
!result.nil?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Notifiers
|
|
5
|
+
class Composite
|
|
6
|
+
def initialize(notifiers)
|
|
7
|
+
@notifiers = Array(notifiers)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def deliver(anomaly, event: DETECTED)
|
|
11
|
+
results = @notifiers.map do |notifier|
|
|
12
|
+
notifier.deliver(anomaly, event: event)
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
Anomonitor.logger.warn("[Anomonitor] Notifier #{notifier.class} error: #{e.message}")
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
results.any?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deliver_digest(payload)
|
|
21
|
+
results = @notifiers.map do |notifier|
|
|
22
|
+
next false unless notifier.respond_to?(:deliver_digest)
|
|
23
|
+
|
|
24
|
+
notifier.deliver_digest(payload)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Anomonitor.logger.warn("[Anomonitor] Digest notifier #{notifier.class} error: #{e.message}")
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
results.any?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Notifiers
|
|
5
|
+
class RateLimited
|
|
6
|
+
def initialize(notifier, limit_per_minute:)
|
|
7
|
+
@notifier = notifier
|
|
8
|
+
@limit = limit_per_minute.to_i
|
|
9
|
+
@timestamps = []
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deliver(anomaly, event: DETECTED)
|
|
14
|
+
return false unless allow?
|
|
15
|
+
|
|
16
|
+
@notifier.deliver(anomaly, event: event)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deliver_digest(payload)
|
|
20
|
+
return false unless allow?
|
|
21
|
+
return @notifier.deliver_digest(payload) if @notifier.respond_to?(:deliver_digest)
|
|
22
|
+
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def allow?
|
|
29
|
+
return true if @limit <= 0
|
|
30
|
+
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
cutoff = Time.current - 60
|
|
33
|
+
@timestamps.reject! { |t| t < cutoff }
|
|
34
|
+
if @timestamps.size >= @limit
|
|
35
|
+
Anomonitor.logger.warn("[Anomonitor] Notifier rate limit reached (#{@limit}/min)")
|
|
36
|
+
return false
|
|
37
|
+
end
|
|
38
|
+
@timestamps << Time.current
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Anomonitor
|
|
8
|
+
module Notifiers
|
|
9
|
+
class Webhook
|
|
10
|
+
def initialize(url: Anomonitor.config.webhook_url)
|
|
11
|
+
@url = url
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def deliver(anomaly, event: DETECTED)
|
|
15
|
+
return false if @url.nil? || @url.to_s.strip.empty?
|
|
16
|
+
|
|
17
|
+
payload = build_payload(anomaly, event.to_s)
|
|
18
|
+
response = post_json(payload)
|
|
19
|
+
success = response.is_a?(Net::HTTPSuccess)
|
|
20
|
+
|
|
21
|
+
unless success
|
|
22
|
+
Anomonitor.logger.warn(
|
|
23
|
+
"[Anomonitor] Webhook failed: HTTP #{response&.code} #{response&.body}"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
success
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Anomonitor.logger.warn("[Anomonitor] Webhook error: #{e.message}")
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def deliver_digest(payload)
|
|
34
|
+
return false if @url.nil? || @url.to_s.strip.empty?
|
|
35
|
+
|
|
36
|
+
body =
|
|
37
|
+
if slack_incoming_webhook?
|
|
38
|
+
count = payload[:count] || payload["count"] || 0
|
|
39
|
+
{ text: "*Anomonitor* digest — #{count} anomalies <#{Anomonitor.config.dashboard_path}/anomalies>" }
|
|
40
|
+
else
|
|
41
|
+
payload
|
|
42
|
+
end
|
|
43
|
+
response = post_json(body)
|
|
44
|
+
response.is_a?(Net::HTTPSuccess)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Anomonitor.logger.warn("[Anomonitor] Digest webhook error: #{e.message}")
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_payload(anomaly, event)
|
|
53
|
+
if slack_incoming_webhook?
|
|
54
|
+
{ text: slack_text(anomaly, event) }
|
|
55
|
+
else
|
|
56
|
+
Notifiers.payload(anomaly, event: event)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def slack_incoming_webhook?
|
|
61
|
+
@url.to_s.include?("hooks.slack.com")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def slack_text(anomaly, event)
|
|
65
|
+
threshold = anomaly.threshold.nil? ? "n/a" : anomaly.threshold
|
|
66
|
+
dashboard = Anomonitor.config.anomaly_dashboard_url(anomaly.id)
|
|
67
|
+
tags = anomaly.tags.is_a?(Hash) ? anomaly.tags : {}
|
|
68
|
+
tenant = tags["tenant"] || tags[:tenant]
|
|
69
|
+
items = tags["items"] || tags[:items]
|
|
70
|
+
|
|
71
|
+
parts = ["*Anomonitor*"]
|
|
72
|
+
parts << if event == RESOLVED
|
|
73
|
+
"resolved"
|
|
74
|
+
elsif event == ACKED
|
|
75
|
+
"acked"
|
|
76
|
+
else
|
|
77
|
+
anomaly.severity.to_s
|
|
78
|
+
end
|
|
79
|
+
parts << "tenant `#{tenant}`" if tenant && !tenant.to_s.empty?
|
|
80
|
+
parts << "— #{anomaly.rule} on #{anomaly.source}/#{anomaly.metric}:"
|
|
81
|
+
parts << "#{anomaly.value} (threshold #{threshold})"
|
|
82
|
+
parts << "items: #{items}" if items && !items.to_s.empty?
|
|
83
|
+
parts << "<#{dashboard}>"
|
|
84
|
+
parts.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def post_json(payload)
|
|
88
|
+
uri = URI.parse(@url)
|
|
89
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
90
|
+
http.use_ssl = uri.scheme == "https"
|
|
91
|
+
http.open_timeout = 5
|
|
92
|
+
http.read_timeout = 10
|
|
93
|
+
|
|
94
|
+
request = Net::HTTP::Post.new(uri)
|
|
95
|
+
request["Content-Type"] = "application/json"
|
|
96
|
+
request["User-Agent"] = "anomonitor/#{Anomonitor::VERSION}"
|
|
97
|
+
request.body = JSON.generate(payload)
|
|
98
|
+
|
|
99
|
+
response = http.request(request)
|
|
100
|
+
if response.is_a?(Net::HTTPServerError)
|
|
101
|
+
sleep 0.5
|
|
102
|
+
response = http.request(request)
|
|
103
|
+
end
|
|
104
|
+
response
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Notifiers
|
|
5
|
+
DETECTED = "anomaly.detected"
|
|
6
|
+
RESOLVED = "anomaly.resolved"
|
|
7
|
+
ACKED = "anomaly.acked"
|
|
8
|
+
DIGEST = "anomaly.digest"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def build(config = Anomonitor.config)
|
|
13
|
+
raw = config.notifier
|
|
14
|
+
notifier =
|
|
15
|
+
if raw.nil?
|
|
16
|
+
Webhook.new
|
|
17
|
+
else
|
|
18
|
+
list = Array(raw).map { |entry| wrap(entry) }
|
|
19
|
+
list.size == 1 ? list.first : Composite.new(list)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
limit = config.notifier_rate_limit.to_i
|
|
23
|
+
limit.positive? ? RateLimited.new(notifier, limit_per_minute: limit) : notifier
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wrap(entry)
|
|
27
|
+
case entry
|
|
28
|
+
when Class
|
|
29
|
+
entry.new
|
|
30
|
+
else
|
|
31
|
+
if entry.respond_to?(:deliver)
|
|
32
|
+
entry
|
|
33
|
+
elsif entry.respond_to?(:call)
|
|
34
|
+
Callable.new(entry)
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"Anomonitor notifier must respond to #deliver or #call (got #{entry.class})"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def payload(anomaly, event: DETECTED)
|
|
43
|
+
{
|
|
44
|
+
gem: "anomonitor",
|
|
45
|
+
event: event.to_s,
|
|
46
|
+
severity: anomaly.severity,
|
|
47
|
+
rule: anomaly.rule,
|
|
48
|
+
source: anomaly.source,
|
|
49
|
+
metric: anomaly.metric,
|
|
50
|
+
value: anomaly.value,
|
|
51
|
+
threshold: anomaly.threshold,
|
|
52
|
+
sampled_at: anomaly.sampled_at&.iso8601,
|
|
53
|
+
resolved_at: anomaly.resolved_at&.iso8601,
|
|
54
|
+
dashboard_url: Anomonitor.config.anomaly_dashboard_url(anomaly.id),
|
|
55
|
+
tags: anomaly.tags
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
class PollLock
|
|
5
|
+
LOCK_KEY = 8_604_071_000 # stable advisory lock id for Anomonitor
|
|
6
|
+
LOCK_FILE = "tmp/anomonitor_poll.lock"
|
|
7
|
+
|
|
8
|
+
def self.with_lock(enabled: Anomonitor.config.poll_lock)
|
|
9
|
+
return yield unless enabled
|
|
10
|
+
|
|
11
|
+
new.with_lock { yield }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def with_lock
|
|
15
|
+
if postgres?
|
|
16
|
+
with_pg_lock { yield }
|
|
17
|
+
else
|
|
18
|
+
with_file_lock { yield }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def postgres?
|
|
25
|
+
return false unless defined?(ActiveRecord::Base)
|
|
26
|
+
|
|
27
|
+
adapter = ActiveRecord::Base.connection.adapter_name.to_s.downcase
|
|
28
|
+
adapter.include?("postgres")
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_pg_lock
|
|
34
|
+
conn = ActiveRecord::Base.connection
|
|
35
|
+
locked = conn.select_value("SELECT pg_try_advisory_lock(#{LOCK_KEY})")
|
|
36
|
+
unless locked == true || locked == "t" || locked == 1
|
|
37
|
+
Anomonitor.logger.info("[Anomonitor] Poll skipped — advisory lock held by another process")
|
|
38
|
+
return []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
begin
|
|
44
|
+
conn&.execute("SELECT pg_advisory_unlock(#{LOCK_KEY})")
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_file_lock
|
|
51
|
+
path = lock_path
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
53
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
54
|
+
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
55
|
+
Anomonitor.logger.info("[Anomonitor] Poll skipped — file lock held by another process")
|
|
56
|
+
return []
|
|
57
|
+
end
|
|
58
|
+
yield
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def lock_path
|
|
63
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Dir.pwd
|
|
64
|
+
File.join(root.to_s, LOCK_FILE)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Anomonitor
|
|
7
|
+
class Poller
|
|
8
|
+
include Singleton
|
|
9
|
+
|
|
10
|
+
attr_reader :last_run_at, :last_error, :running
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@thread = nil
|
|
15
|
+
@running = false
|
|
16
|
+
@last_run_at = nil
|
|
17
|
+
@last_error = nil
|
|
18
|
+
@collector_status = {}
|
|
19
|
+
@last_schema_drift_at = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
return if @running
|
|
25
|
+
|
|
26
|
+
@running = true
|
|
27
|
+
@thread = Thread.new { loop_poll }
|
|
28
|
+
@thread.abort_on_exception = false
|
|
29
|
+
Anomonitor.logger.info("[Anomonitor] Poller started (interval=#{Anomonitor.config.poll_interval}s)")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stop
|
|
34
|
+
thread = nil
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@running = false
|
|
37
|
+
thread = @thread
|
|
38
|
+
@thread = nil
|
|
39
|
+
end
|
|
40
|
+
return unless thread
|
|
41
|
+
|
|
42
|
+
# Prefer a cooperative stop over Thread#kill mid-insert/webhook
|
|
43
|
+
thread.join(Anomonitor.config.poll_interval.to_i + 5)
|
|
44
|
+
thread.kill if thread.alive?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def tick
|
|
48
|
+
PollLock.with_lock do
|
|
49
|
+
points = collect_all
|
|
50
|
+
persist(points)
|
|
51
|
+
Detector.new.evaluate(points)
|
|
52
|
+
Mute.prune_expired! if defined?(Anomonitor::Mute)
|
|
53
|
+
prune_old_records
|
|
54
|
+
@last_run_at = Time.current
|
|
55
|
+
@last_error = nil
|
|
56
|
+
points
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
@last_error = e.message
|
|
60
|
+
Anomonitor.logger.warn("[Anomonitor] Poller tick failed: #{e.message}")
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def status
|
|
65
|
+
{
|
|
66
|
+
poll_mode: Anomonitor.config.poll_mode,
|
|
67
|
+
running: @running,
|
|
68
|
+
last_run_at: effective_last_run_at,
|
|
69
|
+
last_error: @last_error,
|
|
70
|
+
collectors: @collector_status,
|
|
71
|
+
poll_interval: Anomonitor.config.poll_interval,
|
|
72
|
+
schema_drift_interval: Anomonitor.config.schema_drift_interval,
|
|
73
|
+
poll_lock: Anomonitor.config.poll_lock
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def effective_last_run_at
|
|
80
|
+
return @last_run_at if @last_run_at
|
|
81
|
+
return nil unless defined?(Anomonitor::MetricSample)
|
|
82
|
+
|
|
83
|
+
Anomonitor::MetricSample.maximum(:sampled_at)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def loop_poll
|
|
89
|
+
while @running
|
|
90
|
+
tick
|
|
91
|
+
sleep Anomonitor.config.poll_interval.to_i
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collect_all
|
|
96
|
+
points = []
|
|
97
|
+
collectors.each do |name, collector|
|
|
98
|
+
result = Array(collector.collect)
|
|
99
|
+
@collector_status[name] = { ok: true, count: result.size, at: Time.current }
|
|
100
|
+
@last_schema_drift_at = Time.current if name == :schema_drift
|
|
101
|
+
points.concat(result)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
@collector_status[name] = { ok: false, error: e.message, at: Time.current }
|
|
104
|
+
Anomonitor.logger.warn("[Anomonitor] Collector #{name} failed: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
points
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collectors
|
|
110
|
+
list = {}
|
|
111
|
+
cfg = Anomonitor.config.collectors
|
|
112
|
+
list[:sidekiq] = Collectors::Sidekiq.new if cfg.sidekiq
|
|
113
|
+
list[:delayed_job] = Collectors::DelayedJob.new if cfg.delayed_job
|
|
114
|
+
list[:solid_queue] = Collectors::SolidQueue.new if cfg.solid_queue
|
|
115
|
+
list[:schema_drift] = Collectors::SchemaDrift.new if cfg.schema_drift && schema_drift_due?
|
|
116
|
+
Anomonitor.config.tables.each do |table|
|
|
117
|
+
list[:"table_#{table.name}"] = Collectors::Table.new(table)
|
|
118
|
+
end
|
|
119
|
+
list
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def schema_drift_due?
|
|
123
|
+
interval = Anomonitor.config.schema_drift_interval.to_i
|
|
124
|
+
interval = Anomonitor.config.poll_interval.to_i if interval <= 0
|
|
125
|
+
return true if @last_schema_drift_at.nil?
|
|
126
|
+
|
|
127
|
+
Time.current - @last_schema_drift_at >= interval
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def persist(points)
|
|
131
|
+
return if points.empty?
|
|
132
|
+
return unless defined?(Anomonitor::MetricSample)
|
|
133
|
+
|
|
134
|
+
rows = points.map do |p|
|
|
135
|
+
{
|
|
136
|
+
source: p.source,
|
|
137
|
+
metric: p.metric,
|
|
138
|
+
value: p.value,
|
|
139
|
+
tags: p.tags,
|
|
140
|
+
sampled_at: p.sampled_at,
|
|
141
|
+
created_at: Time.current,
|
|
142
|
+
updated_at: Time.current
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
Anomonitor::MetricSample.insert_all(rows)
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
Anomonitor.logger.warn("[Anomonitor] Failed to persist metrics: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def prune_old_records
|
|
151
|
+
days = Anomonitor.config.retention_days.to_i
|
|
152
|
+
return if days <= 0
|
|
153
|
+
|
|
154
|
+
cutoff = days.days.ago
|
|
155
|
+
Anomonitor::MetricSample.where("sampled_at < ?", cutoff).delete_all
|
|
156
|
+
Anomonitor::Anomaly
|
|
157
|
+
.where("created_at < ?", cutoff)
|
|
158
|
+
.where("NOT (source = ? AND resolved_at IS NULL)", "schema_drift")
|
|
159
|
+
.delete_all
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
# Resolves tenant schema names and switches connection context.
|
|
5
|
+
#
|
|
6
|
+
# Anomonitor.configure do |c|
|
|
7
|
+
# c.tenants = -> { CustomerTenant.pluck(:name) }
|
|
8
|
+
# c.exclude_tenants = %w[public]
|
|
9
|
+
# c.tenant_switch = ->(name, &block) { Apartment::Tenant.switch(name, &block) }
|
|
10
|
+
# end
|
|
11
|
+
module Tenancy
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def tenant_names
|
|
15
|
+
cfg = Anomonitor.config
|
|
16
|
+
raw = cfg.tenants
|
|
17
|
+
list =
|
|
18
|
+
case raw
|
|
19
|
+
when Proc then Array(raw.call)
|
|
20
|
+
when nil then []
|
|
21
|
+
else Array(raw)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
excluded = Array(cfg.exclude_tenants).map(&:to_s)
|
|
25
|
+
list.map(&:to_s).reject { |name| name.empty? || excluded.include?(name) }.uniq.sort
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Anomonitor.logger.warn("[Anomonitor] Failed to resolve tenants: #{e.message}")
|
|
28
|
+
[]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def switch(name, &block)
|
|
32
|
+
switcher = Anomonitor.config.tenant_switch
|
|
33
|
+
if switcher
|
|
34
|
+
switcher.call(name, &block)
|
|
35
|
+
elsif defined?(::Apartment::Tenant)
|
|
36
|
+
::Apartment::Tenant.switch(name, &block)
|
|
37
|
+
else
|
|
38
|
+
yield
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def multi_tenant?
|
|
43
|
+
tenant_names.any?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|