allgood 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28b060b89f8c61aa677718b5fb317179709bc8fb41e72e393f4df54c779ad927
4
- data.tar.gz: f2c53147cd88aa374d261f5922a33188f6fca0ceed0648c44de320c15ac91ade
3
+ metadata.gz: 45bac1780ef5cb92516f0a6f02cd7aa7b476213d6595a937252c600dc3e870e0
4
+ data.tar.gz: b1f8f7e8e30609d67c6c28fb66cd651d13414f9161f74b3e3880f02113408c10
5
5
  SHA512:
6
- metadata.gz: 2135e908374e1621ad8cddc12605fad50f2b84ac7253c67e32d6ceae02c0d0a805a0a2c682fefe9800a7b49c1d9ae7276059f7473499510de8e59b87f9ba2c9e
7
- data.tar.gz: 482317b8650757f64e8a37a15b4f0f9e3789fd61d74dc05e51fcd58eff2710c701f59e7bd42594d724fe107bb39552565179c77d300b0d131a8857955d172f8d
6
+ metadata.gz: b169c7d38987605312e2e013f645814e12fa23f2b0574793fa560f9f9ec9f97491eea5347c625e8b6d0909f3ec32c912a744925906f9e66b710486eb365cf35f
7
+ data.tar.gz: 0573a0b4918ec2e6e4a0ce40831d3e57afed34db5e8ada879d0db4450764dd0e72a8ffe4b3e779bf58758cf3d29546728600f9823437cdf1618627a48648fe5a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.3.0] - 2024-10-27
2
+
3
+ - Added rate limiting for expensive checks with the `run: "N times per day/hour"` option
4
+ - Added a cache mechanism to store check results and error states, which allows for rate limiting and avoiding redundant runs when checks fail
5
+ - Added automatic cache key expiration
6
+ - Added error handling and feedback for rate-limited checks
7
+
1
8
  ## [0.2.0] - 2024-10-26
2
9
 
3
10
  - Improved the `allgood` DSL by adding optional conditionals on when individual checks are run
data/README.md CHANGED
@@ -4,11 +4,13 @@
4
4
 
5
5
  Add quick, simple, and beautiful health checks to your Rails application via a `/healthcheck` page.
6
6
 
7
+ Use it for smoke testing, to make sure your app is healthy and functioning as expected.
8
+
7
9
  ![Example dashboard of the Allgood health check page](allgood.jpeg)
8
10
 
9
11
  ## How it works
10
12
 
11
- `allgood` allows you to define custom health checks (as in: can the Rails app connect to the DB, are there any new users in the past 24 hours, are they actually using the app, etc.) in a very intuitive way that reads just like English.
13
+ `allgood` allows you to define custom health checks / smoke tests (as in: can the Rails app connect to the DB, are there any new users in the past 24 hours, are they actually using the app, etc.) in a very intuitive way that reads just like English.
12
14
 
13
15
  It provides a `/healthcheck` endpoint that displays the results in a beautiful page.
14
16
 
@@ -180,6 +182,26 @@ check "Complex check",
180
182
  end
