├── allgood.jpeg ├── allgood_skipped.webp ├── config └── routes.rb ├── Rakefile ├── lib ├── allgood │ ├── version.rb │ ├── engine.rb │ ├── cache_store.rb │ └── configuration.rb └── allgood.rb ├── sig └── allgood.rbs ├── .gitignore ├── app ├── controllers │ └── allgood │ │ ├── base_controller.rb │ │ └── healthcheck_controller.rb └── views │ ├── layouts │ └── allgood │ │ └── application.html.erb │ └── allgood │ └── healthcheck │ └── index.html.erb ├── bin ├── setup └── console ├── Gemfile ├── LICENSE.txt ├── CHANGELOG.md ├── allgood.gemspec ├── .github └── workflows │ ├── claude.yml │ └── claude-code-review.yml ├── Gemfile.lock ├── examples └── allgood.rb └── README.md /allgood.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/allgood/HEAD/allgood.jpeg -------------------------------------------------------------------------------- /allgood_skipped.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rameerez/allgood/HEAD/allgood_skipped.webp -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Allgood::Engine.routes.draw do 2 | root to: "healthcheck#index" 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | task default: %i[] 5 | -------------------------------------------------------------------------------- /lib/allgood/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Allgood 4 | VERSION = "0.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /sig/allgood.rbs: -------------------------------------------------------------------------------- 1 | module Allgood 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /dist/ 10 | *.gem 11 | 12 | TODO -------------------------------------------------------------------------------- /app/controllers/allgood/base_controller.rb: -------------------------------------------------------------------------------- 1 | module Allgood 2 | class BaseController < ApplicationController 3 | layout 'allgood/application' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in allgood.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | -------------------------------------------------------------------------------- /app/views/layouts/allgood/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Health Check 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | 9 | 10 | <%= yield %> 11 | 12 | -------------------------------------------------------------------------------- /lib/allgood.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "allgood/version" 4 | require_relative "allgood/engine" 5 | require_relative "allgood/configuration" 6 | require_relative "allgood/cache_store" 7 | 8 | module Allgood 9 | class Error < StandardError; end 10 | # Your code goes here... 11 | end 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "allgood" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /lib/allgood/engine.rb: -------------------------------------------------------------------------------- 1 | module Allgood 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Allgood 4 | 5 | config.after_initialize do 6 | config_file = Rails.root.join("config", "allgood.rb") 7 | if File.exist?(config_file) 8 | Allgood.configure do |config| 9 | config.instance_eval(File.read(config_file)) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Javi R 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/views/allgood/healthcheck/index.html.erb: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 |

<%= @status == "ok" ? "🤙 It's all good" : "❌ Something's wrong" %>

25 |
26 | 27 | <% if @results.any? %> 28 | <% @results.each do |result| %> 29 |
30 | <% if result[:skipped] %> 31 | ⏭️ 32 | <% else %> 33 | <%= result[:success] ? "✅" : "❌" %> 34 | <% end %> 35 | <%= result[:name] %>: <%= result[:message] %> 36 | <% unless result[:skipped] %> 37 | [<%= result[:duration] %>ms] 38 | <% end %> 39 |
40 | <% end %> 41 | <% else %> 42 |

No health checks were run. Please check your configuration.

