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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +90 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +275 -0
  5. data/RELEASE.md +32 -0
  6. data/Rakefile +13 -0
  7. data/SECURITY.md +24 -0
  8. data/app/assets/config/anomonitor_manifest.js +1 -0
  9. data/app/assets/stylesheets/anomonitor/application.css +370 -0
  10. data/app/controllers/anomonitor/anomalies_controller.rb +44 -0
  11. data/app/controllers/anomonitor/application_controller.rb +21 -0
  12. data/app/controllers/anomonitor/dashboard_controller.rb +69 -0
  13. data/app/controllers/anomonitor/jobs_controller.rb +61 -0
  14. data/app/controllers/anomonitor/metrics_controller.rb +18 -0
  15. data/app/controllers/anomonitor/mutes_controller.rb +41 -0
  16. data/app/helpers/anomonitor/application_helper.rb +71 -0
  17. data/app/models/anomonitor/anomaly.rb +105 -0
  18. data/app/models/anomonitor/application_record.rb +5 -0
  19. data/app/models/anomonitor/metric_sample.rb +25 -0
  20. data/app/models/anomonitor/mute.rb +56 -0
  21. data/app/views/anomonitor/anomalies/index.html.erb +54 -0
  22. data/app/views/anomonitor/anomalies/show.html.erb +54 -0
  23. data/app/views/anomonitor/dashboard/show.html.erb +124 -0
  24. data/app/views/anomonitor/jobs/index.html.erb +135 -0
  25. data/app/views/anomonitor/metrics/index.html.erb +33 -0
  26. data/app/views/anomonitor/mutes/index.html.erb +56 -0
  27. data/app/views/layouts/anomonitor/application.html.erb +39 -0
  28. data/config/routes.rb +15 -0
  29. data/db/migrate/20260807100000_create_anomonitor_metric_samples.rb +17 -0
  30. data/db/migrate/20260807100001_create_anomonitor_anomalies.rb +23 -0
  31. data/db/migrate/20260808100000_add_resolved_at_to_anomonitor_anomalies.rb +6 -0
  32. data/db/migrate/20260808120000_create_anomonitor_mutes.rb +16 -0
  33. data/lib/anomonitor/collectors/base.rb +25 -0
  34. data/lib/anomonitor/collectors/delayed_job.rb +55 -0
  35. data/lib/anomonitor/collectors/schema_drift.rb +144 -0
  36. data/lib/anomonitor/collectors/sidekiq.rb +47 -0
  37. data/lib/anomonitor/collectors/solid_queue.rb +57 -0
  38. data/lib/anomonitor/collectors/table.rb +116 -0
  39. data/lib/anomonitor/configuration.rb +184 -0
  40. data/lib/anomonitor/configuration_validator.rb +40 -0
  41. data/lib/anomonitor/detector.rb +195 -0
  42. data/lib/anomonitor/digester.rb +79 -0
  43. data/lib/anomonitor/engine.rb +35 -0
  44. data/lib/anomonitor/jobs/browser.rb +89 -0
  45. data/lib/anomonitor/jobs/browsers/delayed_job.rb +143 -0
  46. data/lib/anomonitor/jobs/browsers/sidekiq.rb +164 -0
  47. data/lib/anomonitor/jobs/browsers/solid_queue.rb +149 -0
  48. data/lib/anomonitor/jobs/browsers/table.rb +206 -0
  49. data/lib/anomonitor/jobs/row.rb +30 -0
  50. data/lib/anomonitor/metric_point.rb +42 -0
  51. data/lib/anomonitor/metrics_export.rb +55 -0
  52. data/lib/anomonitor/notifiers/callable.rb +44 -0
  53. data/lib/anomonitor/notifiers/composite.rb +33 -0
  54. data/lib/anomonitor/notifiers/rate_limited.rb +44 -0
  55. data/lib/anomonitor/notifiers/webhook.rb +108 -0
  56. data/lib/anomonitor/notifiers.rb +59 -0
  57. data/lib/anomonitor/poll_lock.rb +67 -0
  58. data/lib/anomonitor/poller.rb +164 -0
  59. data/lib/anomonitor/tenancy.rb +46 -0
  60. data/lib/anomonitor/version.rb +3 -0
  61. data/lib/anomonitor.rb +52 -0
  62. data/lib/generators/anomonitor/install/install_generator.rb +27 -0
  63. data/lib/generators/anomonitor/install/templates/anomonitor.rb +87 -0
  64. data/lib/tasks/anomonitor_tasks.rake +27 -0
  65. 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
@@ -0,0 +1,3 @@
1
+ module Anomonitor
2
+ VERSION = "0.6.1"
3
+ end