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