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,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
module Browsers
|
|
6
|
+
class DelayedJob
|
|
7
|
+
PER_TENANT = 50
|
|
8
|
+
|
|
9
|
+
def source_key
|
|
10
|
+
"delayed_job"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(filters)
|
|
14
|
+
unless defined?(::Delayed::Job)
|
|
15
|
+
Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped delayed_job: Delayed::Job not loaded")
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if Tenancy.multi_tenant?
|
|
20
|
+
fetch_per_tenant(filters)
|
|
21
|
+
else
|
|
22
|
+
fetch_scope(::Delayed::Job, filters, nil)
|
|
23
|
+
end
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Anomonitor.logger.warn("[Anomonitor] Delayed Job browser error: #{e.message}")
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def fetch_per_tenant(filters)
|
|
32
|
+
rows = []
|
|
33
|
+
tenants = Tenancy.tenant_names
|
|
34
|
+
tenants = tenants.select { |t| t == filters[:tenant] } if filters[:tenant]
|
|
35
|
+
tenants.each do |tenant|
|
|
36
|
+
Tenancy.switch(tenant) do
|
|
37
|
+
rows.concat(fetch_scope(::Delayed::Job, filters, tenant))
|
|
38
|
+
end
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Anomonitor.logger.warn("[Anomonitor] Delayed Job browser (#{tenant}) error: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
rows
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch_scope(klass, filters, tenant)
|
|
46
|
+
scope = apply_status(klass.all, filters[:status])
|
|
47
|
+
if filters[:queue] && klass.column_names.include?("queue")
|
|
48
|
+
scope = scope.where(queue: filters[:queue])
|
|
49
|
+
end
|
|
50
|
+
scope = scope.order(Arel.sql("COALESCE(failed_at, run_at, created_at) DESC"))
|
|
51
|
+
scope.limit(PER_TENANT).map { |job| row_for(job, tenant) }
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# SQLite / adapters without Arel.sql coalesce ordering
|
|
54
|
+
scope = apply_status(klass.all, filters[:status])
|
|
55
|
+
if filters[:queue] && klass.column_names.include?("queue")
|
|
56
|
+
scope = scope.where(queue: filters[:queue])
|
|
57
|
+
end
|
|
58
|
+
scope.limit(PER_TENANT).map { |job| row_for(job, tenant) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply_status(scope, status)
|
|
62
|
+
case status
|
|
63
|
+
when "pending"
|
|
64
|
+
scope = scope.where(failed_at: nil).where(locked_at: nil)
|
|
65
|
+
if scope.klass.column_names.include?("run_at")
|
|
66
|
+
scope = scope.where("run_at <= ?", Time.current)
|
|
67
|
+
end
|
|
68
|
+
scope
|
|
69
|
+
when "failed"
|
|
70
|
+
scope.where.not(failed_at: nil)
|
|
71
|
+
when "locked"
|
|
72
|
+
scope.where.not(locked_at: nil).where(failed_at: nil)
|
|
73
|
+
else
|
|
74
|
+
scope
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def row_for(job, tenant)
|
|
79
|
+
name =
|
|
80
|
+
if job.respond_to?(:name) && job.name.present?
|
|
81
|
+
job.name
|
|
82
|
+
elsif job.respond_to?(:handler)
|
|
83
|
+
handler_class(job.handler)
|
|
84
|
+
else
|
|
85
|
+
"Delayed::Job"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
error = nil
|
|
89
|
+
full_error = nil
|
|
90
|
+
if job.respond_to?(:last_error) && job.last_error
|
|
91
|
+
full_error = job.last_error.to_s
|
|
92
|
+
error = full_error.lines.first&.strip
|
|
93
|
+
end
|
|
94
|
+
handler = job.respond_to?(:handler) ? job.handler.to_s : nil
|
|
95
|
+
|
|
96
|
+
Row.new(
|
|
97
|
+
source: source_key,
|
|
98
|
+
id: job.id,
|
|
99
|
+
name: name,
|
|
100
|
+
status: status_for(job),
|
|
101
|
+
queue: job.respond_to?(:queue) ? job.queue : nil,
|
|
102
|
+
tenant: tenant,
|
|
103
|
+
run_at: job.respond_to?(:run_at) ? job.run_at : nil,
|
|
104
|
+
failed_at: job.respond_to?(:failed_at) ? job.failed_at : nil,
|
|
105
|
+
error: truncate(error),
|
|
106
|
+
tags: tenant ? { tenant: tenant } : {},
|
|
107
|
+
detail: {
|
|
108
|
+
"handler" => truncate(handler, 800),
|
|
109
|
+
"error" => truncate(full_error, 1000),
|
|
110
|
+
"attempts" => (job.respond_to?(:attempts) ? job.attempts : nil)
|
|
111
|
+
}.compact
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def status_for(job)
|
|
116
|
+
return "failed" if job.respond_to?(:failed_at) && job.failed_at
|
|
117
|
+
return "locked" if job.respond_to?(:locked_at) && job.locked_at
|
|
118
|
+
|
|
119
|
+
"pending"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def handler_class(handler)
|
|
123
|
+
return "Delayed::Job" if handler.blank?
|
|
124
|
+
|
|
125
|
+
if handler =~ /!ruby\/object:(\S+)/
|
|
126
|
+
Regexp.last_match(1)
|
|
127
|
+
elsif handler =~ /job_class:\s*(\S+)/
|
|
128
|
+
Regexp.last_match(1)
|
|
129
|
+
else
|
|
130
|
+
handler.to_s.lines.first.to_s.strip[0, 80]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def truncate(text, max = 200)
|
|
135
|
+
return nil if text.nil?
|
|
136
|
+
|
|
137
|
+
s = text.to_s
|
|
138
|
+
s.length > max ? "#{s[0, max]}…" : s
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
module Browsers
|
|
6
|
+
class Sidekiq
|
|
7
|
+
PER_SET = 50
|
|
8
|
+
|
|
9
|
+
def source_key
|
|
10
|
+
"sidekiq"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(filters)
|
|
14
|
+
unless defined?(::Sidekiq)
|
|
15
|
+
Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped sidekiq: Sidekiq not loaded")
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
status = filters[:status]
|
|
20
|
+
rows = []
|
|
21
|
+
rows.concat(pending_rows(filters)) if status == "all" || status == "pending"
|
|
22
|
+
rows.concat(locked_rows(filters)) if status == "all" || status == "locked"
|
|
23
|
+
rows.concat(failed_rows(filters)) if status == "all" || status == "failed"
|
|
24
|
+
rows
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Anomonitor.logger.warn("[Anomonitor] Sidekiq jobs browser error: #{e.message}")
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def pending_rows(filters)
|
|
33
|
+
queues = ::Sidekiq::Queue.all
|
|
34
|
+
queues = queues.select { |q| q.name == filters[:queue] } if filters[:queue]
|
|
35
|
+
rows = []
|
|
36
|
+
queues.each do |queue|
|
|
37
|
+
queue.each do |job|
|
|
38
|
+
rows << row_from_job(job, status: "pending", queue: queue.name)
|
|
39
|
+
break if rows.size >= PER_SET
|
|
40
|
+
end
|
|
41
|
+
break if rows.size >= PER_SET
|
|
42
|
+
end
|
|
43
|
+
rows
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def locked_rows(filters)
|
|
47
|
+
return [] unless defined?(::Sidekiq::Workers)
|
|
48
|
+
|
|
49
|
+
rows = []
|
|
50
|
+
::Sidekiq::Workers.new.each do |_process_id, _thread_id, work|
|
|
51
|
+
payload = work.is_a?(Hash) ? work : {}
|
|
52
|
+
job = payload["payload"] || payload[:payload] || payload
|
|
53
|
+
queue = payload["queue"] || payload[:queue] || job["queue"]
|
|
54
|
+
next if filters[:queue] && queue.to_s != filters[:queue]
|
|
55
|
+
|
|
56
|
+
rows << Row.new(
|
|
57
|
+
source: source_key,
|
|
58
|
+
id: job["jid"] || job[:jid],
|
|
59
|
+
name: job["class"] || job[:class] || "Sidekiq::Job",
|
|
60
|
+
status: "locked",
|
|
61
|
+
queue: queue,
|
|
62
|
+
tenant: nil,
|
|
63
|
+
run_at: parse_time(payload["run_at"] || payload[:run_at] || job["enqueued_at"]),
|
|
64
|
+
failed_at: nil,
|
|
65
|
+
error: nil,
|
|
66
|
+
tags: {}
|
|
67
|
+
)
|
|
68
|
+
break if rows.size >= PER_SET
|
|
69
|
+
end
|
|
70
|
+
rows
|
|
71
|
+
rescue StandardError
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def failed_rows(filters)
|
|
76
|
+
rows = []
|
|
77
|
+
if defined?(::Sidekiq::RetrySet)
|
|
78
|
+
::Sidekiq::RetrySet.new.each do |job|
|
|
79
|
+
next if filters[:queue] && job.queue.to_s != filters[:queue]
|
|
80
|
+
|
|
81
|
+
rows << row_from_job(job, status: "failed", queue: job.queue, error: job["error_message"])
|
|
82
|
+
break if rows.size >= PER_SET
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
if defined?(::Sidekiq::DeadSet) && rows.size < PER_SET
|
|
86
|
+
::Sidekiq::DeadSet.new.each do |job|
|
|
87
|
+
next if filters[:queue] && job.queue.to_s != filters[:queue]
|
|
88
|
+
|
|
89
|
+
rows << row_from_job(job, status: "failed", queue: job.queue, error: job["error_message"])
|
|
90
|
+
break if rows.size >= PER_SET
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
rows
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def row_from_job(job, status:, queue:, error: nil)
|
|
97
|
+
klass =
|
|
98
|
+
if job.respond_to?(:display_class)
|
|
99
|
+
job.display_class
|
|
100
|
+
elsif job.respond_to?(:klass)
|
|
101
|
+
job.klass
|
|
102
|
+
else
|
|
103
|
+
job["class"]
|
|
104
|
+
end
|
|
105
|
+
jid = job.respond_to?(:jid) ? job.jid : job["jid"]
|
|
106
|
+
at =
|
|
107
|
+
if job.respond_to?(:enqueued_at) && job.enqueued_at
|
|
108
|
+
parse_time(job.enqueued_at)
|
|
109
|
+
elsif job["enqueued_at"]
|
|
110
|
+
parse_time(job["enqueued_at"])
|
|
111
|
+
elsif job.respond_to?(:created_at)
|
|
112
|
+
parse_time(job.created_at)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
err = error
|
|
116
|
+
err ||= job["error_message"] if job.respond_to?(:[])
|
|
117
|
+
|
|
118
|
+
args =
|
|
119
|
+
if job.respond_to?(:args)
|
|
120
|
+
job.args
|
|
121
|
+
elsif job.respond_to?(:[]) && job["args"]
|
|
122
|
+
job["args"]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
Row.new(
|
|
126
|
+
source: source_key,
|
|
127
|
+
id: jid,
|
|
128
|
+
name: klass.to_s,
|
|
129
|
+
status: status,
|
|
130
|
+
queue: queue,
|
|
131
|
+
tenant: nil,
|
|
132
|
+
run_at: at,
|
|
133
|
+
failed_at: status == "failed" ? at : nil,
|
|
134
|
+
error: truncate(err),
|
|
135
|
+
tags: {},
|
|
136
|
+
detail: {
|
|
137
|
+
"class" => klass.to_s,
|
|
138
|
+
"jid" => jid,
|
|
139
|
+
"args" => truncate(args.inspect, 500),
|
|
140
|
+
"error" => truncate(err, 1000)
|
|
141
|
+
}.compact
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_time(value)
|
|
146
|
+
return nil if value.nil?
|
|
147
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
148
|
+
return Time.at(value) if value.is_a?(Numeric)
|
|
149
|
+
|
|
150
|
+
Time.parse(value.to_s)
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def truncate(text, max = 200)
|
|
156
|
+
return nil if text.nil?
|
|
157
|
+
|
|
158
|
+
s = text.to_s
|
|
159
|
+
s.length > max ? "#{s[0, max]}…" : s
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
module Browsers
|
|
6
|
+
class SolidQueue
|
|
7
|
+
LIMIT = 50
|
|
8
|
+
|
|
9
|
+
def source_key
|
|
10
|
+
"solid_queue"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(filters)
|
|
14
|
+
unless solid_queue_available?
|
|
15
|
+
Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped solid_queue: tables not available")
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
status = filters[:status]
|
|
20
|
+
rows = []
|
|
21
|
+
rows.concat(ready_rows(filters)) if status == "all" || status == "pending"
|
|
22
|
+
rows.concat(scheduled_rows(filters)) if status == "all" || status == "pending"
|
|
23
|
+
rows.concat(claimed_rows(filters)) if status == "all" || status == "locked"
|
|
24
|
+
rows.concat(failed_rows(filters)) if status == "all" || status == "failed"
|
|
25
|
+
rows.first(LIMIT)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Anomonitor.logger.warn("[Anomonitor] Solid Queue jobs browser error: #{e.message}")
|
|
28
|
+
[]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def solid_queue_available?
|
|
34
|
+
return false unless defined?(ActiveRecord::Base)
|
|
35
|
+
|
|
36
|
+
table_exists?("solid_queue_jobs")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def table_exists?(name)
|
|
40
|
+
connection.data_source_exists?(name)
|
|
41
|
+
rescue StandardError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def connection
|
|
46
|
+
ActiveRecord::Base.connection
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ready_rows(filters)
|
|
50
|
+
return [] unless table_exists?("solid_queue_ready_executions")
|
|
51
|
+
|
|
52
|
+
map_jobs(select_joined("solid_queue_ready_executions", filters), "pending")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduled_rows(filters)
|
|
56
|
+
return [] unless table_exists?("solid_queue_scheduled_executions")
|
|
57
|
+
|
|
58
|
+
sql = <<~SQL.squish
|
|
59
|
+
SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at,
|
|
60
|
+
e.scheduled_at AS exec_at
|
|
61
|
+
FROM solid_queue_jobs j
|
|
62
|
+
INNER JOIN solid_queue_scheduled_executions e ON e.job_id = j.id
|
|
63
|
+
#{queue_clause(filters)}
|
|
64
|
+
ORDER BY j.id DESC
|
|
65
|
+
LIMIT #{LIMIT}
|
|
66
|
+
SQL
|
|
67
|
+
map_jobs(connection.select_all(sql), "pending")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def claimed_rows(filters)
|
|
71
|
+
return [] unless table_exists?("solid_queue_claimed_executions")
|
|
72
|
+
|
|
73
|
+
map_jobs(select_joined("solid_queue_claimed_executions", filters), "locked")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def failed_rows(filters)
|
|
77
|
+
return [] unless table_exists?("solid_queue_failed_executions")
|
|
78
|
+
|
|
79
|
+
sql = <<~SQL.squish
|
|
80
|
+
SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at,
|
|
81
|
+
e.error, e.created_at AS failed_at
|
|
82
|
+
FROM solid_queue_jobs j
|
|
83
|
+
INNER JOIN solid_queue_failed_executions e ON e.job_id = j.id
|
|
84
|
+
#{queue_clause(filters)}
|
|
85
|
+
ORDER BY e.created_at DESC
|
|
86
|
+
LIMIT #{LIMIT}
|
|
87
|
+
SQL
|
|
88
|
+
map_jobs(connection.select_all(sql), "failed")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def select_joined(exec_table, filters)
|
|
92
|
+
sql = <<~SQL.squish
|
|
93
|
+
SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at
|
|
94
|
+
FROM solid_queue_jobs j
|
|
95
|
+
INNER JOIN #{exec_table} e ON e.job_id = j.id
|
|
96
|
+
#{queue_clause(filters)}
|
|
97
|
+
ORDER BY j.id DESC
|
|
98
|
+
LIMIT #{LIMIT}
|
|
99
|
+
SQL
|
|
100
|
+
connection.select_all(sql)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def queue_clause(filters)
|
|
104
|
+
return "" unless filters[:queue]
|
|
105
|
+
|
|
106
|
+
"WHERE j.queue_name = #{connection.quote(filters[:queue])}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def map_jobs(result, status)
|
|
110
|
+
result.map do |row|
|
|
111
|
+
error = row["error"]
|
|
112
|
+
error = error.to_s.lines.first&.strip if error
|
|
113
|
+
run_at = parse_time(row["exec_at"] || row["scheduled_at"] || row["created_at"])
|
|
114
|
+
failed_at = status == "failed" ? parse_time(row["failed_at"] || row["created_at"]) : nil
|
|
115
|
+
|
|
116
|
+
Row.new(
|
|
117
|
+
source: source_key,
|
|
118
|
+
id: row["id"],
|
|
119
|
+
name: row["class_name"].presence || "SolidQueue::Job",
|
|
120
|
+
status: status,
|
|
121
|
+
queue: row["queue_name"],
|
|
122
|
+
tenant: nil,
|
|
123
|
+
run_at: run_at,
|
|
124
|
+
failed_at: failed_at,
|
|
125
|
+
error: truncate(error),
|
|
126
|
+
tags: {}
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_time(value)
|
|
132
|
+
return nil if value.nil?
|
|
133
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
134
|
+
|
|
135
|
+
Time.parse(value.to_s)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def truncate(text, max = 200)
|
|
141
|
+
return nil if text.nil?
|
|
142
|
+
|
|
143
|
+
s = text.to_s
|
|
144
|
+
s.length > max ? "#{s[0, max]}…" : s
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
module Browsers
|
|
6
|
+
class Table
|
|
7
|
+
LIMIT = 100
|
|
8
|
+
|
|
9
|
+
def initialize(source)
|
|
10
|
+
@source = source
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def source_key
|
|
14
|
+
"table:#{@source.name}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(filters)
|
|
18
|
+
klass = @source.model_class
|
|
19
|
+
scope = klass.all
|
|
20
|
+
scope = scope.where(@source.tenant => filters[:tenant]) if filters[:tenant] && tenant_column?(klass)
|
|
21
|
+
|
|
22
|
+
if @source.delayed_job_style?
|
|
23
|
+
delayed_job_rows(scope, filters)
|
|
24
|
+
else
|
|
25
|
+
status_rows(scope, filters)
|
|
26
|
+
end
|
|
27
|
+
rescue NameError => e
|
|
28
|
+
Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped #{source_key}: #{e.message}")
|
|
29
|
+
[]
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Anomonitor.logger.warn("[Anomonitor] Table jobs browser (#{@source.name}) error: #{e.message}")
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tenant_column?(klass)
|
|
38
|
+
col = @source.tenant
|
|
39
|
+
col && klass.column_names.include?(col.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delayed_job_rows(scope, filters)
|
|
43
|
+
cols = scope.klass.column_names
|
|
44
|
+
scope = apply_delayed_status(scope, filters[:status], cols)
|
|
45
|
+
if filters[:queue] && cols.include?("queue")
|
|
46
|
+
scope = scope.where(queue: filters[:queue])
|
|
47
|
+
end
|
|
48
|
+
order_col = %w[failed_at run_at created_at updated_at].find { |c| cols.include?(c) }
|
|
49
|
+
scope = scope.order(order_col => :desc) if order_col
|
|
50
|
+
scope.limit(LIMIT).map { |record| delayed_row(record, cols) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_delayed_status(scope, status, cols)
|
|
54
|
+
case status
|
|
55
|
+
when "pending"
|
|
56
|
+
scope = scope.where(failed_at: nil) if cols.include?("failed_at")
|
|
57
|
+
if cols.include?("locked_by")
|
|
58
|
+
scope = scope.where(locked_by: nil)
|
|
59
|
+
elsif cols.include?("locked_at")
|
|
60
|
+
scope = scope.where(locked_at: nil)
|
|
61
|
+
end
|
|
62
|
+
scope = scope.where("run_at <= ?", Time.current) if cols.include?("run_at")
|
|
63
|
+
scope
|
|
64
|
+
when "failed"
|
|
65
|
+
cols.include?("failed_at") ? scope.where.not(failed_at: nil) : scope.none
|
|
66
|
+
when "locked"
|
|
67
|
+
return scope.none unless cols.include?("locked_at")
|
|
68
|
+
|
|
69
|
+
scope = scope.where.not(locked_at: nil)
|
|
70
|
+
scope = scope.where(failed_at: nil) if cols.include?("failed_at")
|
|
71
|
+
scope
|
|
72
|
+
else
|
|
73
|
+
scope
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delayed_row(record, cols)
|
|
78
|
+
tenant = cols.include?(@source.tenant.to_s) ? record.public_send(@source.tenant) : nil
|
|
79
|
+
error = cols.include?("last_error") ? record.public_send(:last_error) : nil
|
|
80
|
+
error = error.to_s.lines.first&.strip if error
|
|
81
|
+
|
|
82
|
+
Row.new(
|
|
83
|
+
source: source_key,
|
|
84
|
+
id: record.id,
|
|
85
|
+
name: record_name(record),
|
|
86
|
+
status: delayed_status(record, cols),
|
|
87
|
+
queue: cols.include?("queue") ? record.queue : nil,
|
|
88
|
+
tenant: tenant,
|
|
89
|
+
run_at: cols.include?("run_at") ? record.run_at : nil,
|
|
90
|
+
failed_at: cols.include?("failed_at") ? record.failed_at : nil,
|
|
91
|
+
error: truncate(error),
|
|
92
|
+
tags: { table: @source.name.to_s }.tap { |t| t[:tenant] = tenant if tenant },
|
|
93
|
+
detail: {
|
|
94
|
+
"table" => @source.name.to_s,
|
|
95
|
+
"handler" => truncate(cols.include?("handler") ? record.handler.to_s : nil, 800),
|
|
96
|
+
"error" => truncate(cols.include?("last_error") ? record.last_error.to_s : nil, 1000)
|
|
97
|
+
}.compact
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def delayed_status(record, cols)
|
|
102
|
+
return "failed" if cols.include?("failed_at") && record.failed_at
|
|
103
|
+
return "locked" if cols.include?("locked_at") && record.locked_at
|
|
104
|
+
|
|
105
|
+
"pending"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def status_rows(scope, filters)
|
|
109
|
+
cols = scope.klass.column_names
|
|
110
|
+
status_col = @source.status
|
|
111
|
+
if status_col && cols.include?(status_col.to_s)
|
|
112
|
+
scope = apply_status_filter(scope, filters[:status], status_col)
|
|
113
|
+
elsif filters[:status] != "all"
|
|
114
|
+
return []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if filters[:queue] && cols.include?("queue")
|
|
118
|
+
scope = scope.where(queue: filters[:queue])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
order_col = %w[updated_at created_at].find { |c| cols.include?(c) }
|
|
122
|
+
scope = scope.order(order_col => :desc) if order_col
|
|
123
|
+
scope.limit(LIMIT).map { |record| status_row(record, cols, status_col) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def apply_status_filter(scope, status, status_col)
|
|
127
|
+
active = Array(@source.active).map(&:to_s)
|
|
128
|
+
case status
|
|
129
|
+
when "pending", "locked"
|
|
130
|
+
# status-style tables: active statuses map to pending; no distinct locked
|
|
131
|
+
return scope.none if status == "locked"
|
|
132
|
+
return scope.where(status_col => active) if active.any?
|
|
133
|
+
|
|
134
|
+
scope
|
|
135
|
+
when "failed"
|
|
136
|
+
failed = infer_failed_statuses(scope.klass, status_col, active)
|
|
137
|
+
failed.any? ? scope.where(status_col => failed) : scope.none
|
|
138
|
+
else
|
|
139
|
+
scope
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def infer_failed_statuses(klass, status_col, active)
|
|
144
|
+
known = %w[failed error dead]
|
|
145
|
+
values = klass.distinct.limit(50).pluck(status_col).compact.map(&:to_s)
|
|
146
|
+
values.select { |v| known.include?(v.downcase) || (active.any? && !active.include?(v)) }
|
|
147
|
+
.select { |v| %w[failed error dead].include?(v.downcase) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def status_row(record, cols, status_col)
|
|
151
|
+
raw_status = status_col && cols.include?(status_col.to_s) ? record.public_send(status_col).to_s : "pending"
|
|
152
|
+
active = Array(@source.active).map(&:to_s)
|
|
153
|
+
mapped =
|
|
154
|
+
if %w[failed error dead].include?(raw_status.downcase)
|
|
155
|
+
"failed"
|
|
156
|
+
elsif active.include?(raw_status)
|
|
157
|
+
"pending"
|
|
158
|
+
else
|
|
159
|
+
raw_status.presence || "pending"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
tenant = @source.tenant && cols.include?(@source.tenant.to_s) ? record.public_send(@source.tenant) : nil
|
|
163
|
+
run_at =
|
|
164
|
+
if @source.timestamp && cols.include?(@source.timestamp.to_s)
|
|
165
|
+
record.public_send(@source.timestamp)
|
|
166
|
+
elsif cols.include?("created_at")
|
|
167
|
+
record.created_at
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Row.new(
|
|
171
|
+
source: source_key,
|
|
172
|
+
id: record.id,
|
|
173
|
+
name: record_name(record),
|
|
174
|
+
status: mapped,
|
|
175
|
+
queue: cols.include?("queue") ? record.queue : nil,
|
|
176
|
+
tenant: tenant,
|
|
177
|
+
run_at: run_at,
|
|
178
|
+
failed_at: mapped == "failed" ? run_at : nil,
|
|
179
|
+
error: nil,
|
|
180
|
+
tags: { table: @source.name.to_s, status: raw_status }.tap { |t| t[:tenant] = tenant if tenant },
|
|
181
|
+
detail: { "table" => @source.name.to_s, "status" => raw_status }
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def record_name(record)
|
|
186
|
+
if record.respond_to?(:name) && record.name.present?
|
|
187
|
+
record.name.to_s
|
|
188
|
+
elsif record.respond_to?(:handler) && record.handler.present?
|
|
189
|
+
record.handler.to_s.lines.first.to_s.strip[0, 80]
|
|
190
|
+
elsif record.respond_to?(:job_class) && record.job_class.present?
|
|
191
|
+
record.job_class.to_s
|
|
192
|
+
else
|
|
193
|
+
@source.model.to_s
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def truncate(text, max = 200)
|
|
198
|
+
return nil if text.nil?
|
|
199
|
+
|
|
200
|
+
s = text.to_s
|
|
201
|
+
s.length > max ? "#{s[0, max]}…" : s
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Jobs
|
|
5
|
+
Row = Struct.new(
|
|
6
|
+
:source,
|
|
7
|
+
:id,
|
|
8
|
+
:name,
|
|
9
|
+
:status,
|
|
10
|
+
:queue,
|
|
11
|
+
:tenant,
|
|
12
|
+
:run_at,
|
|
13
|
+
:failed_at,
|
|
14
|
+
:error,
|
|
15
|
+
:tags,
|
|
16
|
+
:detail,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
) do
|
|
19
|
+
def sort_at
|
|
20
|
+
failed_at || run_at || Time.at(0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def detail_text
|
|
24
|
+
return nil if detail.nil? || detail.empty?
|
|
25
|
+
|
|
26
|
+
detail.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|