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,39 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Anomonitor<%= " — #{yield(:title)}" if content_for?(:title) %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
<%= stylesheet_link_tag "anomonitor/application", media: "all" %>
|
|
10
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
11
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
12
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
|
13
|
+
</head>
|
|
14
|
+
<body class="am-body">
|
|
15
|
+
<div class="am-shell">
|
|
16
|
+
<header class="am-header">
|
|
17
|
+
<a class="am-brand" href="<%= root_path %>">Anomonitor</a>
|
|
18
|
+
<nav class="am-nav">
|
|
19
|
+
<%= link_to "Overview", root_path %>
|
|
20
|
+
<%= link_to "Jobs", jobs_path %>
|
|
21
|
+
<%= link_to "Anomalies", anomalies_path %>
|
|
22
|
+
<%= link_to "Mutes", mutes_path %>
|
|
23
|
+
<%= link_to "Metrics", metrics_path %>
|
|
24
|
+
<%= link_to "JSON", metrics_json_path %>
|
|
25
|
+
<%= link_to "Prom", metrics_prom_path %>
|
|
26
|
+
</nav>
|
|
27
|
+
</header>
|
|
28
|
+
<main class="am-main">
|
|
29
|
+
<% if flash[:notice].present? %>
|
|
30
|
+
<p class="am-flash ok"><%= flash[:notice] %></p>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if flash[:alert].present? %>
|
|
33
|
+
<p class="am-flash warn"><%= flash[:alert] %></p>
|
|
34
|
+
<% end %>
|
|
35
|
+
<%= yield %>
|
|
36
|
+
</main>
|
|
37
|
+
</div>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Anomonitor::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#show"
|
|
3
|
+
resources :anomalies, only: %i[index show] do
|
|
4
|
+
member do
|
|
5
|
+
post :resolve
|
|
6
|
+
post :reopen
|
|
7
|
+
post :retry_webhook
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
resources :mutes, only: %i[index create destroy]
|
|
11
|
+
get "metrics", to: "metrics#index", as: :metrics
|
|
12
|
+
get "metrics.json", to: "metrics#export_json", as: :metrics_json
|
|
13
|
+
get "metrics.prom", to: "metrics#export_prometheus", as: :metrics_prom
|
|
14
|
+
get "jobs", to: "jobs#index", as: :jobs
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateAnomonitorMetricSamples < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :anomonitor_metric_samples do |t|
|
|
4
|
+
t.string :source, null: false
|
|
5
|
+
t.string :metric, null: false
|
|
6
|
+
t.float :value, null: false, default: 0
|
|
7
|
+
t.json :tags, default: {}
|
|
8
|
+
t.datetime :sampled_at, null: false
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :anomonitor_metric_samples, [:source, :metric, :sampled_at],
|
|
14
|
+
name: "idx_anomonitor_samples_source_metric_time"
|
|
15
|
+
add_index :anomonitor_metric_samples, :sampled_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateAnomonitorAnomalies < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :anomonitor_anomalies do |t|
|
|
4
|
+
t.string :rule, null: false
|
|
5
|
+
t.string :source, null: false
|
|
6
|
+
t.string :metric, null: false
|
|
7
|
+
t.float :value, null: false
|
|
8
|
+
t.float :threshold
|
|
9
|
+
t.string :severity, null: false, default: "high"
|
|
10
|
+
t.string :cooldown_key, null: false
|
|
11
|
+
t.json :tags, default: {}
|
|
12
|
+
t.datetime :sampled_at
|
|
13
|
+
t.string :webhook_status, default: "pending"
|
|
14
|
+
t.datetime :webhook_delivered_at
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :anomonitor_anomalies, :cooldown_key
|
|
20
|
+
add_index :anomonitor_anomalies, :created_at
|
|
21
|
+
add_index :anomonitor_anomalies, [:source, :metric]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateAnomonitorMutes < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :anomonitor_mutes do |t|
|
|
4
|
+
t.string :metric
|
|
5
|
+
t.string :rule
|
|
6
|
+
t.string :source
|
|
7
|
+
t.string :tenant
|
|
8
|
+
t.datetime :muted_until, null: false
|
|
9
|
+
t.string :reason
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :anomonitor_mutes, :muted_until
|
|
14
|
+
add_index :anomonitor_mutes, :tenant
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Collectors
|
|
5
|
+
class Base
|
|
6
|
+
def collect
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def available?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def point(source:, metric:, value:, tags: {})
|
|
17
|
+
MetricPoint.new(source: source, metric: metric, value: value, tags: tags)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def log_skip(reason)
|
|
21
|
+
Anomonitor.logger.debug("[Anomonitor] Skipping #{self.class.name}: #{reason}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Collectors
|
|
5
|
+
class DelayedJob < Base
|
|
6
|
+
def collect
|
|
7
|
+
unless defined?(::Delayed::Job)
|
|
8
|
+
log_skip("Delayed::Job not loaded")
|
|
9
|
+
return []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
if Tenancy.multi_tenant?
|
|
13
|
+
collect_per_tenant
|
|
14
|
+
else
|
|
15
|
+
metrics_for(nil)
|
|
16
|
+
end
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
Anomonitor.logger.warn("[Anomonitor] Delayed Job collector error: #{e.message}")
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def collect_per_tenant
|
|
25
|
+
points = []
|
|
26
|
+
Tenancy.tenant_names.each do |tenant|
|
|
27
|
+
Tenancy.switch(tenant) do
|
|
28
|
+
points.concat(metrics_for(tenant))
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Anomonitor.logger.warn("[Anomonitor] Delayed Job collector (#{tenant}) error: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
points
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def metrics_for(tenant)
|
|
37
|
+
scope = ::Delayed::Job
|
|
38
|
+
now = Time.current
|
|
39
|
+
tags = tenant ? { tenant: tenant } : {}
|
|
40
|
+
|
|
41
|
+
pending = scope.where(failed_at: nil).where("run_at <= ?", now).where(locked_at: nil).count
|
|
42
|
+
failed = scope.where.not(failed_at: nil).count
|
|
43
|
+
locked = scope.where.not(locked_at: nil).where(failed_at: nil).count
|
|
44
|
+
total = scope.count
|
|
45
|
+
|
|
46
|
+
[
|
|
47
|
+
point(source: "delayed_job", metric: "queue_depth", value: pending, tags: tags),
|
|
48
|
+
point(source: "delayed_job", metric: "failed", value: failed, tags: tags),
|
|
49
|
+
point(source: "delayed_job", metric: "locked", value: locked, tags: tags),
|
|
50
|
+
point(source: "delayed_job", metric: "total", value: total, tags: tags)
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Anomonitor
|
|
7
|
+
module Collectors
|
|
8
|
+
# Detects schema drift across tenant schemas:
|
|
9
|
+
# missing/extra tables and missing/extra columns vs the majority baseline.
|
|
10
|
+
#
|
|
11
|
+
# Requires Anomonitor.config.tenants to be set.
|
|
12
|
+
# PostgreSQL (information_schema.table_schema as tenant) is supported.
|
|
13
|
+
class SchemaDrift < Base
|
|
14
|
+
def collect
|
|
15
|
+
tenants = Tenancy.tenant_names
|
|
16
|
+
if tenants.size < 2
|
|
17
|
+
log_skip("need at least 2 tenants for schema drift (configure c.tenants)")
|
|
18
|
+
return []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
tables_by_tenant = load_tables(tenants)
|
|
22
|
+
columns_by_tenant = load_columns(tenants)
|
|
23
|
+
expected_tables = majority_keys(tables_by_tenant)
|
|
24
|
+
expected_columns = majority_keys(columns_by_tenant)
|
|
25
|
+
|
|
26
|
+
points = []
|
|
27
|
+
tenants.each do |tenant|
|
|
28
|
+
tenant_tables = tables_by_tenant[tenant] || Set.new
|
|
29
|
+
tenant_columns = columns_by_tenant[tenant] || Set.new
|
|
30
|
+
|
|
31
|
+
missing_tables = (expected_tables - tenant_tables).to_a.sort
|
|
32
|
+
extra_tables = (tenant_tables - expected_tables).to_a.sort
|
|
33
|
+
|
|
34
|
+
comparable_expected_cols = Set.new(
|
|
35
|
+
expected_columns.select do |key|
|
|
36
|
+
table = key.split(".", 2).first
|
|
37
|
+
tenant_tables.include?(table)
|
|
38
|
+
end
|
|
39
|
+
)
|
|
40
|
+
comparable_tenant_cols = Set.new(
|
|
41
|
+
tenant_columns.select do |key|
|
|
42
|
+
table = key.split(".", 2).first
|
|
43
|
+
expected_tables.include?(table)
|
|
44
|
+
end
|
|
45
|
+
)
|
|
46
|
+
missing_columns = (comparable_expected_cols - tenant_columns).to_a.sort
|
|
47
|
+
extra_columns = (comparable_tenant_cols - expected_columns).to_a.sort
|
|
48
|
+
|
|
49
|
+
points.concat(drift_points(tenant, "missing_tables", missing_tables))
|
|
50
|
+
points.concat(drift_points(tenant, "extra_tables", extra_tables))
|
|
51
|
+
points.concat(drift_points(tenant, "missing_columns", missing_columns))
|
|
52
|
+
points.concat(drift_points(tenant, "extra_columns", extra_columns))
|
|
53
|
+
end
|
|
54
|
+
points
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Anomonitor.logger.warn("[Anomonitor] SchemaDrift collector error: #{e.message}")
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def load_tables(tenants)
|
|
63
|
+
conn = ActiveRecord::Base.connection
|
|
64
|
+
quoted = tenants.map { |t| conn.quote(t) }.join(", ")
|
|
65
|
+
rows = conn.select_all(<<~SQL.squish)
|
|
66
|
+
SELECT table_schema, table_name
|
|
67
|
+
FROM information_schema.tables
|
|
68
|
+
WHERE table_type = 'BASE TABLE'
|
|
69
|
+
AND table_schema IN (#{quoted})
|
|
70
|
+
SQL
|
|
71
|
+
|
|
72
|
+
by_tenant = Hash.new { |h, k| h[k] = Set.new }
|
|
73
|
+
rows.each do |row|
|
|
74
|
+
table = row["table_name"].to_s
|
|
75
|
+
next if excluded_table?(table)
|
|
76
|
+
|
|
77
|
+
by_tenant[row["table_schema"].to_s] << table
|
|
78
|
+
end
|
|
79
|
+
by_tenant
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def load_columns(tenants)
|
|
83
|
+
conn = ActiveRecord::Base.connection
|
|
84
|
+
quoted = tenants.map { |t| conn.quote(t) }.join(", ")
|
|
85
|
+
rows = conn.select_all(<<~SQL.squish)
|
|
86
|
+
SELECT table_schema, table_name, column_name
|
|
87
|
+
FROM information_schema.columns
|
|
88
|
+
WHERE table_schema IN (#{quoted})
|
|
89
|
+
SQL
|
|
90
|
+
|
|
91
|
+
by_tenant = Hash.new { |h, k| h[k] = Set.new }
|
|
92
|
+
rows.each do |row|
|
|
93
|
+
table = row["table_name"].to_s
|
|
94
|
+
next if excluded_table?(table)
|
|
95
|
+
|
|
96
|
+
key = "#{table}.#{row['column_name']}"
|
|
97
|
+
by_tenant[row["table_schema"].to_s] << key
|
|
98
|
+
end
|
|
99
|
+
by_tenant
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def majority_keys(by_tenant)
|
|
103
|
+
counts = Hash.new(0)
|
|
104
|
+
by_tenant.each_value do |keys|
|
|
105
|
+
keys.each { |key| counts[key] += 1 }
|
|
106
|
+
end
|
|
107
|
+
threshold = (by_tenant.size / 2.0).ceil
|
|
108
|
+
Set.new(counts.select { |_, c| c >= threshold }.keys)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def excluded_table?(name)
|
|
112
|
+
Array(Anomonitor.config.schema_drift_exclude).any? do |pattern|
|
|
113
|
+
File.fnmatch?(pattern.to_s, name, File::FNM_EXTGLOB)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Always emit (including zeros) so the detector can resolve sticky alerts when drift clears.
|
|
118
|
+
# Display truncates items; items_digest fingerprints the full sorted set.
|
|
119
|
+
def drift_points(tenant, metric, items)
|
|
120
|
+
digest =
|
|
121
|
+
if items.empty?
|
|
122
|
+
nil
|
|
123
|
+
else
|
|
124
|
+
Digest::SHA256.hexdigest(items.join("\0"))[0, 16]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[
|
|
128
|
+
point(
|
|
129
|
+
source: "schema_drift",
|
|
130
|
+
metric: metric,
|
|
131
|
+
value: items.size,
|
|
132
|
+
tags: {
|
|
133
|
+
tenant: tenant,
|
|
134
|
+
items: items.first(25).join(","),
|
|
135
|
+
items_list: items,
|
|
136
|
+
item_count: items.size,
|
|
137
|
+
items_digest: digest
|
|
138
|
+
}.compact
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Collectors
|
|
5
|
+
class Sidekiq < Base
|
|
6
|
+
def collect
|
|
7
|
+
unless defined?(::Sidekiq)
|
|
8
|
+
log_skip("Sidekiq not loaded")
|
|
9
|
+
return []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
stats = ::Sidekiq::Stats.new
|
|
13
|
+
points = []
|
|
14
|
+
|
|
15
|
+
points << point(source: "sidekiq", metric: "queue_depth", value: stats.enqueued)
|
|
16
|
+
points << point(source: "sidekiq", metric: "busy_workers", value: stats.workers_size)
|
|
17
|
+
points << point(source: "sidekiq", metric: "retries", value: stats.retry_size)
|
|
18
|
+
points << point(source: "sidekiq", metric: "dead", value: stats.dead_size)
|
|
19
|
+
points << point(source: "sidekiq", metric: "failed", value: stats.failed)
|
|
20
|
+
points << point(source: "sidekiq", metric: "processed", value: stats.processed)
|
|
21
|
+
|
|
22
|
+
::Sidekiq::Queue.all.each do |queue|
|
|
23
|
+
points << point(
|
|
24
|
+
source: "sidekiq",
|
|
25
|
+
metric: "queue_depth",
|
|
26
|
+
value: queue.size,
|
|
27
|
+
tags: { queue: queue.name }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if queue.respond_to?(:latency)
|
|
31
|
+
points << point(
|
|
32
|
+
source: "sidekiq",
|
|
33
|
+
metric: "latency",
|
|
34
|
+
value: queue.latency,
|
|
35
|
+
tags: { queue: queue.name }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
points
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Anomonitor.logger.warn("[Anomonitor] Sidekiq collector error: #{e.message}")
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Collectors
|
|
5
|
+
class SolidQueue < Base
|
|
6
|
+
def collect
|
|
7
|
+
unless solid_queue_available?
|
|
8
|
+
log_skip("Solid Queue tables not available")
|
|
9
|
+
return []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
points = []
|
|
13
|
+
points << count_point("claimed", "solid_queue_claimed_executions")
|
|
14
|
+
points << count_point("failed", "solid_queue_failed_executions")
|
|
15
|
+
points << count_point("scheduled", "solid_queue_scheduled_executions")
|
|
16
|
+
|
|
17
|
+
if table_exists?("solid_queue_ready_executions")
|
|
18
|
+
points << point(
|
|
19
|
+
source: "solid_queue",
|
|
20
|
+
metric: "queue_depth",
|
|
21
|
+
value: connection.select_value("SELECT COUNT(*) FROM solid_queue_ready_executions").to_i
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
points.compact
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Anomonitor.logger.warn("[Anomonitor] Solid Queue collector 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_ready_executions")
|
|
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 count_point(metric, table)
|
|
50
|
+
return nil unless table_exists?(table)
|
|
51
|
+
|
|
52
|
+
value = connection.select_value("SELECT COUNT(*) FROM #{table}").to_i
|
|
53
|
+
point(source: "solid_queue", metric: metric, value: value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anomonitor
|
|
4
|
+
module Collectors
|
|
5
|
+
class Table < Base
|
|
6
|
+
def initialize(source)
|
|
7
|
+
@source = source
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def collect
|
|
11
|
+
klass = @source.model_class
|
|
12
|
+
|
|
13
|
+
if tenant_column?(klass)
|
|
14
|
+
collect_per_tenant(klass)
|
|
15
|
+
else
|
|
16
|
+
metrics_for(klass.all, nil)
|
|
17
|
+
end
|
|
18
|
+
rescue NameError => e
|
|
19
|
+
log_skip("model #{@source.model} not found: #{e.message}")
|
|
20
|
+
[]
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
Anomonitor.logger.warn("[Anomonitor] Table collector (#{@source.name}) error: #{e.message}")
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def tenant_column?(klass)
|
|
29
|
+
col = @source.tenant
|
|
30
|
+
col && klass.column_names.include?(col.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def collect_per_tenant(klass)
|
|
34
|
+
column = @source.tenant
|
|
35
|
+
points = []
|
|
36
|
+
klass.distinct.pluck(column).compact.each do |tenant|
|
|
37
|
+
points.concat(metrics_for(klass.where(column => tenant), tenant))
|
|
38
|
+
end
|
|
39
|
+
points
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def metrics_for(scope, tenant)
|
|
43
|
+
tags = { table: @source.name.to_s }
|
|
44
|
+
tags[:tenant] = tenant if tenant
|
|
45
|
+
|
|
46
|
+
if @source.delayed_job_style?
|
|
47
|
+
delayed_job_metrics(scope, tags)
|
|
48
|
+
else
|
|
49
|
+
status_metrics(scope, tags)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status_metrics(scope, tags)
|
|
54
|
+
points = []
|
|
55
|
+
active_scope = scope
|
|
56
|
+
if @source.status && @source.active
|
|
57
|
+
active_scope = scope.where(@source.status => @source.active)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
points << point(source: source_name, metric: "queue_depth", value: active_scope.count, tags: tags)
|
|
61
|
+
|
|
62
|
+
timestamp = @source.timestamp || :created_at
|
|
63
|
+
window = spike_window
|
|
64
|
+
if scope.klass.column_names.include?(timestamp.to_s)
|
|
65
|
+
recent = scope.where(timestamp => window.ago..Time.current).count
|
|
66
|
+
previous = scope.where(timestamp => (window * 2).ago...window.ago).count
|
|
67
|
+
points << point(
|
|
68
|
+
source: source_name,
|
|
69
|
+
metric: "growth_rate",
|
|
70
|
+
value: recent,
|
|
71
|
+
tags: tags.merge(previous: previous)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
points
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delayed_job_metrics(scope, tags)
|
|
79
|
+
now = Time.current
|
|
80
|
+
cols = scope.klass.column_names
|
|
81
|
+
|
|
82
|
+
pending = scope.where(failed_at: nil).where("run_at <= ?", now)
|
|
83
|
+
if cols.include?("locked_by")
|
|
84
|
+
pending = pending.where(locked_by: nil)
|
|
85
|
+
elsif cols.include?("locked_at")
|
|
86
|
+
pending = pending.where(locked_at: nil)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
failed = scope.where.not(failed_at: nil).count
|
|
90
|
+
locked =
|
|
91
|
+
if cols.include?("locked_at")
|
|
92
|
+
scope.where.not(locked_at: nil).where(failed_at: nil).count
|
|
93
|
+
else
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[
|
|
98
|
+
point(source: source_name, metric: "queue_depth", value: pending.count, tags: tags),
|
|
99
|
+
point(source: source_name, metric: "failed", value: failed, tags: tags),
|
|
100
|
+
point(source: source_name, metric: "locked", value: locked, tags: tags),
|
|
101
|
+
point(source: source_name, metric: "total", value: scope.count, tags: tags)
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def source_name
|
|
106
|
+
"table:#{@source.name}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def spike_window
|
|
110
|
+
rule = Anomonitor.config.alerts.find(&:spike?)
|
|
111
|
+
seconds = rule ? rule.window_seconds : 300
|
|
112
|
+
seconds.to_i.seconds
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|