├── .ruby-version ├── .yardopts ├── .rspec ├── lib ├── rack-ecg.rb └── rack │ ├── ecg │ ├── version.rb │ ├── check │ │ ├── http.rb │ │ ├── error.rb │ │ ├── active_record_connection.rb │ │ ├── migration_version.rb │ │ ├── git_revision.rb │ │ ├── redis_connection.rb │ │ ├── sequel_connection.rb │ │ └── static.rb │ ├── check_factory.rb │ ├── check_registry.rb │ └── check.rb │ └── ecg.rb ├── examples ├── stand_alone.ru ├── basic.ru ├── mounted_path.ru ├── checks.ru ├── custom_failure_status.ru ├── parameters.ru ├── hook.ru └── git_revision_check_replacement.ru ├── bin ├── setup └── console ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── Gemfile ├── Rakefile ├── docker-compose.yml ├── spec ├── spec_helper.rb ├── check_registry_spec.rb ├── check_factory_spec.rb └── rack_middleware_spec.rb ├── .rubocop.yml ├── LICENSE.txt ├── CODE_OF_CONDUCT.md ├── rack-ecg.gemspec ├── CHANGELOG.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --files lib/**/*.rb 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format documentation 4 | -------------------------------------------------------------------------------- /lib/rack-ecg.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | -------------------------------------------------------------------------------- /examples/stand_alone.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | run(Rack::ECG.new) 6 | -------------------------------------------------------------------------------- /examples/basic.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | use(Rack::ECG) 6 | 7 | run(->(_env) { [200, {}, ["Hello, World"]] }) 8 | -------------------------------------------------------------------------------- /lib/rack/ecg/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | # Library version. 6 | VERSION = "0.3.0" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/mounted_path.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | use(Rack::ECG, at: "/health_check") 6 | 7 | run(->(_env) { [200, {}, ["Hello, World"]] }) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /examples/checks.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | use(Rack::ECG, checks: [:git_revision, :migration_version]) 6 | 7 | run(->(_env) { [200, {}, ["Hello, World"]] }) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:00" 8 | ignore: 9 | - dependency-name: "rubocop-*" 10 | -------------------------------------------------------------------------------- /examples/custom_failure_status.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | use( 6 | Rack::ECG, 7 | checks: [:error], 8 | failure_status: 503, 9 | ) 10 | 11 | run(->(_env) { [200, {}, ["Hello, World"]] }) 12 | -------------------------------------------------------------------------------- /examples/parameters.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | require "sequel" 5 | require "sqlite3" 6 | 7 | use(Rack::ECG, checks: [ 8 | :http, 9 | [:sequel, { connection: "sqlite://events.db", name: "events" }], 10 | [:sequel, { connection: "sqlite://projections.db", name: "projections" }], 11 | ]) 12 | 13 | run(->(_env) { [200, {}, ["Hello, World"]] }) 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "rack-ecg" 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 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in rack-ecg.gemspec 6 | gemspec 7 | 8 | group :development, :test do 9 | gem "pry", "~> 0.14.2" 10 | gem "rack-test", "~> 2.1" 11 | gem "rackup", "~> 2.1" 12 | gem "rake", "~> 13.0" 13 | gem "redcarpet", "~> 3.6" 14 | gem "rspec", "~> 3.12" 15 | gem "rubocop-shopify", "~> 2.14" 16 | gem "yard", "~> 0.9.34" 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | jobs: 3 | test: 4 | strategy: 5 | matrix: 6 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] 7 | runs-on: ubuntu-latest 8 | name: Test (Ruby ${{ matrix.ruby }}) 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: ${{ matrix.ruby }} 14 | bundler-cache: true 15 | - run: bundle exec rake 16 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "bundler/gem_tasks" 5 | rescue LoadError 6 | # not a significant issue 7 | end 8 | 9 | require "rspec/core/rake_task" 10 | require "rubocop/rake_task" 11 | require "yard" 12 | 13 | RSpec::Core::RakeTask.new(:spec) 14 | RuboCop::RakeTask.new(:rubocop) 15 | YARD::Rake::YardocTask.new 16 | 17 | task(default: [:rubocop, :spec, :yard]) 18 | 19 | task(:watch_docs) do 20 | sh "yard server --reload" 21 | end 22 | -------------------------------------------------------------------------------- /examples/hook.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | log_check_results = proc do |success, checks| 6 | next if success 7 | 8 | checks.each do |check_name, check_status| 9 | next unless check_status[:status] == "error" 10 | 11 | puts "Check #{check_name} failed: #{check_status[:value]}" 12 | end 13 | end 14 | 15 | use(Rack::ECG, checks: [:git_revision, :migration_version], hook: log_check_results) 16 | 17 | run(->(_env) { [200, {}, ["Hello, World"]] }) 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # for running tests across different versions locally 2 | 3 | version: "3" 4 | services: 5 | ruby30: &ruby30 6 | image: ruby:3.0 7 | volumes: 8 | - ./:/app 9 | command: bash -c 'gem update --system && cd /app && { ! [ -f Gemfile.lock ] || rm Gemfile.lock; } && bundle install && bundle exec rake' 10 | ruby27: 11 | <<: *ruby30 12 | image: ruby:2.7 13 | ruby26: 14 | <<: *ruby30 15 | image: ruby:2.6 16 | ruby25: 17 | <<: *ruby30 18 | image: ruby:2.5 19 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/http.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "static" 4 | 5 | module Rack 6 | class ECG 7 | module Check 8 | # @!method initialize 9 | # Always returns a success. 10 | class Http < Static 11 | STATIC_PARAMETERS = { 12 | name: :http, 13 | success: true, 14 | value: "online", 15 | }.freeze 16 | 17 | def initialize 18 | super(STATIC_PARAMETERS) 19 | end 20 | end 21 | 22 | CheckRegistry.instance.register(:http, Http) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "static" 4 | 5 | module Rack 6 | class ECG 7 | module Check 8 | # @!method initialize 9 | # Always returns a basic error for testing purposes. 10 | class Error < Static 11 | STATIC_PARAMETERS = { 12 | name: :error, 13 | success: false, 14 | value: "PC LOAD LETTER", 15 | }.freeze 16 | 17 | def initialize 18 | super(STATIC_PARAMETERS) 19 | end 20 | end 21 | 22 | CheckRegistry.instance.register(:error, Error) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec" 4 | require "rack/test" 5 | require "rack/ecg" 6 | 7 | RSpec.configure do |config| 8 | config.expect_with(:rspec) do |expectations| 9 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 10 | end 11 | 12 | config.mock_with(:rspec) do |mocks| 13 | mocks.verify_partial_doubles = true 14 | end 15 | 16 | config.disable_monkey_patching! 17 | 18 | config.warnings = true 19 | 20 | config.include(Rack::Test::Methods) 21 | 22 | def json_body 23 | @json_body ||= JSON.parse(last_response.body) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/check_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe(Rack::ECG::CheckRegistry) do 4 | class MyCheckClass; end 5 | subject(:check_registry) { described_class } 6 | 7 | before do 8 | check_registry.register(:my_check, MyCheckClass) 9 | end 10 | 11 | describe ".lookup" do 12 | context "with a registered class" do 13 | it "returns the registered class" do 14 | expect(check_registry.lookup(:my_check)).to(eq(MyCheckClass)) 15 | end 16 | end 17 | 18 | context "when the class is not registered" do 19 | it "raises an error" do 20 | expect { check_registry.lookup(:my_other_check) }.to(raise_error(Rack::ECG::CheckRegistry::CheckNotRegistered)) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # The behavior of RuboCop can be controlled via the .rubocop.yml 2 | # configuration file. It makes it possible to enable/disable 3 | # certain cops (checks) and to alter their behavior if they accept 4 | # any parameters. The file can be placed either in your home 5 | # directory or in some project directory. 6 | # 7 | # RuboCop will start looking for the configuration file in the directory 8 | # where the inspected file is and continue its way up to the root directory. 9 | # 10 | # See https://github.com/rubocop-hq/rubocop/blob/master/manual/configuration.md 11 | 12 | inherit_gem: 13 | rubocop-shopify: rubocop.yml 14 | 15 | AllCops: 16 | SuggestExtensions: false 17 | NewCops: disable 18 | Exclude: 19 | - 'vendor/**/*' 20 | 21 | Naming/FileName: 22 | Exclude: 23 | - 'lib/rack-ecg.rb' 24 | -------------------------------------------------------------------------------- /lib/rack/ecg/check_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg/check" 4 | 5 | module Rack 6 | class ECG 7 | class CheckFactory 8 | CheckDefinition = Struct.new(:check_class, :parameters) 9 | 10 | def initialize(definitions, default_checks = []) 11 | definitions = Array(definitions) | default_checks 12 | 13 | @checks = definitions.map do |check_name, check_parameters| 14 | CheckDefinition.new(CheckRegistry.lookup(check_name), check_parameters) 15 | end 16 | end 17 | 18 | def build_all 19 | @checks.map do |check_definition| 20 | build(check_definition.check_class, check_definition.parameters) 21 | end 22 | end 23 | 24 | def build(check_class, parameters = nil) 25 | parameters.nil? ? check_class.new : check_class.new(parameters) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /examples/git_revision_check_replacement.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg" 4 | 5 | # This example behaves just like the deprecated GitRevision check, except that the value is memoized. 6 | # i.e. "Fetching the git revision" shouldn't show up for every `GET /_ecg` request. 7 | # 8 | # Also consider writing the git revision to a file, or storing it in an environment variable, so it can found more 9 | # efficiently and with fewer dependencies. 10 | 11 | def git_revision 12 | puts "Fetching the git revision" 13 | 14 | _stdin, stdout, stderr, wait_thread = Open3.popen3("git rev-parse HEAD") 15 | 16 | success = wait_thread.value.success? 17 | 18 | status = success ? Rack::ECG::Check::Status::OK : Rack::ECG::Check::Status::ERROR 19 | 20 | value = success ? stdout.read : stderr.read 21 | value = value.strip 22 | 23 | { name: :git_revision, status: status, value: value } 24 | end 25 | 26 | use(Rack::ECG, checks: [[:static, git_revision]]) 27 | 28 | run(->(_env) { [200, {}, ["Hello, World"]] }) 29 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/active_record_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | # @!method initialize 7 | # Checks whether ActiveRecord is currently connected to the default 8 | # database. 9 | class ActiveRecordConnection 10 | def result 11 | value = "" 12 | status = Status::OK 13 | begin 14 | if defined?(ActiveRecord) 15 | value = ::ActiveRecord::Base.connection.active? 16 | status = value ? Status::OK : Status::ERROR 17 | else 18 | status = Status::ERROR 19 | value = "ActiveRecord not found" 20 | end 21 | rescue => e 22 | status = Status::ERROR 23 | value = e.message 24 | end 25 | 26 | Result.new(:active_record, status, value.to_s) 27 | end 28 | 29 | CheckRegistry.instance.register(:active_record, ActiveRecordConnection) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/migration_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | # @!method initialize 7 | # Returns the latest applied ActiveRecord migration in the default 8 | # database. 9 | class MigrationVersion 10 | def result 11 | value = "" 12 | status = Status::OK 13 | begin 14 | if defined?(ActiveRecord) 15 | connection = ActiveRecord::Base.connection 16 | value = connection.select_value("select max(version) from schema_migrations") 17 | else 18 | status = Status::ERROR 19 | value = "ActiveRecord not found" 20 | end 21 | rescue => e 22 | status = Status::ERROR 23 | value = e.message 24 | end 25 | 26 | Result.new(:migration_version, status, value) 27 | end 28 | end 29 | 30 | CheckRegistry.instance.register(:migration_version, MigrationVersion) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/git_revision.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | # @deprecated This check requires the presence of the git executable, and executes it every time to determine the 7 | # current revision. Consider checking the revision at initialization time, and returning it via a {Static} check 8 | # instead. 9 | # 10 | # @!method initialize 11 | # Returns the SHA1 of the current commit, as reported by the git 12 | # executable. 13 | class GitRevision 14 | def result 15 | _stdin, stdout, stderr, wait_thread = Open3.popen3("git rev-parse HEAD") 16 | 17 | success = wait_thread.value.success? 18 | 19 | status = success ? Status::OK : Status::ERROR 20 | 21 | value = success ? stdout.read : stderr.read 22 | value = value.strip 23 | 24 | Result.new(:git_revision, status, value) 25 | end 26 | end 27 | 28 | CheckRegistry.instance.register(:git_revision, GitRevision) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Envato 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/redis_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | class RedisConnection 7 | attr_reader :redis_instance 8 | 9 | # Checks whether the given Redis client is currently connected to the 10 | # database as identified by the ++instance++ option. 11 | # 12 | # @option parameters instance [Redis] The Redis client 13 | def initialize(parameters = {}) 14 | @redis_instance = parameters[:instance] 15 | end 16 | 17 | def result 18 | value = "" 19 | status = Status::OK 20 | begin 21 | if redis_instance.nil? 22 | status = Status::ERROR 23 | value = "Redis instance parameters not found" 24 | elsif defined?(::Redis) 25 | value = redis_instance.connected? 26 | status = value ? Status::OK : Status::ERROR 27 | else 28 | status = Status::ERROR 29 | value = "Redis not found" 30 | end 31 | rescue => e 32 | status = Status::ERROR 33 | value = e.message 34 | end 35 | 36 | Result.new(:redis, status, value.to_s) 37 | end 38 | 39 | CheckRegistry.instance.register(:redis, RedisConnection) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/rack/ecg/check_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "singleton" 4 | 5 | module Rack 6 | class ECG 7 | class CheckRegistry 8 | # Raised when a check didn't exist during lookup 9 | CheckNotRegistered = Class.new(StandardError) 10 | include Singleton 11 | 12 | # Constructs the singleton instance of the registry 13 | def initialize 14 | @registry = {} 15 | end 16 | 17 | # Register a check class by name 18 | # 19 | # @param [Symbol] name Desired check name 20 | # @param [Class] check_class Class implementing check functionality 21 | def register(name, check_class) 22 | @registry[name] = check_class 23 | end 24 | 25 | # Fetches the registered check class by name 26 | # 27 | # @param [Symbol] name Registered check name 28 | # @raise [CheckNotRegistered] if the named check has not been registered 29 | def lookup(name) 30 | @registry.fetch(name) { raise CheckNotRegistered, "Check '#{name}' is not registered" } 31 | end 32 | 33 | class << self 34 | # (see #lookup) 35 | def lookup(name) 36 | instance.lookup(name) 37 | end 38 | 39 | # (see #register) 40 | def register(name, check_class) 41 | instance.register(name, check_class) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /lib/rack/ecg/check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg/check_registry" 4 | require "rack/ecg/check/active_record_connection" 5 | require "rack/ecg/check/error" 6 | require "rack/ecg/check/git_revision" 7 | require "rack/ecg/check/http" 8 | require "rack/ecg/check/migration_version" 9 | require "rack/ecg/check/redis_connection" 10 | require "rack/ecg/check/sequel_connection" 11 | require "rack/ecg/check/static" 12 | 13 | module Rack 14 | class ECG 15 | module Check 16 | # Possible recognised check statuses. 17 | module Status 18 | # Indicates the check was successful. 19 | OK = "ok" 20 | # Indicates the check errored. 21 | ERROR = "error" 22 | end 23 | 24 | class Result < Struct.new(:name, :status, :value) 25 | # Format the result as a JSON compatible hash. 26 | # 27 | # @return [Hash>] Result in a hash format. 28 | # @example A HTTP success response 29 | # puts result.as_json 30 | # # {:http=>{:status=>"ok", :value=>"online"}} 31 | def as_json 32 | { name => { status: status, value: value } } 33 | end 34 | 35 | # Return the result as a JSON object. 36 | # 37 | # @return [String] Result in a JSON object string. 38 | # @example A HTTP success response 39 | # puts result.to_json 40 | # # {"http": {"status": "ok", "value": "online"}} 41 | def to_json 42 | JSON.dump(as_json) 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /rack-ecg.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | require_relative "lib/rack/ecg/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rack-ecg" 8 | spec.version = Rack::ECG::VERSION 9 | spec.authors = ["Envato", "Julian Doherty"] 10 | spec.email = ["julian@envato.com"] 11 | spec.summary = "Rack middleware serving a health check page" 12 | spec.description = <<-EOF 13 | rack-ecg allows you to serve a page that shows you facts about your deployed 14 | app to allow you to check that everything is running as it should: git 15 | revision, database migrations, and more 16 | EOF 17 | spec.homepage = "https://github.com/envato/rack-ecg" 18 | spec.license = "MIT" 19 | 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["source_code_uri"] = spec.homepage 22 | spec.metadata["changelog_uri"] = "https://github.com/envato/rack-ecg/blob/main/CHANGELOG.md" 23 | 24 | # Specify which files should be added to the gem when it is released. 25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 26 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 27 | %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | 33 | spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0") 34 | 35 | spec.add_runtime_dependency("rack") 36 | end 37 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/sequel_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | class SequelConnection 7 | attr_reader :connection_parameters, :name 8 | 9 | # Checks whether Sequel can connect to the database identified by the 10 | # ++connection++ option. 11 | # 12 | # @option parameters connection [String,Hash] Sequel connection parameters to check 13 | # @option parameters name [String,nil] Name to distinguish multiple Sequel checks 14 | def initialize(parameters = {}) 15 | @connection_parameters = parameters[:connection] 16 | @name = parameters[:name] 17 | end 18 | 19 | def result 20 | value = "" 21 | status = Status::OK 22 | begin 23 | if connection_parameters.nil? 24 | status = Status::ERROR 25 | value = "Sequel Connection parameters not found" 26 | elsif defined?(::Sequel) 27 | ::Sequel.connect(connection_parameters) do |db| 28 | value = db.test_connection 29 | status = Status::OK 30 | end 31 | else 32 | status = Status::ERROR 33 | value = "Sequel not found" 34 | end 35 | rescue => e 36 | status = Status::ERROR 37 | value = e.message 38 | end 39 | 40 | Result.new(result_key.to_sym, status, value.to_s) 41 | end 42 | 43 | def result_key 44 | if name 45 | "sequel #{name.downcase}".gsub(/\W+/, "_") 46 | else 47 | "sequel" 48 | end 49 | end 50 | 51 | CheckRegistry.instance.register(:sequel, SequelConnection) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rack/ecg/check/static.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ECG 5 | module Check 6 | class Static 7 | # Always returns the provided ++value++ under the ++name++ key, with the result set by ++status++. 8 | # 9 | # @example Return "Hello, world!" under ++static++ 10 | # use(Rack::ECG, { checks: [[:static, { value: "Hello, world!" }]] }) 11 | # 12 | # @example Return "Paper jam in tray 2" as an error under ++printer_status++ 13 | # use(Rack::ECG, { 14 | # checks: [ 15 | # [ 16 | # :static, 17 | # { 18 | # value: "Paper jam in tray 2", 19 | # success: false, # or status: Rack::ECG::Check::Status::ERROR 20 | # name: :printer_status, 21 | # }, 22 | # ], 23 | # ], 24 | # }) 25 | # 26 | # @option parameters value [Object] (nil) Result value 27 | # @option parameters status [Status::ERROR, Status::OK, nil] (nil) Result status (takes precedence over 28 | # ++success++) 29 | # @option parameters success [Boolean] (true) Whether the result is successful 30 | # @option parameters name [Symbol, #to_sym] (:static) Key for the check result in the response 31 | def initialize(parameters) 32 | parameters ||= {} 33 | 34 | @name = parameters.fetch(:name, :static).to_sym 35 | @value = parameters.fetch(:value, nil) 36 | 37 | @status = if parameters.key?(:status) 38 | parameters[:status] 39 | else 40 | parameters.fetch(:success, true) ? Status::OK : Status::ERROR 41 | end 42 | end 43 | 44 | def result 45 | Result.new(@name, @status, @value) 46 | end 47 | end 48 | 49 | CheckRegistry.instance.register(:static, Static) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/check_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe(Rack::ECG::CheckFactory) do 4 | class MyCheckClass; end 5 | 6 | class MyOtherCheckClass; def initialize(params); end; end 7 | 8 | let(:definitions) { [] } 9 | let(:default_checks) { [] } 10 | subject(:check_factory) { Rack::ECG::CheckFactory.new(definitions, default_checks) } 11 | 12 | describe "#build" do 13 | context "with a class that does not take params" do 14 | let(:check_class) { spy(MyCheckClass) } 15 | 16 | it "builds the specified class" do 17 | expect { check_factory.build(check_class) }.not_to(raise_error) 18 | expect(check_class).to(have_received(:new).with(no_args)) 19 | end 20 | end 21 | 22 | context "with a class that does not take params" do 23 | let(:check_class) { spy(MyOtherCheckClass) } 24 | let(:check_parameters) { double } 25 | it "builds the specified class" do 26 | expect { check_factory.build(check_class, check_parameters) }.not_to(raise_error) 27 | expect(check_class).to(have_received(:new).with(check_parameters)) 28 | end 29 | end 30 | end 31 | 32 | describe "#build_all" do 33 | context "with defined checks" do 34 | let(:definitions) { [:my_check, [:my_other_check, { foo: "bar" }]] } 35 | let(:check_class) { spy(MyCheckClass) } 36 | let(:other_check_class) { spy(MyOtherCheckClass) } 37 | before do 38 | allow(Rack::ECG::CheckRegistry).to(receive(:lookup).with(:my_check).and_return(check_class)) 39 | allow(Rack::ECG::CheckRegistry).to(receive(:lookup).with(:my_other_check).and_return(other_check_class)) 40 | end 41 | 42 | it "builds all registered checks" do 43 | check_factory.build_all 44 | expect(check_class).to(have_received(:new).with(no_args)) 45 | expect(other_check_class).to(have_received(:new).with(foo: "bar")) 46 | end 47 | end 48 | 49 | context "with defined default checks" do 50 | let(:default_checks) { [:http] } 51 | let(:http_class) { spy(Rack::ECG::Check::Http) } 52 | before do 53 | allow(Rack::ECG::CheckRegistry).to(receive(:lookup).with(:http).and_return(http_class)) 54 | end 55 | 56 | it "builds registered default checks" do 57 | check_factory.build_all 58 | expect(http_class).to(have_received(:new).with(no_args)) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/rack/ecg.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/ecg/version" 4 | require "json" 5 | require "open3" 6 | require "rack/ecg/check_factory" 7 | 8 | module Rack 9 | class ECG 10 | # Default mount path. 11 | DEFAULT_MOUNT_AT = "/_ecg" 12 | # Checks enabled by default. 13 | DEFAULT_CHECKS = [:http] 14 | # Default failure response status. 15 | DEFAULT_FAILURE_STATUS = 500 16 | 17 | # Constructs an instance of ECG Rack middleware with the specified 18 | # options. 19 | # 20 | # @param app [Object,nil] Underlying Rack application to receive unmatched 21 | # requests. If unset, any unmatched requests will return a 404. 22 | # @param checks [Array>] Sets and 23 | # configures the checks run by this instance. 24 | # @param at [String, nil] Path which this ECG instance handles. 25 | # @param hook [#call, nil] Callable which receives the success status and 26 | # check results 27 | # @param failure_status [Integer] Status code to return on check failure 28 | def initialize(app = nil, checks: DEFAULT_CHECKS, at: DEFAULT_MOUNT_AT, hook: nil, 29 | failure_status: DEFAULT_FAILURE_STATUS) 30 | @app = app 31 | 32 | check_configuration = checks || [] 33 | @check_factory = CheckFactory.new(check_configuration, DEFAULT_CHECKS) 34 | @mount_at = at || DEFAULT_MOUNT_AT 35 | 36 | @result_hook = hook 37 | 38 | @failure_response_status = failure_status 39 | end 40 | 41 | # Rack compatible call method. Not intended for direct usage. 42 | def call(env) 43 | if env["PATH_INFO"] == @mount_at 44 | check_results = @check_factory.build_all.inject({}) do |results, check| 45 | results.merge(check.result.as_json) 46 | end 47 | 48 | success = check_results.none? { |check| check[1][:status] == Check::Status::ERROR } 49 | 50 | response_status = success ? 200 : @failure_response_status 51 | 52 | @result_hook&.call(success, check_results) 53 | 54 | response_headers = { 55 | "x-rack-ecg-version" => Rack::ECG::VERSION, 56 | "content-type" => "application/json", 57 | } 58 | 59 | response_body = JSON.pretty_generate(check_results) 60 | 61 | [response_status, response_headers, [response_body]] 62 | elsif @app 63 | @app.call(env) 64 | else 65 | [404, {}, []] 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ### Fixed 10 | 11 | - Resolved Rack::Response#header deprecation warnings when running rspec 12 | 13 | ## [0.3.0] - 2023-09-08 14 | 15 | ### Added 16 | 17 | - (#53) Add a configuration option for the unhealthy status code - thanks @floere! 18 | 19 | ### Removed 20 | 21 | - **Breaking:** Dropped support for Ruby versions < 2.7.0 22 | 23 | ## [0.2.0] - 2022-02-21 24 | 25 | ### Added 26 | 27 | - The `static` check, which uses the provided check parameters to return the same result every time. 28 | 29 | ### Changed 30 | 31 | - **Breaking**: The Redis check now requires being configured with an instance of the Redis client, via the named `instance` parameter 32 | - **Deprecated**: The `git_revision` check will be removed in rack-ecg version 1.0.0. For a suggested replacement, [see the GitRevision Check Replacement example](./examples/gitrevision_check_replacement.ru), which uses the `static` check to memoize the value. 33 | 34 | ### Removed 35 | 36 | - **Breaking:** Dropped support for Ruby versions < 2.6.0 37 | 38 | ## [0.1.0] - 2020-12-16 39 | 40 | ### Added 41 | 42 | - YARD-based gem documentation 43 | 44 | ### Changed 45 | 46 | - **Breaking**: The Rack::ECG initializer now uses named options, instead of an options hash. 47 | 48 | If you manually initialized an instance, you may need to use the `**` operator to pass these options. (e.g. `Rack::ECG.new(nil, **options)`) 49 | 50 | ### Removed 51 | 52 | - **Breaking:** Dropped support for Ruby versions < 2.5.0 53 | 54 | ## [0.0.5] - 2017-05-12 55 | 56 | ### Added 57 | 58 | - A new `sequel` check (#8), which checks if the Sequel database connection is active. 59 | 60 | ## [0.0.4] - 2017-05-04 61 | 62 | ### Added 63 | 64 | - A new `active_record` check (#7), which checks if the ActiveRecord connection is active. 65 | - A new `redis` check (#7), which checks if the Redis connection is active. 66 | 67 | ## [0.0.3] - 2017-02-13 68 | 69 | ### Added 70 | 71 | - Accept a `hook` in configuration, which is run when all check results have been gathered (#6) 72 | 73 | ### Fixed 74 | 75 | - Resolved an issue with the migration version check and MySQL connections (#3) 76 | 77 | ## [0.0.2] - 2015-06-17 78 | 79 | ### Added 80 | 81 | - Support running Rack::ECG as a standalone application 82 | - Support adding checks via the `CheckRegistry` 83 | 84 | ## [0.0.1] - 2015-04-09 85 | 86 | ### Added 87 | 88 | - Base middleware to use in Rails or Rack apps 89 | - `git_revision` check to return the current git revision 90 | - `migration_version` check to return the current ActiveRecord migration version 91 | 92 | [Unreleased]: https://github.com/envato/rack-ecg/compare/v0.3.0...HEAD 93 | [0.3.0]: https://github.com/envato/rack-ecg/compare/v0.2.0...v0.3.0 94 | [0.2.0]: https://github.com/envato/rack-ecg/compare/v0.1.0...v0.2.0 95 | [0.1.0]: https://github.com/envato/rack-ecg/compare/v0.0.5...v0.1.0 96 | [0.0.5]: https://github.com/envato/rack-ecg/compare/v0.0.4...v0.0.5 97 | [0.0.4]: https://github.com/envato/rack-ecg/compare/v0.0.3...v0.0.4 98 | [0.0.3]: https://github.com/envato/rack-ecg/compare/v0.0.2...v0.0.3 99 | [0.0.2]: https://github.com/envato/rack-ecg/compare/v0.0.1...v0.0.2 100 | [0.0.1]: https://github.com/envato/rack-ecg/releases/tag/v0.0.1 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::ECG 2 | 3 | [![Gem version](https://img.shields.io/gem/v/rack-ecg)][gem-page] [![Rubydoc](https://img.shields.io/badge/docs-rubydoc-success)][rubydoc] 4 | 5 | Rack middleware for Ruby web apps, providing a simple and extensible health 6 | check endpoint, with minimal configuration. 7 | 8 | > Electrocardiogram (ECG): A recording of the electrical activity of the heart. 9 | 10 | ## Features 11 | 12 | - Start with a single line in your `config.ru` or `config/application.rb` file. 13 | - reports git revision status 14 | - reports ActiveRecord migration schema version 15 | - reports errors if any check can't be executed for whatever reason 16 | - JSON output 17 | 18 | ## Development Status 19 | 20 | [![Build Status](https://github.com/envato/rack-ecg/workflows/test/badge.svg?branch=main)](https://github.com/envato/rack-ecg/actions?query=branch%3Amain) 21 | 22 | `Rack::ECG` is extracted from production code in use at 23 | [Envato](http://envato.com). However, it is undergoing early development, and 24 | APIs and features are almost certain to be in flux. 25 | 26 | ## Getting Started 27 | 28 | Add this to your application's `Gemfile`: 29 | 30 | ```ruby 31 | gem 'rack-ecg', '~> 0.3.0` 32 | ``` 33 | 34 | Then run `bundle install`. 35 | 36 | In Rails you can add `Rack::ECG` to your `config/application.rb` as a middleware: 37 | 38 | ```ruby 39 | # config/application.rb 40 | # ... 41 | config.middleware.use Rack::ECG 42 | # ... 43 | ``` 44 | 45 | In Rack apps, you can add `Rack::ECG` to your `config.ru`: 46 | 47 | ```ruby 48 | # config.ru 49 | require 'rack/ecg' 50 | 51 | use Rack::ECG 52 | 53 | run MyRackApp 54 | ``` 55 | 56 | ## Usage 57 | 58 | You can now hit your app and get a basic health check response from `Rack::ECG` 59 | 60 | ``` 61 | $ curl http://localhost:9292/_ecg 62 | { 63 | "http": { 64 | "status": "ok", 65 | "value": "online" 66 | } 67 | } 68 | ``` 69 | 70 | `/_ecg` will return a `200` HTTP status if all the checks are OK, or `500` 71 | status if any of the checks fail. 72 | 73 | 74 | ## Configuration 75 | 76 | There are options that can be passed to `use Rack::ECG` to customise how it works. 77 | 78 | ### Checks 79 | 80 | By default, `Rack::ECG` indicates that the service is reponsive via a `http` check. Additional checks are included in this gem, and can be enabled by passing their configuration to the `checks` parameter. To enable a check, add its name, and optionally configuration, to the `checks` array: 81 | 82 | ```ruby 83 | use Rack::ECG, checks: [ 84 | # no configuration required, or allowed 85 | :http, 86 | # passing configuration options 87 | [:static, { name: "app", value: "my-cool-app" }], 88 | # some checks can be used multiple times 89 | [:static, { name: "env", value: Rails.env }], 90 | ] 91 | ``` 92 | 93 | #### `active_record` 94 | 95 | Requires a configured ActiveRecord connection. Does not support configuration. Indicates whether the connection to the default database is currently open. On success, returns something in the following format: 96 | 97 | ```json 98 | { 99 | "active_record": { 100 | "status": "ok", 101 | "value": true 102 | } 103 | } 104 | ``` 105 | 106 | #### `error` 107 | 108 | Does not support configuration. Always returns the following: 109 | 110 | ```json 111 | { 112 | "error": { 113 | "status": "error", 114 | "value": "PC LOAD ERROR" 115 | } 116 | } 117 | ``` 118 | 119 | #### `git_revision` 120 | 121 | **Deprecated**: will be removed in version 1.0.0. [See the GitRevision Check Replacement example](./examples/git_revision_check_replacement.ru), which uses the `static` check to memoize the value. 122 | 123 | Requires the `git` executable on path, and that the application's working directory is within a Git repository. Does not support configuration. On success, returns something in the following format: 124 | 125 | ```json 126 | { 127 | "git_revision": { 128 | "status": "ok", 129 | "value": "dc840f9d5563e6e5a8b34da29c298764e3046039" 130 | } 131 | } 132 | ``` 133 | 134 | #### `http` 135 | 136 | Automatically included, and does not support configuration. Always returns the following: 137 | 138 | ```json 139 | { 140 | "http": { 141 | "status": "ok", 142 | "value": "online" 143 | } 144 | } 145 | ``` 146 | 147 | #### `migration_version` 148 | 149 | Requires a configured ActiveRecord connection, and that ActiveRecord migrations are in use. Does not support configuration. Queries the `schema_versions` table on the default database to report the current migration version. On success, returns something in the following format: 150 | 151 | ```json 152 | { 153 | "migration_version": { 154 | "status": "ok", 155 | "value": "20210506024055" 156 | } 157 | } 158 | ``` 159 | 160 | #### `redis` 161 | 162 | Requires the Redis gem. Requires configuration, an instance of a Redis client. Indicates whether the Redis client passed in is currently connected to the Redis database. On success, returns something in the following format: 163 | 164 | ```json 165 | { 166 | "redis": { 167 | "status": "ok", 168 | "value": true 169 | } 170 | } 171 | ``` 172 | 173 | #### `sequel` 174 | 175 | Requires the Sequel gem. Requires configuration, and can be configured multiple times. Indicates whether a (new) connection can be established to the configured Sequel database. 176 | 177 | Given the following configuration: 178 | 179 | ```ruby 180 | { 181 | connection: "sqlite://events.db", 182 | name: "events", # must be unique per sequel check 183 | } 184 | ``` 185 | 186 | Returns the something in the following format on success: 187 | 188 | ```json 189 | { 190 | "sequel_events": { 191 | "status": "ok", 192 | "value": true 193 | } 194 | } 195 | ``` 196 | 197 | #### `static` 198 | 199 | Returns the same value every time. Requires configuration, and can be configured multiple times. 200 | 201 | Given the following configuration: 202 | 203 | ```ruby 204 | { 205 | name: "image_build_url", # must be unique per static check 206 | success: true, # default value 207 | status: Rack::ECG::Check::Status::OK, # optional, overrides `success` 208 | value: ENV["IMAGE_BUILD_URL"], 209 | } 210 | ``` 211 | 212 | Returns the something in the following format: 213 | 214 | ```json 215 | { 216 | "image_build_url": { 217 | "status": "ok", 218 | "value": "https://example.com/pipelines/my-cool-app/builds/1234" 219 | } 220 | } 221 | ``` 222 | 223 | ### `at` 224 | 225 | By default `Rack::ECG` is mapped to a URL of `/_ecg`, you can set this to 226 | a different path by setting the `at` option. e.g. 227 | 228 | ```ruby 229 | use Rack::ECG, at: "/health_check" 230 | ``` 231 | 232 | ### `hook` 233 | 234 | The `hook` option takes a `Proc` or equivalent, and calls it after the checks 235 | have run, but before the response is complete. 236 | 237 | ```ruby 238 | use Rack::ECG, hook: Proc.new { |success, _checks| puts "Is healthy? #{success}" } 239 | ``` 240 | 241 | - `success`: whether the response will indicate success 242 | - `checks`: an array of the check names and values 243 | 244 | ### `failure_status` 245 | 246 | By default, check failures result in a HTTP status code `500` (Internal Server 247 | Error). You can change this code by setting the `failure_status` option. e.g. 248 | 249 | ```ruby 250 | use Rack::ECG, checks: [:error], failure_status: 503 251 | ``` 252 | 253 | ## Requirements 254 | 255 | - Ruby >= 2.6 256 | - Rack 257 | - To use optional `git_revision` check, your deployed code needs to be in a git repo, and 258 | `git` command must be accessible on the server 259 | - To use optional `migration_version` check, you must be using ActiveRecord and 260 | migrations stored in `schema_versions` table 261 | 262 | ## Examples 263 | 264 | Some configuration examples are provided in [/examples](https://github.com/envato/rack-ecg/tree/main/examples). 265 | 266 | ## Contact 267 | 268 | - [github project](https://github.com/envato/rack-ecg) 269 | - [gitter chat room ![Join the chat at 270 | https://gitter.im/envato/rack-ecg](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/envato/rack-ecg) 271 | - Bug reports and feature requests are via [github issues](https://github.com/envato/rack-ecg/issues) 272 | 273 | ## Maintainers 274 | 275 | - [Liam Dawson](https://github.com/liamdawson) 276 | - [Andrew Davis](https://github.com/andyjdavis) 277 | 278 | ## Contributors 279 | 280 | - [Tao Guo](https://github.com/taoza) 281 | - [Julian Doherty](https://github.com/madlep) 282 | - [Warren Seen](https://github.com/warrenseen) 283 | 284 | ## License 285 | 286 | `Rack::ECG` uses MIT license. See 287 | [`LICENSE.txt`](https://github.com/envato/rack-ecg/blob/main/LICENSE.txt) for 288 | details. 289 | 290 | ## Code of conduct 291 | 292 | We welcome contribution from everyone. Read more about it in 293 | [`CODE_OF_CONDUCT.md`](https://github.com/envato/rack-ecg/blob/main/CODE_OF_CONDUCT.md) 294 | 295 | ## Contributing 296 | 297 | For bug fixes, documentation changes, and small features: 298 | 299 | 1. Fork it ( https://github.com/[my-github-username]/rack-ecg/fork ) 300 | 2. Create your feature branch (git checkout -b my-new-feature) 301 | 3. Commit your changes (git commit -am 'Add some feature') 302 | 4. Push to the branch (git push origin my-new-feature) 303 | 5. Create a new Pull Request 304 | 305 | For larger new features: Do everything as above, but first also make contact with the project maintainers to be sure your change fits with the project direction and you won't be wasting effort going in the wrong direction. 306 | 307 | ## About 308 | 309 | This project is maintained by the [Envato engineering team][webuild] and funded by [Envato][envato]. 310 | 311 | [Envato logo][envato] 312 | 313 | Encouraging the use and creation of open source software is one of the ways we serve our community. See [our other projects][oss] or [come work with us][careers] where you'll find an incredibly diverse, intelligent and capable group of people who help make our company succeed and make our workplace fun, friendly and happy. 314 | 315 | [webuild]: http://webuild.envato.com?utm_source=github 316 | [envato]: https://envato.com?utm_source=github 317 | [oss]: http://opensource.envato.com//?utm_source=github 318 | [careers]: http://careers.envato.com/?utm_source=github 319 | [gem-page]: https://rubygems.org/gems/rack-ecg 320 | [rubydoc]: https://www.rubydoc.info/gems/rack-ecg/ 321 | -------------------------------------------------------------------------------- /spec/rack_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "open3" 4 | require "rack" 5 | require "stringio" 6 | 7 | RSpec.describe("when used as middleware") do 8 | let(:app) do 9 | opts = options 10 | Rack::Builder.new do 11 | use Rack::ECG, **opts 12 | run lambda { |env| 13 | if env["PATH_INFO"] == "/hello/world" 14 | [200, {}, ["Hello, World"]] 15 | else 16 | [404, {}, ["Goodbye, World"]] 17 | end 18 | } 19 | end 20 | end 21 | let(:options) do 22 | {} # empty default 23 | end 24 | 25 | context "main app" do 26 | it "responds OK for normal requests" do 27 | get "/hello/world" 28 | expect(last_response).to(be_ok) 29 | end 30 | 31 | it "doesn't include an X-Rack-ECG-Version custom header" do 32 | get "/hello/world" 33 | expect(last_response.headers["X-Rack-ECG-Version"]).to(be_nil) 34 | end 35 | end 36 | 37 | context "ecg app" do 38 | it "responds " do 39 | get "/_ecg" 40 | expect(last_response).to(be_ok) 41 | end 42 | 43 | it "includes an X-Rack-ECG-Version custom header" do 44 | get "/_ecg" 45 | expect(last_response.headers["X-Rack-ECG-Version"]).to(eq(Rack::ECG::VERSION)) 46 | end 47 | 48 | context "when `at` config option is set" do 49 | let(:options) do 50 | { at: "/health_check" } 51 | end 52 | 53 | it "responds from that path" do 54 | get "/health_check" 55 | expect(last_response.headers["X-Rack-ECG-Version"]).to(eq(Rack::ECG::VERSION)) 56 | end 57 | end 58 | 59 | context "when all checks pass" do 60 | it "has a success error code" do 61 | get "_ecg" 62 | expect(last_response.status).to(eq(200)) 63 | end 64 | end 65 | 66 | context "when a checks errors" do 67 | let(:options) do 68 | { checks: [:error] } 69 | end 70 | it "has a failure error code" do 71 | get "_ecg" 72 | expect(last_response.status).to(eq(500)) 73 | end 74 | 75 | context "with failure status option" do 76 | let(:options) do 77 | { checks: [:error], failure_status: 503 } 78 | end 79 | it "has a failure error code" do 80 | get "_ecg" 81 | expect(last_response.status).to(eq(503)) 82 | end 83 | end 84 | end 85 | 86 | context "when hook config option is set" do 87 | let(:hook_proc) { instance_double(Proc) } 88 | let(:options) do 89 | { hook: hook_proc, checks: :error } 90 | end 91 | 92 | it "executes the hook proc with success status and check results as params" do 93 | expect(hook_proc).to(receive(:call)) do |success, check_results| 94 | expect(success).to(be_falsey) 95 | expect(check_results).to(have_key(:error)) 96 | end 97 | get "_ecg" 98 | expect(last_response.status).to(eq(500)) 99 | end 100 | end 101 | 102 | context "git revision" do 103 | let(:options) do 104 | { checks: [:git_revision] } 105 | end 106 | context "when available" do 107 | let(:sha) { "cafe1234" } 108 | it "is reported" do 109 | expect(Open3).to(receive(:popen3) 110 | .with("git rev-parse HEAD") 111 | .and_return([ 112 | nil, # stdin 113 | StringIO.new(sha + "\n"), # stdout 114 | StringIO.new, # stderr 115 | double(value: double(Process::Status, success?: true)), # wait thread & process status 116 | ])) 117 | get "/_ecg" 118 | expect(json_body["git_revision"]["status"]).to(eq("ok")) 119 | expect(json_body["git_revision"]["value"]).to(eq(sha)) 120 | end 121 | end 122 | 123 | context "when not available" do 124 | let(:error_message) { "git had a sad" } 125 | it "is reported" do 126 | expect(Open3).to(receive(:popen3) 127 | .with("git rev-parse HEAD") 128 | .and_return([ 129 | nil, # stdin 130 | StringIO.new, # stdout 131 | StringIO.new(error_message + "\n"), # stderr 132 | double(value: double(Process::Status, success?: false)), # wait thread & process status 133 | ])) 134 | get "/_ecg" 135 | expect(json_body["git_revision"]["status"]).to(eq("error")) 136 | expect(json_body["git_revision"]["value"]).to(eq("git had a sad")) 137 | end 138 | end 139 | end 140 | 141 | context "migration version" do 142 | let(:options) do 143 | { checks: [:migration_version] } 144 | end 145 | let(:connection) { double("connection") } 146 | let(:version) { "123456" } 147 | 148 | context "when available" do 149 | it "is reported" do 150 | class ActiveRecord 151 | class Base 152 | class << self 153 | def connection 154 | end 155 | end 156 | end 157 | end 158 | expect(ActiveRecord::Base).to(receive(:connection).and_return(connection)) 159 | expect(connection).to(receive(:select_value) 160 | .with("select max(version) from schema_migrations") 161 | .and_return(version)) 162 | get "/_ecg" 163 | expect(json_body["migration_version"]["status"]).to(eq("ok")) 164 | expect(json_body["migration_version"]["value"]).to(eq(version)) 165 | end 166 | end 167 | 168 | context "when not available" do 169 | it "is reported" do 170 | Object.send(:remove_const, :ActiveRecord) if defined?(ActiveRecord) 171 | get "/_ecg" 172 | expect(json_body["migration_version"]["status"]).to(eq("error")) 173 | expect(json_body["migration_version"]["value"]).to(eq("ActiveRecord not found")) 174 | end 175 | end 176 | end 177 | 178 | context "active_record" do 179 | let(:options) do 180 | { checks: [:active_record] } 181 | end 182 | context "when available" do 183 | let(:active) { true } 184 | let(:connection) { double("connection") } 185 | it "is reported" do 186 | class ActiveRecord 187 | class Base 188 | class << self 189 | def connection 190 | end 191 | end 192 | end 193 | end 194 | expect(ActiveRecord::Base).to(receive(:connection).and_return(connection)) 195 | expect(connection).to(receive(:active?).and_return(active)) 196 | get "/_ecg" 197 | expect(json_body["active_record"]["status"]).to(eq("ok")) 198 | expect(json_body["active_record"]["value"]).to(eq(active.to_s)) 199 | end 200 | end 201 | 202 | context "when not available" do 203 | it "is reported" do 204 | Object.send(:remove_const, :ActiveRecord) if defined?(ActiveRecord) 205 | get "/_ecg" 206 | expect(json_body["active_record"]["status"]).to(eq("error")) 207 | expect(json_body["active_record"]["value"]).to(eq("ActiveRecord not found")) 208 | end 209 | end 210 | end 211 | 212 | context "redis" do 213 | let(:options) do 214 | { checks: [[:redis, { instance: instance }]] } 215 | end 216 | let(:instance) { instance_double("Redis", connected?: connected) } 217 | let(:connected) { true } 218 | 219 | before do 220 | # make sure Redis is defined 221 | class Redis 222 | def connected? 223 | end 224 | end unless defined?(Redis) 225 | end 226 | 227 | context "when available" do 228 | it "is reported" do 229 | expect(instance).to(receive(:connected?).and_return(connected)) 230 | get "/_ecg" 231 | expect(json_body["redis"]["status"]).to(eq("ok")) 232 | expect(json_body["redis"]["value"]).to(eq(connected.to_s)) 233 | end 234 | end 235 | 236 | context "the instance is not connected" do 237 | let(:connected) { false } 238 | 239 | it "is reported" do 240 | expect(instance).to(receive(:connected?).and_return(connected)) 241 | get "/_ecg" 242 | expect(json_body["redis"]["status"]).to(eq("error")) 243 | expect(json_body["redis"]["value"]).to(eq(connected.to_s)) 244 | end 245 | end 246 | 247 | context "without instance parameters" do 248 | let(:options) do 249 | { checks: [:redis] } 250 | end 251 | 252 | it "is reported" do 253 | get "/_ecg" 254 | expect(json_body["redis"]["status"]).to(eq("error")) 255 | expect(json_body["redis"]["value"]).to(eq("Redis instance parameters not found")) 256 | end 257 | end 258 | 259 | context "when not available" do 260 | it "is reported" do 261 | Object.send(:remove_const, :Redis) if defined?(Redis) 262 | get "/_ecg" 263 | expect(json_body["redis"]["status"]).to(eq("error")) 264 | expect(json_body["redis"]["value"]).to(eq("Redis not found")) 265 | end 266 | end 267 | end 268 | 269 | context "sequel" do 270 | let(:options) do 271 | { checks: [[:sequel, { name: "My Awesome DB", connection: "sqlite://" }]] } 272 | end 273 | let(:instance) { double("sequel_db") } 274 | 275 | context "when available" do 276 | it "is reported" do 277 | class Sequel 278 | class << self 279 | def connect(_) 280 | end 281 | end 282 | end 283 | expect(Sequel).to(receive(:connect).with("sqlite://").and_yield(instance)) 284 | expect(instance).to(receive(:test_connection).and_return(true)) 285 | get "/_ecg" 286 | expect(json_body["sequel_my_awesome_db"]["status"]).to(eq("ok")) 287 | expect(json_body["sequel_my_awesome_db"]["value"]).to(eq("true")) 288 | end 289 | end 290 | end 291 | 292 | context "static" do 293 | let(:options) do 294 | { checks: [[:static, static_options]] } 295 | end 296 | 297 | context "success is true" do 298 | let(:static_options) do 299 | { success: true, value: "ready" } 300 | end 301 | 302 | it "reports success" do 303 | get "/_ecg" 304 | expect(json_body["static"]["status"]).to(eq("ok")) 305 | expect(json_body["static"]["value"]).to(eq("ready")) 306 | end 307 | end 308 | 309 | context "success is false" do 310 | let(:static_options) do 311 | { success: false, value: "unhealthy" } 312 | end 313 | 314 | it "reports an error" do 315 | get "/_ecg" 316 | expect(json_body["static"]["status"]).to(eq("error")) 317 | expect(json_body["static"]["value"]).to(eq("unhealthy")) 318 | end 319 | end 320 | 321 | context "when a name is set" do 322 | let(:static_options) do 323 | { value: "this is the static value" } 324 | end 325 | 326 | it "reports under that name" do 327 | get "/_ecg" 328 | expect(json_body["static"]["value"]).to(eq("this is the static value")) 329 | end 330 | end 331 | 332 | context "when status is set" do 333 | let(:static_options) do 334 | { value: "no error", success: false, status: "ok" } 335 | end 336 | 337 | it "reports that status" do 338 | get "/_ecg" 339 | expect(json_body["static"]["status"]).to(eq("ok")) 340 | end 341 | end 342 | end 343 | end 344 | end 345 | --------------------------------------------------------------------------------