rails_performance-alerts 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6cc172f0cc721a2a1aa349f9df6ae0ac5d888b85b4075e7d24db94fcd3e27457
4
+ data.tar.gz: b1ae3e85f458a871ff5f73e8a7582309dd85b8647b5c5947bde0c1a4f0bd45f0
5
+ SHA512:
6
+ metadata.gz: 7284b5d1210c3dd1545e53418efe55fe1e34ac8f42cdd8e76a932317715513631ccddb53576eb6a135777a38f2e29dbbf3ba9272bb4fc6c70a2e6dcca0219978
7
+ data.tar.gz: 00cbdbc9646511eb19d39c95e6ad6bed55db4fdda018c15c581e3607bac1281c4f8758c80d860ea8d31accfecf5f730cc548edbb5273693ef859b3d3c5e8c59e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2025
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # RailsPerformance::Alerts
2
+
3
+ Customizable alerts for [rails_performance](https://github.com/igorkasyanchuk/rails_performance) metrics.
4
+
5
+ Monitor your Rails application and receive alerts when metrics exceed configured thresholds.
6
+
7
+ ## Setup
8
+
9
+ Assuming you already have rails_performance set up, follow these steps:
10
+
11
+ 1. Add to your Gemfile and run `bundle install`:
12
+
13
+ ```ruby
14
+ gem "rails_performance-alerts"
15
+ ```
16
+
17
+ 2. Update `config/initializers/rails_performance.rb`:
18
+
19
+ ```ruby
20
+ RailsPerformance.setup do |config|
21
+ # ... existing rails_performance config ...
22
+
23
+ # example alerts:
24
+ config.alert_rules = [
25
+ # Request-based alerts
26
+ {
27
+ name: "High Response Time",
28
+ report: "ResponseTimeReport", # RailsPerformance::Reports::ResponseTimeReport
29
+ threshold: ->(value) { value > 500 }, # Alert when > 500ms
30
+ time_window: 5.minutes, # Calculate average from last 5 minutes
31
+ duration: 3.minutes # Alert if breached for 3 consecutive minutes
32
+ },
33
+ {
34
+ name: "Low Throughput",
35
+ report: "ThroughputReport",
36
+ threshold: ->(value) { value < 10 }, # Alert when < 10 requests/min
37
+ time_window: 3.minutes,
38
+ duration: 2.minutes
39
+ },
40
+ {
41
+ name: "High Error Count",
42
+ report: "ThroughputReport",
43
+ query: { status: 500 }, # Filter to 500 errors only
44
+ threshold: ->(value) { value > 5 },
45
+ time_window: 5.minutes,
46
+ duration: 1.minute
47
+ },
48
+ # System resource alerts (requires rails_performance's system monitoring to be enabled)
49
+ {
50
+ name: "High CPU Load",
51
+ report: "CPULoad", # RailsPerformance::SystemMonitor::CPULoad
52
+ threshold: ->(value) { value > 80 }, # Alert when CPU > 80%
53
+ time_window: 5.minutes,
54
+ duration: 3.minutes
55
+ },
56
+ {
57
+ name: "High Memory Usage",
58
+ report: "MemoryUsage",
59
+ threshold: ->(value) { value > 1.gigabyte }, # Alert when > 1GB
60
+ time_window: 5.minutes,
61
+ duration: 2.minutes
62
+ },
63
+ {
64
+ name: "Disk Space Exhaustion",
65
+ report: "DiskUsage",
66
+ threshold: ->(value) { value < 5.gigabytes }, # Alert when < 5GB free
67
+ time_window: 10.minutes,
68
+ duration: 5.minutes,
69
+ },
70
+ ]
71
+
72
+ config.alert_handler = RailsPerformance::Alerts::EmailNotifier.new(
73
+ recipients: ["team@yourapp.com", "ops@yourapp.com"],
74
+ from: "alerts@yourapp.com",
75
+ smtp_settions: { ... } # defaults to Rails ActionMailer config if omitted
76
+ )
77
+ end
78
+ ```
79
+
80
+ 3. Run the checks periodically using either ActiveJob, cron, or a standalone daemon:
81
+
82
+ * ActiveJob: `RailsPerformance::Alerts::CheckerJob`
83
+ * Rake task for cron: `rails_performance:alerts:check`
84
+ * Rake task for daemon: `rails_performance:alerts:monitor`
85
+
86
+ ## How It Works
87
+
88
+ Alerts can be configured for two types of metrics:
89
+
90
+ ### Request-Based Reports
91
+
92
+ Any report class from `RailsPerformance::Reports::*`:
93
+ - `ResponseTimeReport` - Average response time in milliseconds
94
+ - `ThroughputReport` - Requests per minute
95
+ - `PercentileReport` - P50, P90, P95, P99 response times
96
+ - Any custom report you create
97
+
98
+ ### System Resource Charts
99
+
100
+ Monitor system resources with `RailsPerformance::SystemMonitor::*` classes:
101
+ - `CPULoad` - CPU load average
102
+ - `MemoryUsage` - Application memory usage (in bytes)
103
+ - `DiskUsage` - Available disk space (in bytes)
104
+ - Any custom system monitor you create
105
+
106
+ The `threshold` parameter accepts a lambda that receives the calculated metric value and returns true when the threshold is breached.
107
+
108
+ ## Understanding `time_window` vs `duration`
109
+
110
+ - **`time_window`**: Period of historical data to analyze (lookback period)
111
+ - **`duration`**: How long the threshold must be exceeded before alerting
112
+
113
+ Example: `time_window: 5.minutes, duration: 3.minutes` means:
114
+ > "Calculate the average over the last 5 minutes of data. If that average exceeds the threshold for 3 consecutive checks, trigger the alert."
115
+
116
+ This prevents false alarms from brief spikes while still detecting sustained problems.
117
+
118
+ ## Running Alert Checks
119
+
120
+ ### With ActiveJob (recommended)
121
+
122
+ Schedule with your preferred job scheduler:
123
+
124
+ #### With Solid Queue (config/recurring.yml):
125
+
126
+ ```yaml
127
+ rails_performance_alerts:
128
+ class: RailsPerformance::Alerts::CheckerJob
129
+ schedule: every minute
130
+ ```
131
+
132
+ #### With whenever gem (config/schedule.rb):
133
+
134
+ ```ruby
135
+ every 1.minute do
136
+ runner "RailsPerformance::Alerts::CheckerJob.perform_later"
137
+ end
138
+ ```
139
+
140
+ ### With cron and rake
141
+
142
+ Add to your crontab:
143
+ ```bash
144
+ * * * * * cd /app && bundle exec rake rails_performance:alerts:check
145
+ ```
146
+
147
+ ### Run as its own process
148
+
149
+ You can run the alert monitor continuously as a daemon process. It will check alerts every minute.
150
+ ```bash
151
+ bundle exec rake rails_performance:alerts:monitor
152
+ ```
153
+
154
+ ## Filtering Alerts by Query Parameters
155
+
156
+ You can scope alerts to specific controllers, actions, status codes, or any other attribute:
157
+
158
+ ```ruby
159
+ {
160
+ name: "Slow API Endpoint",
161
+ report: RailsPerformance::Reports::ResponseTimeReport,
162
+ query: {
163
+ controller: "Api::UsersController",
164
+ action: "index"
165
+ },
166
+ threshold: ->(value) { value > 1000 },
167
+ time_window: 3.minutes,
168
+ duration: 2.minutes
169
+ }
170
+ ```
171
+
172
+ The `query` parameter is passed directly to the RailsPerformance DataSource, so you can filter by any attribute available in your performance data.
173
+
174
+ ## Monitoring Specific Servers
175
+
176
+ For system resource alerts, you can monitor a specific server by providing the `server` parameter:
177
+
178
+ ```ruby
179
+ {
180
+ name: "High Memory on Production Web Server",
181
+ report: RailsPerformance::SystemMonitor::MemoryUsage,
182
+ server: "web-server-1///production///web", # Format: hostname///context///role
183
+ threshold: ->(value) { value > 2_000_000_000 },
184
+ time_window: 5.minutes,
185
+ duration: 2.minutes
186
+ }
187
+ ```
188
+
189
+ If no `server` is specified, the alert will monitor the first available server in the ResourcesReport.
190
+
191
+ ## Alerting on Missing Data
192
+
193
+ By default, alerts will trigger when metric data is missing (e.g., no server reporting resource metrics). This is useful for detecting when servers go offline or stop reporting data.
194
+
195
+ ```ruby
196
+ {
197
+ name: "Server Offline",
198
+ report: RailsPerformance::SystemMonitor::CPULoad,
199
+ threshold: ->(value) { value > 80 },
200
+ time_window: 5.minutes,
201
+ duration: 2.minutes,
202
+ alert_on_missing_data: true # Default: true
203
+ }
204
+ ```
205
+
206
+ If you want to ignore missing data and only alert on threshold breaches:
207
+
208
+ ```ruby
209
+ {
210
+ name: "High CPU (ignore missing data)",
211
+ report: RailsPerformance::SystemMonitor::CPULoad,
212
+ threshold: ->(value) { value > 80 },
213
+ time_window: 5.minutes,
214
+ duration: 2.minutes,
215
+ alert_on_missing_data: false # Don't alert when data is missing
216
+ }
217
+ ```
218
+
219
+ **Note**: For request-based reports, missing data typically means no requests were recorded, which returns a value of `0.0` rather than `nil`.
220
+
221
+ ## Custom Alert Handlers
222
+
223
+ While the built-in `EmailNotifier` (shown in the configuration example above) handles most use cases, you can create custom alert handlers for other notification systems.
224
+
225
+ ### Post to Slack
226
+
227
+ ```ruby
228
+ config.alert_handler = proc do |alert|
229
+ slack = Slack::Notifier.new(ENV['SLACK_WEBHOOK_URL'])
230
+
231
+ case alert[:action]
232
+ when :triggered
233
+ slack.ping("🚨 *Alert Triggered*: #{alert[:name]}\n" \
234
+ "Current value: #{alert[:current_value]}")
235
+ when :resolved
236
+ slack.ping("✅ *Alert Resolved*: #{alert[:name]}")
237
+ when :error
238
+ slack.ping("⚠️ *Alert System Error*: #{alert[:error].message}")
239
+ end
240
+ end
241
+ ```
242
+
243
+ ### Create PagerDuty Incident
244
+
245
+ ```ruby
246
+ config.alert_handler = proc do |alert|
247
+ pagerduty = Pagerduty.build(
248
+ integration_key: ENV['PAGERDUTY_INTEGRATION_KEY']
249
+ )
250
+
251
+ case alert[:action]
252
+ when :triggered
253
+ pagerduty.trigger(
254
+ summary: "#{alert[:name]}: value is #{alert[:current_value]}",
255
+ severity: 'error',
256
+ custom_details: alert
257
+ )
258
+ when :resolved
259
+ pagerduty.resolve
260
+ when :error
261
+ pagerduty.trigger(
262
+ summary: "Alert system error: #{alert[:error].message}",
263
+ severity: 'critical',
264
+ custom_details: alert
265
+ )
266
+ end
267
+ end
268
+ ```
269
+
270
+ ### Custom Email Mailer
271
+
272
+ ```ruby
273
+ config.alert_handler = proc do |alert|
274
+ case alert[:action]
275
+ when :triggered
276
+ MyAlertMailer.alert_triggered(alert).deliver_later
277
+ when :resolved
278
+ MyAlertMailer.alert_resolved(alert).deliver_later
279
+ when :error
280
+ MyAlertMailer.alert_error(alert).deliver_later
281
+ end
282
+ end
283
+ ```
284
+
285
+ ### Alert Handler Actions
286
+
287
+ Your alert handler will receive alerts with three possible actions:
288
+
289
+ - **`:triggered`** - An alert threshold has been exceeded
290
+ - Contains: `:name`, `:current_value`, `:time_window`, `:duration`, `:triggered_at`
291
+ - **`:resolved`** - An alert has returned to normal
292
+ - Contains: `:name`, `:current_value`, `:resolved_at`, `:was_triggered_at`
293
+ - **`:error`** - An error occurred while checking alerts
294
+ - Contains: `:error` (exception object), `:rule_config` (may be nil)
295
+
296
+ ## Rake Tasks
297
+
298
+ ```bash
299
+ # Check alerts once
300
+ rake rails_performance:alerts:check
301
+
302
+ # Run alert monitor continuously as a daemon
303
+ rake rails_performance:alerts:monitor
304
+ ```
305
+
306
+ ## License
307
+
308
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ t.verbose = true
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,84 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class Checker
4
+ def self.check_all
5
+ new.check_all
6
+ end
7
+
8
+ def check_all
9
+ RailsPerformance.alert_rules.map do |rule_config|
10
+ begin
11
+ rule = Rule.new(rule_config)
12
+ check_rule(rule)
13
+ rescue => e
14
+ RailsPerformance.log "Error checking alert rule: #{e.message}"
15
+ notify_error e, rule_config
16
+ nil
17
+ end
18
+ end.compact
19
+ end
20
+
21
+ def check_rule rule
22
+ current_value, breaching = rule.evaluate
23
+
24
+ # Only skip if value is nil AND not breaching (alert_on_missing_data: false)
25
+ return nil if current_value.nil? && !breaching
26
+
27
+ state = State.new(rule.id)
28
+
29
+ if breaching
30
+ state.record_breach
31
+ duration_met = state.breaching_duration >= rule.duration
32
+
33
+ if duration_met && !state.triggered?
34
+ trigger_alert rule, current_value, state
35
+ return { action: :triggered, rule: rule, value: current_value }
36
+ end
37
+ else
38
+ if state.triggered?
39
+ resolve_alert rule, current_value, state
40
+ return { action: :resolved, rule: rule, value: current_value }
41
+ end
42
+ state.clear_breaches
43
+ end
44
+
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def trigger_alert rule, current_value, state
51
+ state.mark_triggered!
52
+
53
+ RailsPerformance.alert_handler&.call({
54
+ action: :triggered,
55
+ name: rule.name,
56
+ current_value: current_value,
57
+ time_window: rule.time_window,
58
+ duration: rule.duration,
59
+ triggered_at: Time.now
60
+ })
61
+ end
62
+
63
+ def resolve_alert rule, current_value, state
64
+ state.mark_resolved!
65
+
66
+ RailsPerformance.alert_handler&.call({
67
+ action: :resolved,
68
+ name: rule.name,
69
+ current_value: current_value,
70
+ resolved_at: Time.now,
71
+ was_triggered_at: state.triggered_at
72
+ })
73
+ end
74
+
75
+ def notify_error(exception, rule_config)
76
+ RailsPerformance.alert_handler&.call({
77
+ action: :error,
78
+ error: exception,
79
+ rule_config: rule_config
80
+ })
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,11 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class CheckerJob < ActiveJob::Base
4
+ queue_as :default
5
+
6
+ def perform
7
+ Checker.check_all
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "mailer"
2
+
3
+ module RailsPerformance
4
+ module Alerts
5
+ class EmailNotifier < Data.define(:recipients, :from, :smtp_settings)
6
+ def initialize recipients:, from:, smtp_settings: ::Rails.application.config.action_mailer.smtp_settings
7
+ recipients = Array(recipients)
8
+ super
9
+ end
10
+
11
+ def call alert
12
+ configure_mailer
13
+ deliver_emails alert
14
+ end
15
+
16
+ private
17
+
18
+ def deliver_emails alert
19
+ case alert[:action]
20
+ when :triggered
21
+ recipients.each do |recipient|
22
+ Mailer.triggered(alert, to: recipient, from: from).deliver_now
23
+ end
24
+ when :resolved
25
+ recipients.each do |recipient|
26
+ Mailer.resolved(alert, to: recipient, from: from).deliver_now
27
+ end
28
+ end
29
+ end
30
+
31
+ def configure_mailer
32
+ Mailer.smtp_settings = smtp_settings
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class Mailer < ActionMailer::Base
4
+ prepend_view_path File.expand_path("views", __dir__)
5
+ layout "rails_performance/alerts/layouts/mailer"
6
+
7
+ def triggered alert_data, to:, from:
8
+ @alert = alert_data
9
+ @name = alert_data[:name]
10
+ @current_value = alert_data[:current_value]
11
+ @time_window = format_duration(alert_data[:time_window])
12
+ @duration = format_duration(alert_data[:duration])
13
+ @triggered_at = alert_data[:triggered_at]
14
+
15
+ mail(
16
+ to: to,
17
+ from: from,
18
+ subject: "🚨 Alert Triggered: #{@name}",
19
+ )
20
+ end
21
+
22
+ def resolved alert_data, to:, from:
23
+ @alert = alert_data
24
+ @name = alert_data[:name]
25
+ @current_value = alert_data[:current_value]
26
+ @resolved_at = alert_data[:resolved_at]
27
+ @was_triggered_at = alert_data[:was_triggered_at]
28
+ @duration_triggered = calculate_duration(@was_triggered_at, @resolved_at)
29
+
30
+ mail(
31
+ to: to,
32
+ from: from,
33
+ subject: "✅ Alert Resolved: #{@name}",
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def format_duration seconds
40
+ return "N/A" unless seconds
41
+
42
+ if seconds < 60
43
+ "#{seconds.to_i} seconds"
44
+ elsif seconds < 3600
45
+ "#{(seconds / 60).to_i} minutes"
46
+ else
47
+ "#{(seconds / 3600).to_i} hours"
48
+ end
49
+ end
50
+
51
+ def calculate_duration start_time, end_time
52
+ return "N/A" unless start_time && end_time
53
+ format_duration(end_time - start_time)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class Railtie < ::Rails::Railtie
4
+ rake_tasks do
5
+ load File.expand_path("tasks.rake", __dir__)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,126 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class Rule
4
+ attr_reader :name, :time_window, :duration, :report, :query, :threshold, :server, :alert_on_missing_data
5
+
6
+ def initialize(options = {})
7
+ @name = options[:name] || "Unnamed Alert"
8
+ @time_window = options[:time_window] || 5.minutes
9
+ @duration = options[:duration] || 5.minutes
10
+ @report = options[:report]
11
+ @query = options[:query] || {}
12
+ @threshold = options[:threshold]
13
+ @server = options[:server]
14
+ @alert_on_missing_data = options.fetch(:alert_on_missing_data, true)
15
+
16
+ validate!
17
+ end
18
+
19
+ def id
20
+ @id ||= Digest::MD5.hexdigest("#{name}_#{report_class}_#{query}_#{server}_#{threshold.source_location}")
21
+ end
22
+
23
+ def evaluate
24
+ data = fetch_data
25
+
26
+ value = calculate_average_value(data)
27
+ breaching = if value.nil?
28
+ @alert_on_missing_data
29
+ else
30
+ @threshold.call(value)
31
+ end
32
+
33
+ [value, breaching]
34
+ end
35
+
36
+ def to_s
37
+ "#{name} (#{report})"
38
+ end
39
+
40
+ def report_class
41
+ return @report if @report.is_a?(Class)
42
+
43
+ unless @report.is_a?(String)
44
+ raise ArgumentError, "Invalid report class name: #{@report.inspect}"
45
+ end
46
+
47
+ # Try RailsPerformance::Reports namespace first
48
+ if RailsPerformance::Reports.const_defined?(@report, false)
49
+ return RailsPerformance::Reports.const_get(@report, false)
50
+ end
51
+
52
+ # Try RailsPerformance::SystemMonitor namespace
53
+ if RailsPerformance::SystemMonitor.const_defined?(@report, false)
54
+ return RailsPerformance::SystemMonitor.const_get(@report, false)
55
+ end
56
+
57
+ # Try top-level constant
58
+ if Object.const_defined?(@report, false)
59
+ return Object.const_get(@report, false)
60
+ end
61
+
62
+ raise ArgumentError, "Class #{@report} not found in RailsPerformance::Reports, RailsPerformance::SystemMonitor, or top level"
63
+ end
64
+
65
+ private
66
+
67
+ def resource_chart?
68
+ report_class.ancestors.include?(RailsPerformance::SystemMonitor::ResourceChart)
69
+ end
70
+
71
+ def fetch_data
72
+ if resource_chart?
73
+ fetch_resource_chart_data
74
+ else
75
+ fetch_report_data
76
+ end
77
+ end
78
+
79
+ def fetch_resource_chart_data
80
+ time_window_days = RailsPerformance::Utils.days(@time_window)
81
+ query_params = @query.merge({on: Date.today})
82
+ db = RailsPerformance::DataSource.new(type: :resources, q: query_params, days: time_window_days).db
83
+
84
+ resources_report = RailsPerformance::Reports::ResourcesReport.new(db)
85
+
86
+ target_server = if @server
87
+ resources_report.servers.find { |s| s.key == @server }
88
+ else
89
+ resources_report.servers.first
90
+ end
91
+
92
+ return nil if target_server.nil?
93
+
94
+ chart = report_class.new(target_server)
95
+ chart.data
96
+ end
97
+
98
+ def fetch_report_data
99
+ time_window_days = RailsPerformance::Utils.days(@time_window)
100
+ query_params = @query.merge({on: Date.today})
101
+ db = RailsPerformance::DataSource.new(type: :requests, q: query_params, days: time_window_days).db
102
+
103
+ report = report_class.new(db)
104
+ report.data
105
+ end
106
+
107
+ def calculate_average_value(data)
108
+ window_start = (Time.now - @time_window).to_i * 1000
109
+ recent_data = (data || []).select { |timestamp, value| timestamp >= window_start && value }
110
+ values = recent_data.map { |_, value| value }.compact
111
+
112
+ return nil if values.empty?
113
+
114
+ (values.sum.to_f / values.size).round(2)
115
+ end
116
+
117
+ def validate!
118
+ raise ArgumentError, "name is required" if @name.nil? || @name.empty?
119
+ raise ArgumentError, "report class is required" unless report_class.is_a?(Class)
120
+ raise ArgumentError, "threshold lambda is required" unless @threshold.is_a?(Proc)
121
+ raise ArgumentError, "time_window must be positive" unless @time_window.to_i > 0
122
+ raise ArgumentError, "duration must be positive" unless @duration.to_i > 0
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,80 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ class State
4
+ REDIS_KEY_PREFIX = "rails_performance:alerts:state"
5
+ STATE_TTL = 7.days.to_i
6
+
7
+ attr_reader :rule_id
8
+
9
+ def initialize(rule_id)
10
+ @rule_id = rule_id
11
+ end
12
+
13
+ def triggered?
14
+ redis.get(triggered_key) == "true"
15
+ end
16
+
17
+ def mark_triggered!(timestamp = Time.now)
18
+ redis.setex(triggered_key, STATE_TTL, "true")
19
+ redis.setex(triggered_at_key, STATE_TTL, timestamp.to_i.to_s)
20
+ end
21
+
22
+ def mark_resolved!(timestamp = Time.now)
23
+ redis.del(triggered_key)
24
+ redis.setex(resolved_at_key, STATE_TTL, timestamp.to_i.to_s)
25
+ end
26
+
27
+ def triggered_at
28
+ timestamp = redis.get(triggered_at_key)
29
+ timestamp ? Time.at(timestamp.to_i) : nil
30
+ end
31
+
32
+ def resolved_at
33
+ timestamp = redis.get(resolved_at_key)
34
+ timestamp ? Time.at(timestamp.to_i) : nil
35
+ end
36
+
37
+ def first_breach_at
38
+ timestamp = redis.get(first_breach_key)
39
+ timestamp ? Time.at(timestamp.to_i) : nil
40
+ end
41
+
42
+ def record_breach(timestamp = Time.now)
43
+ unless first_breach_at
44
+ redis.setex(first_breach_key, STATE_TTL, timestamp.to_i.to_s)
45
+ end
46
+ end
47
+
48
+ def clear_breaches
49
+ redis.del(first_breach_key)
50
+ end
51
+
52
+ def breaching_duration
53
+ return 0 unless first_breach_at
54
+ Time.now - first_breach_at
55
+ end
56
+
57
+ private
58
+
59
+ def redis
60
+ RailsPerformance.redis
61
+ end
62
+
63
+ def triggered_key
64
+ "#{REDIS_KEY_PREFIX}:#{rule_id}:triggered"
65
+ end
66
+
67
+ def triggered_at_key
68
+ "#{REDIS_KEY_PREFIX}:#{rule_id}:triggered_at"
69
+ end
70
+
71
+ def resolved_at_key
72
+ "#{REDIS_KEY_PREFIX}:#{rule_id}:resolved_at"
73
+ end
74
+
75
+ def first_breach_key
76
+ "#{REDIS_KEY_PREFIX}:#{rule_id}:first_breach_at"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,75 @@
1
+ namespace :rails_performance do
2
+ namespace :alerts do
3
+ desc "Check all configured alerts"
4
+ task check: :environment do
5
+ unless RailsPerformance.enabled
6
+ puts "RailsPerformance is disabled. Skipping alert checks."
7
+ next
8
+ end
9
+
10
+ if RailsPerformance.alert_rules.empty?
11
+ puts "No alert rules configured. Skipping alert checks."
12
+ next
13
+ end
14
+
15
+ puts "Checking #{RailsPerformance.alert_rules.size} alert rule(s)..."
16
+
17
+ results = RailsPerformance::Alerts::Checker.check_all
18
+
19
+ if results.empty?
20
+ puts "All metrics are within normal thresholds."
21
+ else
22
+ results.each do |result|
23
+ case result[:action]
24
+ when :triggered
25
+ puts "ALERT TRIGGERED: #{result[:rule].name} - #{result[:rule].metric} is #{result[:value]} (threshold: #{result[:rule].threshold})"
26
+ when :resolved
27
+ puts "ALERT RESOLVED: #{result[:rule].name} - #{result[:rule].metric} is now #{result[:value]}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ desc "Run alert checker continuously (for background process)"
34
+ task monitor: :environment do
35
+ unless RailsPerformance.enabled
36
+ puts "RailsPerformance is disabled. Exiting."
37
+ exit
38
+ end
39
+
40
+ if RailsPerformance.alert_rules.empty?
41
+ puts "No alert rules configured. Exiting."
42
+ exit
43
+ end
44
+
45
+ puts "Starting alert monitor (checking every minute)..."
46
+ puts "Monitoring #{RailsPerformance.alert_rules.size} alert rule(s)"
47
+ puts "Press Ctrl+C to stop"
48
+
49
+ trap("INT") do
50
+ puts "\nStopping alert monitor..."
51
+ exit
52
+ end
53
+
54
+ loop do
55
+ begin
56
+ results = RailsPerformance::Alerts::Checker.check_all
57
+
58
+ results.each do |result|
59
+ case result[:action]
60
+ when :triggered
61
+ puts "[#{Time.now}] ALERT TRIGGERED: #{result[:rule].name}"
62
+ when :resolved
63
+ puts "[#{Time.now}] ALERT RESOLVED: #{result[:rule].name}"
64
+ end
65
+ end
66
+ rescue => e
67
+ puts "[#{Time.now}] Error checking alerts: #{e.message}"
68
+ puts e.backtrace.first(5).join("\n") if RailsPerformance.debug
69
+ end
70
+
71
+ sleep 1.minute
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ module RailsPerformance
2
+ module Alerts
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
5
+ <style>
6
+ body {
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8
+ line-height: 1.6;
9
+ color: #333;
10
+ max-width: 600px;
11
+ margin: 0 auto;
12
+ padding: 20px;
13
+ }
14
+ .alert-box {
15
+ border-radius: 8px;
16
+ padding: 20px;
17
+ margin: 20px 0;
18
+ }
19
+ .alert-box.warning {
20
+ background-color: #fff3cd;
21
+ border: 2px solid #ffc107;
22
+ }
23
+ .alert-box.success {
24
+ background-color: #d4edda;
25
+ border: 2px solid #28a745;
26
+ }
27
+ .alert-header {
28
+ font-size: 24px;
29
+ font-weight: bold;
30
+ margin-bottom: 15px;
31
+ }
32
+ .alert-box.warning .alert-header {
33
+ color: #856404;
34
+ }
35
+ .alert-box.success .alert-header {
36
+ color: #155724;
37
+ }
38
+ .alert-details {
39
+ background-color: white;
40
+ border-radius: 4px;
41
+ padding: 15px;
42
+ margin: 15px 0;
43
+ }
44
+ .detail-row {
45
+ display: flex;
46
+ padding: 8px 0;
47
+ border-bottom: 1px solid #f0f0f0;
48
+ }
49
+ .detail-row:last-child {
50
+ border-bottom: none;
51
+ }
52
+ .detail-label {
53
+ font-weight: bold;
54
+ min-width: 150px;
55
+ color: #666;
56
+ }
57
+ .detail-value {
58
+ color: #333;
59
+ }
60
+ .footer {
61
+ margin-top: 30px;
62
+ padding-top: 20px;
63
+ border-top: 1px solid #ddd;
64
+ font-size: 12px;
65
+ color: #999;
66
+ }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <%= yield %>
71
+
72
+ <div class="footer">
73
+ This is an automated alert from RailsPerformance::Alerts
74
+ </div>
75
+ </body>
76
+ </html>
@@ -0,0 +1,30 @@
1
+ <div class="alert-box success">
2
+ <div class="alert-header">
3
+ ✅ Alert Resolved
4
+ </div>
5
+
6
+ <p><strong><%= @name %></strong> <%= @current_value.nil? ? "data is now available." : "has returned to normal levels." %></p>
7
+
8
+ <div class="alert-details">
9
+ <div class="detail-row">
10
+ <div class="detail-label">Current Value:</div>
11
+ <div class="detail-value"><%= @current_value.nil? ? "No data available" : @current_value %></div>
12
+ </div>
13
+ <div class="detail-row">
14
+ <div class="detail-label">Was Triggered At:</div>
15
+ <div class="detail-value"><%= @was_triggered_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
16
+ </div>
17
+ <div class="detail-row">
18
+ <div class="detail-label">Resolved At:</div>
19
+ <div class="detail-value"><%= @resolved_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
20
+ </div>
21
+ <div class="detail-row">
22
+ <div class="detail-label">Duration:</div>
23
+ <div class="detail-value"><%= @duration_triggered %></div>
24
+ </div>
25
+ </div>
26
+
27
+ <p style="margin-top: 20px;">
28
+ The alert condition is no longer active.
29
+ </p>
30
+ </div>
@@ -0,0 +1,15 @@
1
+ ✅ ALERT RESOLVED ✅
2
+
3
+ <%= @name %> has returned to normal levels.
4
+
5
+ Alert Details:
6
+ ----------------
7
+ Current Value: <%= @current_value %>
8
+ Was Triggered At: <%= @was_triggered_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
9
+ Resolved At: <%= @resolved_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
10
+ Duration: <%= @duration_triggered %>
11
+
12
+ The alert condition is no longer active.
13
+
14
+ ---
15
+ This is an automated alert from RailsPerformance::Alerts
@@ -0,0 +1,30 @@
1
+ <div class="alert-box warning">
2
+ <div class="alert-header">
3
+ 🚨 Alert Triggered
4
+ </div>
5
+
6
+ <p><strong><%= @name %></strong> <%= @current_value.nil? ? "has no data available." : "has exceeded its threshold." %></p>
7
+
8
+ <div class="alert-details">
9
+ <div class="detail-row">
10
+ <div class="detail-label">Current Value:</div>
11
+ <div class="detail-value"><%= @current_value.nil? ? "No data available" : @current_value %></div>
12
+ </div>
13
+ <div class="detail-row">
14
+ <div class="detail-label">Time Window:</div>
15
+ <div class="detail-value"><%= @time_window %></div>
16
+ </div>
17
+ <div class="detail-row">
18
+ <div class="detail-label">Duration:</div>
19
+ <div class="detail-value"><%= @duration %></div>
20
+ </div>
21
+ <div class="detail-row">
22
+ <div class="detail-label">Triggered At:</div>
23
+ <div class="detail-value"><%= @triggered_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
24
+ </div>
25
+ </div>
26
+
27
+ <p style="margin-top: 20px;">
28
+ Please investigate this issue as soon as possible.
29
+ </p>
30
+ </div>
@@ -0,0 +1,15 @@
1
+ 🚨 ALERT TRIGGERED 🚨
2
+
3
+ <%= @name %> <%= @current_value.nil? ? "has no data available." : "has exceeded its threshold." %>
4
+
5
+ Alert Details:
6
+ ----------------
7
+ Current Value: <%= @current_value.nil? ? "No data available" : @current_value %>
8
+ Time Window: <%= @time_window %>
9
+ Duration: <%= @duration %>
10
+ Triggered At: <%= @triggered_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
11
+
12
+ Please investigate this issue as soon as possible.
13
+
14
+ ---
15
+ This is an automated alert from RailsPerformance::Alerts
@@ -0,0 +1,52 @@
1
+ require "rails_performance"
2
+ require "active_support/core_ext/integer"
3
+ require_relative "alerts/version"
4
+ require_relative "alerts/rule"
5
+ require_relative "alerts/state"
6
+ require_relative "alerts/checker"
7
+ require_relative "alerts/checker_job" if defined?(ActiveJob)
8
+ require_relative "alerts/mailer" if defined?(ActionMailer)
9
+ require_relative "alerts/email_notifier" if defined?(ActionMailer)
10
+ require_relative "alerts/railtie" if defined?(Rails::Railtie)
11
+
12
+ module RailsPerformance
13
+ module Alerts
14
+ class ConfigurationError < StandardError; end
15
+
16
+ class << self
17
+ def validate_configuration!
18
+ # Check if alert_rules exist without handler
19
+ if RailsPerformance.alert_rules.any? && RailsPerformance.alert_handler.nil?
20
+ raise ConfigurationError, "alert_rules are configured but alert_handler is not set"
21
+ end
22
+
23
+ # Validate handler is callable
24
+ if RailsPerformance.alert_handler && !RailsPerformance.alert_handler.respond_to?(:call)
25
+ raise ConfigurationError, "alert_handler must respond to :call"
26
+ end
27
+
28
+ # Validate each rule can be instantiated
29
+ RailsPerformance.alert_rules.each_with_index do |rule_config, index|
30
+ begin
31
+ Rule.new(rule_config)
32
+ rescue => e
33
+ raise ConfigurationError, "Invalid alert rule at index #{index}: #{e.message}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ mattr_accessor :alert_rules
41
+ @@alert_rules = []
42
+
43
+ mattr_accessor :alert_handler
44
+ @@alert_handler = nil
45
+
46
+ singleton_class.prepend Module.new {
47
+ def setup
48
+ super
49
+ RailsPerformance::Alerts.validate_configuration!
50
+ end
51
+ }
52
+ end
@@ -0,0 +1 @@
1
+ require "rails_performance/alerts"
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_performance-alerts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Micah
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails_performance
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Add user-configurable alerts to rails_performance gem for monitoring
56
+ response time, throughput, error rates, and system resources
57
+ email:
58
+ - micah@example.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - MIT-LICENSE
64
+ - README.md
65
+ - Rakefile
66
+ - lib/rails_performance-alerts.rb
67
+ - lib/rails_performance/alerts.rb
68
+ - lib/rails_performance/alerts/checker.rb
69
+ - lib/rails_performance/alerts/checker_job.rb
70
+ - lib/rails_performance/alerts/email_notifier.rb
71
+ - lib/rails_performance/alerts/mailer.rb
72
+ - lib/rails_performance/alerts/railtie.rb
73
+ - lib/rails_performance/alerts/rule.rb
74
+ - lib/rails_performance/alerts/state.rb
75
+ - lib/rails_performance/alerts/tasks.rake
76
+ - lib/rails_performance/alerts/version.rb
77
+ - lib/rails_performance/alerts/views/rails_performance/alerts/layouts/mailer.html.erb
78
+ - lib/rails_performance/alerts/views/rails_performance/alerts/mailer/resolved.html.erb
79
+ - lib/rails_performance/alerts/views/rails_performance/alerts/mailer/resolved.text.erb
80
+ - lib/rails_performance/alerts/views/rails_performance/alerts/mailer/triggered.html.erb
81
+ - lib/rails_performance/alerts/views/rails_performance/alerts/mailer/triggered.text.erb
82
+ homepage: https://github.com/username/rails_performance-alerts
83
+ licenses:
84
+ - MIT
85
+ metadata: {}
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.5.11
102
+ signing_key:
103
+ specification_version: 4
104
+ summary: Customizable alerts for rails_performance metrics
105
+ test_files: []