181
183
  ```
182
184
 
185
+ ### Rate Limiting Expensive Checks
186
+
187
+ For expensive operations (like testing paid APIs), you can limit how often checks run:
188
+
189
+ ```ruby
190
+ # Run expensive checks a limited number of times
191
+ check "OpenAI is responding with a valid LLM message", run: "2 times per day" do
192
+ # expensive API call
193
+ end
194
+
195
+ check "Analytics can be processed", run: "4 times per hour" do
196
+ # expensive operation
197
+ end
198
+ ```
199
+
200
+ Important notes:
201
+ - Rate limits reset at the start of each period (hour/day)
202
+ - The error state persists between rate-limited runs
203
+ - Rate-limited checks show clear feedback about remaining runs and next reset time
204
+
183
205
  When a check is skipped due to its conditions not being met, it will appear in the healthcheck page with a skip emoji (⏭️) and a clear explanation of why it was skipped.
184
206
 
185
207
  ![Example dashboard of the Allgood health check page with skipped checks](allgood_skipped.webp)
@@ -44,20 +44,62 @@ module Allgood
44
44
  end
45
45
 
46
46
  def run_single_check(check)
47
+ last_result_key = "allgood:last_result:#{check[:name].parameterize}"
48
+ last_result = Allgood::CacheStore.instance.read(last_result_key)
49
+
50
+ unless Allgood.configuration.should_run_check?(check)
51
+ message = check[:skip_reason]
52
+ if last_result
53
+ status_info = "Last check #{last_result[:success] ? 'passed' : 'failed'} #{time_ago_in_words(last_result[:time])} ago: #{last_result[:message]}"
54
+ message = "#{message}. #{status_info}"
55
+ end
56
+
57
+ return {
58
+ name: check[:name],
59
+ success: last_result ? last_result[:success] : true,
60
+ skipped: true,
61
+ message: message,
62
+ duration: 0
63
+ }
64
+ end
65
+
47
66
  start_time = Time.now
48
67
  result = { success: false, message: "Check timed out after #{check[:timeout]} seconds" }
68
+ error_key = "allgood:error:#{check[:name].parameterize}"
49
69
 
50
70
  begin
51
71
  Timeout.timeout(check[:timeout]) do
52
72
  check_result = Allgood.configuration.run_check(&check[:block])
53
73
  result = { success: check_result[:success], message: check_result[:message] }
74
+
75
+ if result[:success]
76
+ # Clear error state and store successful result
77
+ Allgood::CacheStore.instance.write(error_key, nil)
78
+ Allgood::CacheStore.instance.write(last_result_key, {
79
+ success: true,
80
+ message: result[:message],
81
+ time: Time.current
82
+ })
83
+ end
84
+ end
85
+ rescue Timeout::Error, Allgood::CheckFailedError, StandardError => e
86
+ error_message = case e
87
+ when Timeout::Error
88
+ "Check timed out after #{check[:timeout]} seconds"
89
+ when Allgood::CheckFailedError
90
+ e.message
91
+ else
92
+ "Error: #{e.message}"
54
93
  end
55
- rescue Timeout::Error
56
- # The result is already set to a timeout message
57
- rescue Allgood::CheckFailedError => e
58
- result = { success: false, message: e.message }
59
- rescue StandardError => e
60
- result = { success: false, message: "Error: #{e.message}" }
94
+
95
+ # Store error state and failed result
96
+ Allgood::CacheStore.instance.write(error_key, error_message)
97
+ Allgood::CacheStore.instance.write(last_result_key, {
98
+ success: false,
99
+ message: error_message,
100
+ time: Time.current
101
+ })
102
+ result = { success: false, message: error_message }
61
103
  end
62
104
 
63
105
  {
data/examples/allgood.rb CHANGED
@@ -106,7 +106,10 @@ end
106
106
 
107
107
  check "ActiveStorage can store images, retrieve them, and purge them" do
108
108
  blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(TEST_IMAGE), filename: "allgood-test-image-#{Time.now.to_i}.jpg", content_type: "image/jpeg")
109
- make_sure blob.persisted? && blob.service.exist?(blob.key) && blob.purge, "Image was successfully stored, retrieved, and purged from #{ActiveStorage::Blob.service.class.name}"
109
+ blob_key = blob.key
110
+ make_sure blob.persisted? && blob.service.exist?(blob_key)
111
+ blob.purge
112
+ make_sure !blob.service.exist?(blob_key), "Image needs to be successfully stored, retrieved, and purged from #{ActiveStorage::Blob.service.name} (#{ActiveStorage::Blob.service.class.name})"
110
113
  end
111
114
 
112
115
  # --- CACHE ---
@@ -143,18 +146,65 @@ check "The percentage of failed jobs in the last 24 hours is less than 1%", only
143
146
  end
144
147
  end
145
148
 
149
+ # --- ACTION CABLE ---
150
+
151
+ check "ActionCable is configured and running" do
152
+ make_sure ActionCable.server.present?, "ActionCable server should be running"
153
+ end
154
+
155
+ check "ActionCable is configured to accept connections with a valid adapter" do
156
+ make_sure ActionCable.server.config.allow_same_origin_as_host, "ActionCable server should be configured to accept connections"
157
+
158
+ adapter = ActionCable.server.config.cable["adapter"]
159
+
160
+ if Rails.env.production?
161
+ make_sure adapter.in?(["solid_cable", "redis"]), "ActionCable running #{adapter} adapter in #{Rails.env.to_s}"
162
+ else
163
+ make_sure adapter.in?(["solid_cable", "async"]), "ActionCable running #{adapter} adapter in #{Rails.env.to_s}"
164
+ end
165
+ end
166
+
167
+ check "ActionCable can broadcast messages and store them in SolidCable" do
168
+ test_message = "allgood_test_#{Time.now.to_i}"
169
+
170
+ begin
171
+ ActionCable.server.broadcast("allgood_test_channel", { message: test_message })
172
+
173
+ # Verify message was stored in SolidCable
174
+ message = SolidCable::Message.where(channel: "allgood_test_channel")
175
+ .order(created_at: :desc)
176
+ .first
177
+
178
+ make_sure message.present?, "Message should be stored in SolidCable"
179
+ make_sure message.payload.include?(test_message) && message.destroy, "Message payload should contain our test message"
180
+ rescue => e
181
+ make_sure false, "Failed to broadcast/verify message: #{e.message}"
182
+ end
183
+ end
184
+
146
185
  # --- SYSTEM ---
147
186
 
148
- check "Disk space usage is below 90%" do
187
+ check "Disk space usage is below 90%", only: :production do
149
188
  usage = `df -h / | tail -1 | awk '{print $5}' | sed 's/%//'`.to_i
150
189
  expect(usage).to_be_less_than(90)
151
190
  end
152
191
 
153
- check "Memory usage is below 90%" do
192
+ check "Memory usage is below 90%", only: :production do
154
193
  usage = `free | grep Mem | awk '{print $3/$2 * 100.0}' | cut -d. -f1`.to_i
155
194
  expect(usage).to_be_less_than(90)
156
195
  end
157
196
 
197
+ # --- SITEMAP ---
198
+
199
+ check "The sitemap generator is available" do
200
+ make_sure SitemapGenerator.present?
201
+ end
202
+
203
+ check "sitemap.xml.gz exists", only: :production do
204
+ make_sure File.exist?(Rails.public_path.join("sitemap.xml.gz"))
205
+ end
206
+
207
+
158
208
  # --- USAGE-DEPENDENT CHECKS ---
159
209
 
160
210
  check "SolidQueue has processed jobs in the last 24 hours", only: :production do
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Allgood
4
+ class CacheStore
5
+ def self.instance
6
+ @instance ||= new
7
+ end
8
+
9
+ def initialize
10
+ @memory_store = {}
11
+ end
12
+
13
+ def read(key)
14
+ if rails_cache_available?
15
+ Rails.cache.read(key)
16
+ else
17
+ @memory_store[key]
18
+ end
19
+ end
20
+
21
+ def write(key, value)
22
+ if rails_cache_available?
23
+ expiry = key.include?('day') ? 1.day : 1.hour
24
+ Rails.cache.write(key, value, expires_in: expiry)
25
+ else
26
+ @memory_store[key] = value
27
+ end
28
+ end
29
+
30
+ def cleanup_old_keys
31
+ return unless rails_cache_available?
32
+
33
+ keys_pattern = "allgood:*"
34
+ if Rails.cache.respond_to?(:delete_matched)
35
+ Rails.cache.delete_matched("#{keys_pattern}:*:#{(Time.current - 2.days).strftime('%Y-%m-%d')}*")
36
+ end
37
+ rescue StandardError => e
38
+ Rails.logger.warn "Allgood: Failed to cleanup old cache keys: #{e.message}"
39
+ end
40
+
41
+ private
42
+
43
+ def rails_cache_available?
44
+ Rails.cache && Rails.cache.respond_to?(:read) && Rails.cache.respond_to?(:write) &&
45
+ Rails.cache.write("allgood_rails_cache_test_ok", "true") &&
46
+ Rails.cache.read("allgood_rails_cache_test_ok") == "true"
47
+ rescue StandardError => e
48
+ Rails.logger.warn "Allgood: Rails.cache not available (#{e.message}), falling back to memory store"
49
+ false
50
+ end
51
+ end
52
+ end
@@ -17,6 +17,18 @@ module Allgood
17
17
  status: :pending
18
18
  }
19
19
 
20
+ # Handle rate limiting
21
+ if options[:run]
22
+ begin
23
+ check_info[:rate] = parse_run_frequency(options[:run])
24
+ rescue ArgumentError => e
25
+ check_info[:status] = :skipped
26
+ check_info[:skip_reason] = "Invalid run frequency: #{e.message}"
27
+ @checks << check_info
28
+ return
29
+ end
30
+ end
31
+
20
32
  # Handle environment-specific options
21
33
  if options[:only]
22
34
  environments = Array(options[:only])
@@ -66,6 +78,97 @@ module Allgood
66
78
  def run_check(&block)
67
79
  CheckRunner.new.instance_eval(&block)
68
80
  end
81
+
82
+ def should_run_check?(check)
83
+ return true unless check[:rate]
84
+
85
+ cache_key = "allgood:last_run:#{check[:name].parameterize}"
86
+ runs_key = "allgood:runs_count:#{check[:name].parameterize}:#{current_period(check[:rate])}"
87
+ error_key = "allgood:error:#{check[:name].parameterize}"
88
+ last_result_key = "allgood:last_result:#{check[:name].parameterize}"
89
+
90
+ last_run = Allgood::CacheStore.instance.read(cache_key)
91
+ period_runs = Allgood::CacheStore.instance.read(runs_key).to_i
92
+ last_result = Allgood::CacheStore.instance.read(last_result_key)
93
+
94
+ current_period_key = current_period(check[:rate])
95
+ stored_period = Allgood::CacheStore.instance.read("allgood:current_period:#{check[:name].parameterize}")
96
+
97
+ # If we're in a new period, reset the counter
98
+ if stored_period != current_period_key
99
+ period_runs = 0
100
+ Allgood::CacheStore.instance.write("allgood:current_period:#{check[:name].parameterize}", current_period_key)
101
+ Allgood::CacheStore.instance.write(runs_key, 0)
102
+ end
103
+
104
+ # If there's an error, wait until next period
105
+ if previous_error = Allgood::CacheStore.instance.read(error_key)
106
+ next_period = next_period_start(check[:rate])
107
+ rate_info = "Rate limited (#{period_runs}/#{check[:rate][:max_runs]} runs this #{check[:rate][:period]})"
108
+ check[:skip_reason] = "#{rate_info}. Waiting until #{next_period.strftime('%H:%M:%S %Z')} to retry failed check"
109
+ return false
110
+ end
111
+
112
+ # If we haven't exceeded the max runs for this period
113
+ if period_runs < check[:rate][:max_runs]
114
+ Allgood::CacheStore.instance.write(cache_key, Time.current)
115
+ Allgood::CacheStore.instance.write(runs_key, period_runs + 1)
116
+ true
117
+ else
118
+ next_period = next_period_start(check[:rate])
119
+ rate_info = "Rate limited (#{period_runs}/#{check[:rate][:max_runs]} runs this #{check[:rate][:period]})"
120
+ next_run = "Next check at #{next_period.strftime('%H:%M:%S %Z')}"
121
+ check[:skip_reason] = "#{rate_info}. #{next_run}"
122
+ false
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def parse_run_frequency(frequency)
129
+ case frequency.to_s.downcase
130
+ when /(\d+)\s+times?\s+per\s+(day|hour)/i
131
+ max_runs, period = $1.to_i, $2
132
+ if max_runs <= 0
133
+ raise ArgumentError, "Number of runs must be positive"
134
+ end
135
+ if max_runs > 1000
136
+ raise ArgumentError, "Maximum 1000 runs per period allowed"
137
+ end
138
+ { max_runs: max_runs, period: period }
139
+ else
140
+ raise ArgumentError, "Unsupported frequency format. Use 'N times per day' or 'N times per hour'"
141
+ end
142
+ end
143
+
144
+ def current_period(rate)
145
+ case rate[:period]
146
+ when 'day'
147
+ Time.current.strftime('%Y-%m-%d')
148
+ when 'hour'
149
+ Time.current.strftime('%Y-%m-%d-%H')
150
+ end
151
+ end
152
+
153
+ def new_period?(last_run, rate)
154
+ case rate[:period]
155
+ when 'day'
156
+ !last_run.to_date.equal?(Time.current.to_date)
157
+ when 'hour'
158
+ last_run.strftime('%Y-%m-%d-%H') != Time.current.strftime('%Y-%m-%d-%H')
159
+ end
160
+ end
161
+
162
+ def next_period_start(rate)
163
+ case rate[:period]
164
+ when 'day'
165
+ Time.current.beginning_of_day + 1.day
166
+ when 'hour'
167
+ Time.current.beginning_of_hour + 1.hour
168
+ else
169
+ raise ArgumentError, "Unsupported period: #{rate[:period]}"
170
+ end
171
+ end
69
172
  end
70
173
 
71
174
  class CheckRunner
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Allgood
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/allgood.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "allgood/version"
4
4
  require_relative "allgood/engine"
5
5
  require_relative "allgood/configuration"
6
+ require_relative "allgood/cache_store"
6
7
 
7
8
  module Allgood
8
9
  class Error < StandardError; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: allgood
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-26 00:00:00.000000000 Z
11
+ date: 2024-11-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -48,6 +48,7 @@ files:
48
48
  - config/routes.rb
49
49
  - examples/allgood.rb
50
50
  - lib/allgood.rb
51
+ - lib/allgood/cache_store.rb
51
52
  - lib/allgood/configuration.rb
52
53
  - lib/allgood/engine.rb
53
54
  - lib/allgood/version.rb
@@ -75,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
76
  - !ruby/object:Gem::Version
76
77
  version: '0'
77
78
  requirements: []
78
- rubygems_version: 3.5.16
79
+ rubygems_version: 3.5.22
79
80
  signing_key:
80
81
  specification_version: 4
81
82
  summary: Add quick, simple, and beautiful health checks to your Rails application.