43 | <% end %> 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 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 | 8 | ## [0.2.0] - 2024-10-26 9 | 10 | - Improved the `allgood` DSL by adding optional conditionals on when individual checks are run 11 | - Allow for environment-specific checks with `only` and `except` options (`check "Test Check", only: [:development, :test]`) 12 | - Allow for conditional checks with `if` and `unless` options, which can be procs or any other condition (`check "Test Check", if: -> { condition }`) 13 | - Added visual indication of skipped checks in the healthcheck page 14 | - Improved developer experience by showing why checks were skipped (didn't meet conditions, environment-specific, etc.) 15 | - New DSL changes are fully backward compatible with the previous version (new options are optional, and checks will run normally if they are not specified), so the new version won't break existing configurations 16 | - Changed configuration loading to happen after Rails initialization so we fix the segfault that could occur when requiring gems in the `allgood.rb` configuration file before Rails was initialized 17 | 18 | ## [0.1.0] - 2024-08-22 19 | 20 | - Initial release 21 | -------------------------------------------------------------------------------- /lib/allgood/cache_store.rb: -------------------------------------------------------------------------------- 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 53 | -------------------------------------------------------------------------------- /allgood.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/allgood/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "allgood" 7 | spec.version = Allgood::VERSION 8 | spec.authors = ["rameerez"] 9 | spec.email = ["rubygems@rameerez.com"] 10 | 11 | spec.summary = "Add quick, simple, and beautiful health checks to your Rails application." 12 | spec.description = "Define custom health checks for your app (as in: are there any new users in the past 24 hours) and see the results in a simple /healthcheck page that you can use to monitor your production app with UptimeRobot, Pingdom, or other monitoring services. It's also useful as a drop-in replacement for the default `/up` health check endpoint for Kamal deployments." 13 | spec.homepage = "https://github.com/rameerez/allgood" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = "https://github.com/rameerez/allgood" 21 | spec.metadata["changelog_uri"] = "https://github.com/rameerez/allgood/blob/main/CHANGELOG.md" 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | gemspec = File.basename(__FILE__) 26 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 27 | ls.readlines("\x0", chomp: true).reject do |f| 28 | (f == gemspec) || 29 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 30 | end 31 | end 32 | spec.bindir = "exe" 33 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 34 | spec.require_paths = ["lib"] 35 | 36 | spec.add_dependency "rails", ">= 6.0.0" 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 44 | # model: "claude-opus-4-20250514" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | # use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /app/controllers/allgood/healthcheck_controller.rb: -------------------------------------------------------------------------------- 1 | require 'timeout' 2 | 3 | module Allgood 4 | class HealthcheckController < BaseController 5 | def index 6 | @results = run_checks 7 | @status = @results.all? { |r| r[:success] } ? "ok" : "error" 8 | status_code = @status == "ok" ? :ok : :service_unavailable 9 | 10 | respond_to do |format| 11 | format.html { render :index, status: status_code } 12 | format.json { render json: { status: @status, checks: @results }, status: status_code } 13 | end 14 | rescue StandardError => e 15 | # Log the error 16 | Rails.logger.error "Allgood Healthcheck Error: #{e.message}\n#{e.backtrace.join("\n")}" 17 | 18 | # Return a minimal response 19 | @results = [{ name: "Healthcheck Error", success: false, message: "Internal error occurred", duration: 0 }] 20 | @status = "error" 21 | 22 | respond_to do |format| 23 | format.html { render :index, status: :internal_server_error } 24 | format.json { render json: { status: @status, checks: @results }, status: :internal_server_error } 25 | end 26 | end 27 | 28 | private 29 | 30 | def run_checks 31 | Allgood.configuration.checks.map do |check| 32 | if check[:status] == :skipped 33 | { 34 | name: check[:name], 35 | success: true, 36 | skipped: true, 37 | message: check[:skip_reason], 38 | duration: 0 39 | } 40 | else 41 | run_single_check(check) 42 | end 43 | end 44 | end 45 | 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 | 66 | start_time = Time.now 67 | result = { success: false, message: "Check timed out after #{check[:timeout]} seconds" } 68 | error_key = "allgood:error:#{check[:name].parameterize}" 69 | 70 | begin 71 | Timeout.timeout(check[:timeout]) do 72 | check_result = Allgood.configuration.run_check(&check[:block]) 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}" 93 | end 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 } 103 | end 104 | 105 | { 106 | name: check[:name], 107 | success: result[:success], 108 | message: result[:message], 109 | duration: ((Time.now - start_time) * 1000).round(1) 110 | } 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | allgood (0.1.0) 5 | rails (>= 6.0.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.2.1) 11 | actionpack (= 7.2.1) 12 | activesupport (= 7.2.1) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (7.2.1) 17 | actionpack (= 7.2.1) 18 | activejob (= 7.2.1) 19 | activerecord (= 7.2.1) 20 | activestorage (= 7.2.1) 21 | activesupport (= 7.2.1) 22 | mail (>= 2.8.0) 23 | actionmailer (7.2.1) 24 | actionpack (= 7.2.1) 25 | actionview (= 7.2.1) 26 | activejob (= 7.2.1) 27 | activesupport (= 7.2.1) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (7.2.1) 31 | actionview (= 7.2.1) 32 | activesupport (= 7.2.1) 33 | nokogiri (>= 1.8.5) 34 | racc 35 | rack (>= 2.2.4, < 3.2) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | useragent (~> 0.16) 41 | actiontext (7.2.1) 42 | actionpack (= 7.2.1) 43 | activerecord (= 7.2.1) 44 | activestorage (= 7.2.1) 45 | activesupport (= 7.2.1) 46 | globalid (>= 0.6.0) 47 | nokogiri (>= 1.8.5) 48 | actionview (7.2.1) 49 | activesupport (= 7.2.1) 50 | builder (~> 3.1) 51 | erubi (~> 1.11) 52 | rails-dom-testing (~> 2.2) 53 | rails-html-sanitizer (~> 1.6) 54 | activejob (7.2.1) 55 | activesupport (= 7.2.1) 56 | globalid (>= 0.3.6) 57 | activemodel (7.2.1) 58 | activesupport (= 7.2.1) 59 | activerecord (7.2.1) 60 | activemodel (= 7.2.1) 61 | activesupport (= 7.2.1) 62 | timeout (>= 0.4.0) 63 | activestorage (7.2.1) 64 | actionpack (= 7.2.1) 65 | activejob (= 7.2.1) 66 | activerecord (= 7.2.1) 67 | activesupport (= 7.2.1) 68 | marcel (~> 1.0) 69 | activesupport (7.2.1) 70 | base64 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.3.1) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | securerandom (>= 0.3) 79 | tzinfo (~> 2.0, >= 2.0.5) 80 | base64 (0.2.0) 81 | bigdecimal (3.1.8) 82 | builder (3.3.0) 83 | concurrent-ruby (1.3.4) 84 | connection_pool (2.4.1) 85 | crass (1.0.6) 86 | date (3.3.4) 87 | drb (2.2.1) 88 | erubi (1.13.0) 89 | globalid (1.2.1) 90 | activesupport (>= 6.1) 91 | i18n (1.14.6) 92 | concurrent-ruby (~> 1.0) 93 | io-console (0.7.2) 94 | irb (1.14.1) 95 | rdoc (>= 4.0.0) 96 | reline (>= 0.4.2) 97 | logger (1.6.1) 98 | loofah (2.22.0) 99 | crass (~> 1.0.2) 100 | nokogiri (>= 1.12.0) 101 | mail (2.8.1) 102 | mini_mime (>= 0.1.1) 103 | net-imap 104 | net-pop 105 | net-smtp 106 | marcel (1.0.4) 107 | mini_mime (1.1.5) 108 | minitest (5.25.1) 109 | net-imap (0.4.16) 110 | date 111 | net-protocol 112 | net-pop (0.1.2) 113 | net-protocol 114 | net-protocol (0.2.2) 115 | timeout 116 | net-smtp (0.5.0) 117 | net-protocol 118 | nio4r (2.7.3) 119 | nokogiri (1.16.7-aarch64-linux) 120 | racc (~> 1.4) 121 | nokogiri (1.16.7-arm-linux) 122 | racc (~> 1.4) 123 | nokogiri (1.16.7-arm64-darwin) 124 | racc (~> 1.4) 125 | nokogiri (1.16.7-x86-linux) 126 | racc (~> 1.4) 127 | nokogiri (1.16.7-x86_64-darwin) 128 | racc (~> 1.4) 129 | nokogiri (1.16.7-x86_64-linux) 130 | racc (~> 1.4) 131 | psych (5.1.2) 132 | stringio 133 | racc (1.8.1) 134 | rack (3.1.7) 135 | rack-session (2.0.0) 136 | rack (>= 3.0.0) 137 | rack-test (2.1.0) 138 | rack (>= 1.3) 139 | rackup (2.1.0) 140 | rack (>= 3) 141 | webrick (~> 1.8) 142 | rails (7.2.1) 143 | actioncable (= 7.2.1) 144 | actionmailbox (= 7.2.1) 145 | actionmailer (= 7.2.1) 146 | actionpack (= 7.2.1) 147 | actiontext (= 7.2.1) 148 | actionview (= 7.2.1) 149 | activejob (= 7.2.1) 150 | activemodel (= 7.2.1) 151 | activerecord (= 7.2.1) 152 | activestorage (= 7.2.1) 153 | activesupport (= 7.2.1) 154 | bundler (>= 1.15.0) 155 | railties (= 7.2.1) 156 | rails-dom-testing (2.2.0) 157 | activesupport (>= 5.0.0) 158 | minitest 159 | nokogiri (>= 1.6) 160 | rails-html-sanitizer (1.6.0) 161 | loofah (~> 2.21) 162 | nokogiri (~> 1.14) 163 | railties (7.2.1) 164 | actionpack (= 7.2.1) 165 | activesupport (= 7.2.1) 166 | irb (~> 1.13) 167 | rackup (>= 1.0.0) 168 | rake (>= 12.2) 169 | thor (~> 1.0, >= 1.2.2) 170 | zeitwerk (~> 2.6) 171 | rake (13.2.1) 172 | rdoc (6.7.0) 173 | psych (>= 4.0.0) 174 | reline (0.5.10) 175 | io-console (~> 0.5) 176 | securerandom (0.3.1) 177 | stringio (3.1.1) 178 | thor (1.3.2) 179 | timeout (0.4.1) 180 | tzinfo (2.0.6) 181 | concurrent-ruby (~> 1.0) 182 | useragent (0.16.10) 183 | webrick (1.8.2) 184 | websocket-driver (0.7.6) 185 | websocket-extensions (>= 0.1.0) 186 | websocket-extensions (0.1.5) 187 | zeitwerk (2.6.18) 188 | 189 | PLATFORMS 190 | aarch64-linux 191 | arm-linux 192 | arm64-darwin 193 | x86-linux 194 | x86_64-darwin 195 | x86_64-linux 196 | 197 | DEPENDENCIES 198 | allgood! 199 | rake (~> 13.0) 200 | 201 | BUNDLED WITH 202 | 2.5.17 203 | -------------------------------------------------------------------------------- /lib/allgood/configuration.rb: -------------------------------------------------------------------------------- 1 | module Allgood 2 | class Configuration 3 | attr_reader :checks 4 | attr_accessor :default_timeout 5 | 6 | def initialize 7 | @checks = [] 8 | @default_timeout = 10 # Default timeout of 10 seconds 9 | end 10 | 11 | def check(name, **options, &block) 12 | check_info = { 13 | name: name, 14 | block: block, 15 | timeout: options[:timeout] || @default_timeout, 16 | options: options, 17 | status: :pending 18 | } 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 | 32 | # Handle environment-specific options 33 | if options[:only] 34 | environments = Array(options[:only]) 35 | unless environments.include?(Rails.env.to_sym) 36 | check_info[:status] = :skipped 37 | check_info[:skip_reason] = "Only runs in #{environments.join(', ')}" 38 | @checks << check_info 39 | return 40 | end 41 | end 42 | 43 | if options[:except] 44 | environments = Array(options[:except]) 45 | if environments.include?(Rails.env.to_sym) 46 | check_info[:status] = :skipped 47 | check_info[:skip_reason] = "This check doesn't run in #{environments.join(', ')}" 48 | @checks << check_info 49 | return 50 | end 51 | end 52 | 53 | # Handle conditional checks 54 | if options[:if] 55 | condition = options[:if] 56 | unless condition.is_a?(Proc) ? condition.call : condition 57 | check_info[:status] = :skipped 58 | check_info[:skip_reason] = "Check condition not met" 59 | @checks << check_info 60 | return 61 | end 62 | end 63 | 64 | if options[:unless] 65 | condition = options[:unless] 66 | if condition.is_a?(Proc) ? condition.call : condition 67 | check_info[:status] = :skipped 68 | check_info[:skip_reason] = "Check `unless` condition met" 69 | @checks << check_info 70 | return 71 | end 72 | end 73 | 74 | check_info[:status] = :active 75 | @checks << check_info 76 | end 77 | 78 | def run_check(&block) 79 | CheckRunner.new.instance_eval(&block) 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 172 | end 173 | 174 | class CheckRunner 175 | def make_sure(condition, message = nil) 176 | if condition 177 | { success: true, message: message || "Check passed" } 178 | else 179 | raise CheckFailedError.new(message || "Check failed") 180 | end 181 | end 182 | 183 | def expect(actual) 184 | Expectation.new(actual) 185 | end 186 | end 187 | 188 | class Expectation 189 | def initialize(actual) 190 | @actual = actual 191 | end 192 | 193 | def to_eq(expected) 194 | if @actual == expected 195 | { success: true, message: "Got: #{@actual || 'nil'}" } 196 | else 197 | raise CheckFailedError.new("Expected #{expected} to equal #{@actual || 'nil'} but it doesn't") 198 | end 199 | end 200 | 201 | def to_be_greater_than(expected) 202 | if @actual > expected 203 | { success: true, message: "Got: #{@actual || 'nil'} (> #{expected})" } 204 | else 205 | raise CheckFailedError.new("We were expecting #{@actual || 'nil'} to be greater than #{expected} but it's not") 206 | end 207 | end 208 | 209 | def to_be_less_than(expected) 210 | if @actual < expected 211 | { success: true, message: "Got: #{@actual || 'nil'} (< #{expected})" } 212 | else 213 | raise CheckFailedError.new("We were expecting #{@actual || 'nil'} to be less than #{expected} but it's not") 214 | end 215 | end 216 | 217 | # Add more expectations as needed 218 | end 219 | 220 | class CheckFailedError < StandardError; end 221 | 222 | def self.configuration 223 | @configuration ||= Configuration.new 224 | end 225 | 226 | def self.configure 227 | yield(configuration) 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /examples/allgood.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | TEST_IMAGE = URI.open("https://picsum.photos/id/237/536/354").read 3 | 4 | # --- ACTIVE RECORD --- 5 | 6 | check "We have an active database connection" do 7 | make_sure ActiveRecord::Base.connection.connect!.active? 8 | end 9 | 10 | check "The database can perform a simple query" do 11 | make_sure ActiveRecord::Base.connection.execute("SELECT 1 LIMIT 1").any? 12 | end 13 | 14 | check "The database can perform writes" do 15 | table_name = "allgood_health_check_#{Time.now.to_i}" 16 | random_id = rand(1..999999) 17 | 18 | result = ActiveRecord::Base.connection.execute(<<~SQL) 19 | DROP TABLE IF EXISTS #{table_name}; 20 | CREATE TEMPORARY TABLE #{table_name} (id integer); 21 | INSERT INTO #{table_name} (id) VALUES (#{random_id}); 22 | SELECT id FROM #{table_name} LIMIT 1; 23 | SQL 24 | 25 | ActiveRecord::Base.connection.execute("DROP TABLE #{table_name}") 26 | 27 | make_sure result.present? && result.first["id"] == random_id, "Able to write to temporary table" 28 | end 29 | 30 | check "The database connection pool is healthy" do 31 | pool = ActiveRecord::Base.connection_pool 32 | 33 | used_connections = pool.connections.count 34 | max_connections = pool.size 35 | usage_percentage = (used_connections.to_f / max_connections * 100).round 36 | 37 | make_sure usage_percentage < 90, "Pool usage at #{usage_percentage}% (#{used_connections}/#{max_connections})" 38 | end 39 | 40 | check "Database migrations are up to date" do 41 | make_sure ActiveRecord::Migration.check_all_pending! == nil 42 | end 43 | 44 | # --- IMAGE PROCESSING --- 45 | 46 | check "Vips (libvips) is installed on Linux", except: :development do 47 | output = `ldconfig -p | grep libvips` 48 | make_sure output.present? && output.include?("libvips.so") && output.include?("libvips-cpp.so"), "libvips is found in the Linux system's library cache" 49 | end 50 | 51 | check "Vips is available to Rails" do 52 | throw "ImageProcessing::Vips is not available" if !ImageProcessing::Vips.present? # Need this line to load `Vips` 53 | 54 | make_sure Vips::VERSION.present?, "Vips available with version #{Vips::VERSION}" 55 | end 56 | 57 | check "Vips can perform operations on images" do 58 | throw "ImageProcessing::Vips is not available" if !ImageProcessing::Vips.present? # Need this line to load `Vips` 59 | 60 | image = Vips::Image.new_from_buffer(TEST_IMAGE, "") 61 | processed_image = image 62 | .gaussblur(10) # Apply Gaussian blur with sigma 10 63 | .linear([1.2], [0]) # Increase brightness 64 | .invert # Invert colors for a wild effect 65 | .sharpen # Apply sharpening 66 | .resize(0.5) 67 | 68 | make_sure processed_image.present? && processed_image.width == 268 && processed_image.height == 177, "If we input an image of 536x354px, and we apply filters and a 0.5 resize, we should get an image of 268x177px" 69 | end 70 | 71 | check "ImageProcessing::Vips is available to Rails" do 72 | make_sure ImageProcessing::Vips.present? 73 | end 74 | 75 | check "ImageProcessing can perform operations on images" do 76 | image_processing_image = ImageProcessing::Vips 77 | .source(Vips::Image.new_from_buffer(TEST_IMAGE, "")) 78 | .resize_to_limit(123, 123) # Resize to fit within 500x500 79 | .convert("webp") # Convert to webp format 80 | .call 81 | processed_image = Vips::Image.new_from_file(image_processing_image.path) 82 | 83 | make_sure processed_image.present? && processed_image.width == 123 && processed_image.get("vips-loader") == "webpload", "ImageProcessing can resize and convert to webp" 84 | end 85 | 86 | # --- ACTIVE STORAGE --- 87 | 88 | check "Active Storage is available to Rails" do 89 | make_sure ActiveStorage.present? 90 | end 91 | 92 | check "Active Storage tables are present in the database" do 93 | make_sure ActiveRecord::Base.connection.table_exists?("active_storage_attachments") && ActiveRecord::Base.connection.table_exists?("active_storage_blobs") 94 | end 95 | 96 | check "Active Storage has a valid client configured" do 97 | service = ActiveStorage::Blob.service 98 | service_name = service&.class&.name&.split("::")&.last&.split("Service")&.first 99 | 100 | if !service_name.downcase.include?("disk") 101 | make_sure service.present? && service.respond_to?(:client) && service.client.present?, "Active Storage service has a valid #{service_name} client configured" 102 | else 103 | make_sure !Rails.env.production? && service.present?, "Active Storage using #{service_name} service in #{Rails.env.to_s}" 104 | end 105 | end 106 | 107 | check "ActiveStorage can store images, retrieve them, and purge them" do 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 | 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})" 113 | end 114 | 115 | # --- CACHE --- 116 | 117 | check "Cache is accessible and functioning" do 118 | cache_value = "allgood_#{Time.now.to_i}" 119 | Rails.cache.write("allgood_health_check_test", cache_value, expires_in: 1.minute) 120 | make_sure Rails.cache.read("allgood_health_check_test") == cache_value, "The `allgood_health_check_test` key in the cache should return the string `#{cache_value}`" 121 | end 122 | 123 | # --- SOLID QUEUE --- 124 | 125 | check "SolidQueue is available to Rails" do 126 | make_sure SolidQueue.present? 127 | end 128 | 129 | check "We have an active SolidQueue connection to the database" do 130 | make_sure SolidQueue::Job.connection.connect!.active? 131 | end 132 | 133 | check "SolidQueue tables are present in the database" do 134 | make_sure SolidQueue::Job.connection.table_exists?("solid_queue_jobs") && SolidQueue::Job.connection.table_exists?("solid_queue_failed_executions") && SolidQueue::Job.connection.table_exists?("solid_queue_semaphores") 135 | end 136 | 137 | check "The percentage of failed jobs in the last 24 hours is less than 1%", only: :production do 138 | failed_jobs = SolidQueue::FailedExecution.where(created_at: 24.hours.ago..Time.now).count 139 | all_jobs = SolidQueue::Job.where(created_at: 24.hours.ago..Time.now).count 140 | 141 | if all_jobs > 10 142 | percentage = all_jobs > 0 ? (failed_jobs.to_f / all_jobs.to_f * 100) : 0 143 | make_sure percentage < 1, "#{percentage.round(2)}% of jobs are failing" 144 | else 145 | make_sure true, "Not enough jobs to calculate meaningful failure rate (only #{all_jobs} jobs in last 24h)" 146 | end 147 | end 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 | 185 | # --- SYSTEM --- 186 | 187 | check "Disk space usage is below 90%", only: :production do 188 | usage = `df -h / | tail -1 | awk '{print $5}' | sed 's/%//'`.to_i 189 | expect(usage).to_be_less_than(90) 190 | end 191 | 192 | check "Memory usage is below 90%", only: :production do 193 | usage = `free | grep Mem | awk '{print $3/$2 * 100.0}' | cut -d. -f1`.to_i 194 | expect(usage).to_be_less_than(90) 195 | end 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 | 208 | # --- USAGE-DEPENDENT CHECKS --- 209 | 210 | check "SolidQueue has processed jobs in the last 24 hours", only: :production do 211 | make_sure SolidQueue::Job.where(created_at: 24.hours.ago..Time.now).order(created_at: :desc).limit(1).any? 212 | end 213 | 214 | # --- PAY / STRIPE --- 215 | 216 | # TODO: no error webhooks in the past 24 hours, new sales in the past few hours, etc. 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ✅ Allgood - Rails gem for health checks 2 | 3 | [![Gem Version](https://badge.fury.io/rb/allgood.svg)](https://badge.fury.io/rb/allgood) 4 | 5 | Add quick, simple, and beautiful health checks to your Rails application via a `/healthcheck` page. 6 | 7 | Use it for smoke testing, to make sure your app is healthy and functioning as expected. 8 | 9 | ![Example dashboard of the Allgood health check page](allgood.jpeg) 10 | 11 | ## How it works 12 | 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. 14 | 15 | It provides a `/healthcheck` endpoint that displays the results in a beautiful page. 16 | 17 | You can then [use that endpoint to monitor the health of your application via UptimeRobot](https://uptimerobot.com/?rid=854006b5fe82e4), Pingdom, etc. These services will load your `/healthcheck` page every few minutes, so all checks will be run when UptimeRobot fetches the page. 18 | 19 | `allgood` aims to provide developers with peace of mind by answering the question "is production okay?" at a glance. 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | ```ruby 25 | gem 'allgood' 26 | ``` 27 | 28 | Then run `bundle install`. 29 | 30 | After installing the gem, you need to mount the `/healthcheck` route and define your health checks in a `config/allgood.rb` file. 31 | 32 | 33 | ## Mount the `/healthcheck` route 34 | 35 | In your `config/routes.rb` file, mount the Allgood engine: 36 | ```ruby 37 | mount Allgood::Engine => '/healthcheck' 38 | ``` 39 | 40 | You can now navigate to `/healthcheck` to see the health check results. 41 | 42 | The `/healthcheck` page returns HTTP codes: 43 | - `200 OK` if all checks are successful 44 | - `503 Service Unavailable` error otherwise 45 | 46 | Services like UptimeRobot pick up these HTTP codes, which makes monitoring easy. 47 | 48 | **Kamal**: `allgood` can also be used as a replacement for the default `/up` Rails action, to make [Kamal](https://github.com/basecamp/kamal) check things like if the database connection is healthy when deploying your app's containers. Just change `allgood`'s mounting route to `/up` instead of `/healthcheck`, or configure Kamal to use the `allgood` route. 49 | 50 | > [!TIP] 51 | > If you're using Kamal with `allgood`, container deployment will fail if any defined checks fail, [without feedback from Kamal](https://github.com/rameerez/allgood/issues/1) on what went wrong. Your containers will just not start, and you'll get a generic error message. To avoid this, you can either keep the `allgood.rb` file very minimal (e.g., only check for active DB connection, migrations up to date, etc.) so the app deployment is likely to succeed, or you can use the default `/up` route for Kamal, and then mount `allgood` on another route for more advanced business-oriented checks. What you want to avoid is your app deployment failing because of usage-dependent or business-oriented checks, like your app not deploying because it didn't get any users in the past hour, or something like that. 52 | 53 | ## Configure your health checks 54 | 55 | Create a file `config/allgood.rb` in your Rails application. This is where you'll define your health checks. Here's a simple example: 56 | ```ruby 57 | # config/allgood.rb 58 | 59 | check "We have an active database connection" do 60 | make_sure ActiveRecord::Base.connection.connect!.active? 61 | end 62 | ``` 63 | 64 | `allgood` will run all checks upon page load, and will show "Check passed" or "Check failed" next to it. That's it – add as many health checks as you want! 65 | 66 | Here's my default `config/allgood.rb` file that should work for most Rails applications, feel free to use it as a starting point: 67 | 68 | ```ruby 69 | # config/allgood.rb 70 | 71 | check "We have an active database connection" do 72 | make_sure ActiveRecord::Base.connection.connect!.active? 73 | end 74 | 75 | check "Database can perform a simple query" do 76 | make_sure ActiveRecord::Base.connection.execute("SELECT 1").any? 77 | end 78 | 79 | check "Database migrations are up to date" do 80 | make_sure ActiveRecord::Migration.check_all_pending! == nil 81 | end 82 | 83 | check "Disk space usage is below 90%" do 84 | usage = `df -h / | tail -1 | awk '{print $5}' | sed 's/%//'`.to_i 85 | expect(usage).to_be_less_than(90) 86 | end 87 | 88 | check "Memory usage is below 90%" do 89 | usage = `free | grep Mem | awk '{print $3/$2 * 100.0}' | cut -d. -f1`.to_i 90 | expect(usage).to_be_less_than(90) 91 | end 92 | ``` 93 | 94 | I've also added an example [`config/allgood.rb`](examples/allgood.rb) file in the `examples` folder, with very comprehensive checks for a Rails 8+ app, that you can use as a starting point. 95 | 96 | > [!IMPORTANT] 97 | > Make sure you restart the Rails server (`bin/rails s`) every time you modify the `config/allgood.rb` file for the changes to apply – the `allgood` config is only loaded once when the Rails server starts. 98 | 99 | ### The `allgood` DSL 100 | 101 | As you can see, there's a very simple DSL (Domain-Specific Language) you can use to define health checks. 102 | 103 | It reads almost like natural English, and allows you to define powerful yet simple checks to make sure your app is healthy. 104 | 105 | For example, you can specify a custom human-readable success / error message for each check, so you don't go crazy when things fail and you can't figure out what the check expected output was: 106 | ```ruby 107 | check "Cache is accessible and functioning" do 108 | Rails.cache.write('allgood_test', 'ok') 109 | make_sure Rails.cache.read('allgood_test') == 'ok', "The `allgood_test` key in the cache should contain `'ok'`" 110 | end 111 | ``` 112 | 113 | Other than checking for an active database connection, it's useful to check for business-oriented metrics, such as whether your app has gotten any new users in the past 24 hours (to make sure your signup flow is not broken), check whether there have been any new posts / records created recently (to make sure your users are performing the actions you'd expect them to do in your app), check for recent purchases, check for external API connections, check whether new records contain values within expected range, etc. 114 | 115 | Some business health check examples that you'd need to adapt to the specifics of your particular app: 116 | ```ruby 117 | # Adapt these to your app specifics 118 | 119 | check "There's been new signups in the past 24 hours" do 120 | count = User.where(created_at: 24.hours.ago..Time.now).count 121 | expect(count).to_be_greater_than(0) 122 | end 123 | 124 | check "The last created Purchase has a valid total" do 125 | last_purchase = Purchase.order(created_at: :desc).limit(1).first 126 | make_sure last_purchase.total.is_a?(Numeric), "Purchase total should be a number" 127 | expect(last_purchase.total).to_be_greater_than(0) 128 | end 129 | ``` 130 | 131 | ### Available check methods 132 | 133 | - `make_sure(condition, message = nil)`: Ensures that the given condition is true. 134 | - `expect(actual).to_eq(expected)`: Checks if the actual value equals the expected value. 135 | - `expect(actual).to_be_greater_than(expected)`: Checks if the actual value is greater than the expected value. 136 | - `expect(actual).to_be_less_than(expected)`: Checks if the actual value is less than the expected value. 137 | 138 | Please help us develop by adding more expectation methods in the `Expectation` class! 139 | 140 | ### Run checks only in specific environments or under certain conditions 141 | 142 | You can also make certain checks run only in specific environments or under certain conditions. Some examples: 143 | 144 | ```ruby 145 | # Only run in production 146 | check "There have been new user signups in the past hour", only: :production do 147 | make_sure User.where(created_at: 1.hour.ago..Time.now).count.positive? 148 | end 149 | 150 | # Run in both staging and production 151 | check "External API is responsive", only: [:staging, :production] do 152 | # ... 153 | end 154 | 155 | # Run everywhere except development 156 | check "A SolidCable connection is active and healthy", except: :development do 157 | # ... 158 | end 159 | 160 | # Using if with a direct boolean 161 | check "Feature flag is enabled", if: ENV['FEATURE_ENABLED'] == 'true' do 162 | # ... 163 | end 164 | 165 | # Using if with a Proc for more complex conditions 166 | check "Complex condition", if: -> { User.count > 1000 && User.last.created_at < 10.minutes.ago } do 167 | # ... 168 | end 169 | 170 | # Override default timeout (in seconds) for specific checks 171 | # By default, each check has a timeout of 10 seconds 172 | check "Slow external API", timeout: 30 do 173 | # ... 174 | end 175 | 176 | # Combine multiple conditions 177 | check "Complex check", 178 | only: :production, 179 | if: -> { User.count > 1000 }, 180 | timeout: 15 do 181 | # ... 182 | end 183 | ``` 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 | 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. 206 | 207 | ![Example dashboard of the Allgood health check page with skipped checks](allgood_skipped.webp) 208 | 209 | _Note: the `allgood` health check dashboard has an automatic dark mode, based on the system's appearance settings._ 210 | 211 | ## Development 212 | 213 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 214 | 215 | To install this gem onto your local machine, run `bundle exec rake install`. 216 | 217 | ## Contributing 218 | 219 | Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/allgood. Our code of conduct is: just be nice and make your mom proud of what you do and post online. 220 | 221 | ## License 222 | 223 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 224 | --------------------------------------------------------------------------------