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,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,6 @@
1
+ class AddResolvedAtToAnomonitorAnomalies < ActiveRecord::Migration[6.1]
2
+ def change
3
+ add_column :anomonitor_anomalies, :resolved_at, :datetime
4
+ add_index :anomonitor_anomalies, :resolved_at
5
+ end
6
+ 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