├── .tool-versions ├── .env ├── .rspec ├── Rakefile ├── bin ├── test ├── setup └── console ├── lib ├── http_health_check │ ├── version.rb │ ├── utils.rb │ ├── probes.rb │ ├── config │ │ └── dsl.rb │ ├── probe.rb │ ├── probe │ │ └── result.rb │ ├── probes │ │ ├── delayed_job.rb │ │ ├── sidekiq.rb │ │ └── ruby_kafka.rb │ ├── utils │ │ └── karafka.rb │ └── rack_app.rb └── http_health_check.rb ├── .rubocop.yml ├── .gitignore ├── Gemfile ├── spec ├── http_health_check │ ├── probe │ │ └── result_spec.rb │ ├── probes │ │ ├── delayed_job_spec.rb │ │ ├── sidekiq_spec.rb │ │ └── ruby_kafka_spec.rb │ ├── probe_spec.rb │ ├── utils │ │ └── karafka_spec.rb │ ├── rack_app_spec.rb │ └── http_health_check_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── Dockerfile ├── .bumpversion.cfg ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── ci.yml ├── Appraisals ├── docker-compose.yml ├── .gitlab-ci.yml ├── LICENSE ├── CHANGELOG.md ├── dip.yml ├── http_health_check.gemspec └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.0 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REDIS_URL=redis://:supersecret@redis:6379/10 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --require rails_helper 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | task default: %i[] 5 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | bundle exec rubocop 6 | bundle exec appraisal rspec 7 | -------------------------------------------------------------------------------- /lib/http_health_check/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | VERSION = '1.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/http_health_check/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'utils/karafka' if defined?(::Karafka::App) 4 | 5 | module HttpHealthCheck 6 | module Utils; end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | bundle exec appraisal install 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | 4 | Style/Documentation: 5 | Enabled: false 6 | 7 | Metrics/BlockLength: 8 | Exclude: 9 | - "spec/**/*.rb" 10 | - "http_health_check.gemspec" 11 | 12 | Style/CommentedKeyword: 13 | Enabled: false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /log/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /.idea 11 | *.gem 12 | /test-results/ 13 | .rspec_status 14 | /Gemfile.lock 15 | /gemfiles/*gemfile* 16 | /spec/internal/log/*.log 17 | test.log 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source ENV.fetch('RUBYGEMS_PUBLIC_SOURCE', 'https://rubygems.org/') 4 | 5 | # Specify your gem's dependencies in http_health_check.gemspec 6 | gemspec 7 | 8 | group :test do 9 | gem 'simplecov', require: false 10 | gem 'simplecov-cobertura', require: false 11 | end 12 | -------------------------------------------------------------------------------- /lib/http_health_check/probes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'probes/sidekiq' if defined?(::Sidekiq) 4 | require_relative 'probes/delayed_job' if defined?(::Delayed::Job) 5 | require_relative 'probes/ruby_kafka' if defined?(::Kafka::Consumer) 6 | 7 | module HttpHealthCheck 8 | module Probes; end 9 | end 10 | -------------------------------------------------------------------------------- /spec/http_health_check/probe/result_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec' 4 | 5 | describe HttpHealthCheck::Probe::Result do 6 | it 'handles configuration errors' do 7 | expect { described_class.ok(42) } 8 | .to raise_error(HttpHealthCheck::ConfigurationError, "can't convert Integer into Hash") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION 2 | 3 | FROM ruby:$RUBY_VERSION 4 | 5 | ARG BUNDLER_VERSION 6 | ARG RUBYGEMS_VERSION 7 | 8 | ENV BUNDLE_JOBS=4 \ 9 | BUNDLE_RETRY=3 10 | 11 | RUN gem update --system "${RUBYGEMS_VERSION}" \ 12 | && rm /usr/local/lib/ruby/gems/*/specifications/default/bundler*.gemspec \ 13 | && gem install --default bundler:${BUNDLER_VERSION} \ 14 | && gem install bundler -v ${BUNDLER_VERSION} 15 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.1 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | message = bump version {current_version} → {new_version} 7 | 8 | [bumpversion:file:lib/http_health_check/version.rb] 9 | 10 | [bumpversion:file:README.md] 11 | 12 | [bumpversion:file:Gemfile.lock] 13 | search = http_health_check ({current_version}) 14 | replace = http_health_check ({new_version}) 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'http_health_check' 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 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | 6 | - 7 | 8 | ## Related tickets 9 | 10 | - 11 | 12 | # What's inside 13 | 14 | 19 | - [x] A 20 | 21 | # Checklist: 22 | 23 | - [ ] I have added tests 24 | - [ ] I have made corresponding changes to the documentation 25 | -------------------------------------------------------------------------------- /lib/http_health_check/config/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | module Config 5 | class Dsl 6 | def initialize 7 | @routes = {} 8 | end 9 | attr_reader :routes, :configured_fallback_app, :configured_logger 10 | 11 | def probe(path, handler = nil, &block) 12 | @routes[path] = block_given? ? block : handler 13 | end 14 | 15 | def fallback_app(handler = nil, &block) 16 | @configured_fallback_app = block_given? ? block : handler 17 | end 18 | 19 | def logger(logger) 20 | @configured_logger = logger 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/http_health_check/probe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'probe/result' 4 | 5 | module HttpHealthCheck 6 | module Probe 7 | def call(env) 8 | with_error_handler { probe(env) } 9 | end 10 | 11 | def meta 12 | {} 13 | end 14 | 15 | def probe_ok(extra_meta = {}) 16 | Result.ok(meta.merge(extra_meta)) 17 | end 18 | 19 | def probe_error(extra_meta = {}) 20 | Result.error(meta.merge(extra_meta)) 21 | end 22 | 23 | def with_error_handler 24 | yield 25 | rescue StandardError => e 26 | probe_error(error_class: e.class.name, error_message: e.message) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/http_health_check/probe/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | module Probe 5 | class Result 6 | def self.ok(meta) 7 | new(true, meta) 8 | end 9 | 10 | def self.error(meta) 11 | new(false, meta) 12 | end 13 | 14 | def initialize(is_ok, meta) 15 | @meta = Hash(meta) 16 | @ok = is_ok 17 | rescue StandardError => e 18 | e = ::HttpHealthCheck::ConfigurationError.new(e.message) 19 | e.set_backtrace(e.backtrace) 20 | raise e 21 | end 22 | attr_reader :meta 23 | 24 | def ok? 25 | @ok 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # See compatibility table at https://www.fastruby.io/blog/ruby/rails/versions/compatibility-table.html 4 | 5 | versions_map = { 6 | '6.0' => %w[2.7], 7 | '6.1' => %w[2.7 3.0], 8 | '7.0' => %w[3.1], 9 | '7.1' => %w[3.2], 10 | '7.2' => %w[3.3 3.4], 11 | '8.0' => %w[3.4] 12 | } 13 | 14 | current_ruby_version = RUBY_VERSION.split('.').first(2).join('.') 15 | 16 | versions_map.each do |rails_version, ruby_versions| 17 | ruby_versions.each do |ruby_version| 18 | next if ruby_version != current_ruby_version 19 | 20 | appraise "rails-#{rails_version}" do 21 | gem 'rails', "~> #{rails_version}.0" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/http_health_check/probes/delayed_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | module Probes 5 | class DelayedJob 6 | class HealthCheckJob 7 | def self.perform; end 8 | 9 | def self.queue_name 10 | 'health-check' 11 | end 12 | end 13 | include ::HttpHealthCheck::Probe 14 | 15 | def initialize(delayed_job: ::Delayed::Job) 16 | @delayed_job = delayed_job 17 | end 18 | 19 | def probe(_env) 20 | @delayed_job.where(queue: HealthCheckJob.queue_name).each(&:destroy!) 21 | @delayed_job.enqueue(HealthCheckJob).destroy! 22 | probe_ok 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Provide configs 16 | 2. Run command 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Context (please complete the following information):** 26 | - Gem version 27 | - Ruby version 28 | - Rails version 29 | - Gruf version 30 | - Faraday version 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /lib/http_health_check/probes/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | module Probes 5 | class Sidekiq 6 | include ::HttpHealthCheck::Probe 7 | 8 | TTL_SEC = 3 9 | MAGIC_NUMBER = 42 10 | 11 | def initialize(sidekiq: ::Sidekiq) 12 | @sidekiq_module = sidekiq 13 | end 14 | 15 | def probe(_env) 16 | @sidekiq_module.redis do |conn| 17 | conn.call('SET', meta[:redis_key], MAGIC_NUMBER, 'EX', TTL_SEC) 18 | probe_ok 19 | end 20 | end 21 | 22 | def meta 23 | @meta ||= { redis_key: redis_key } 24 | end 25 | 26 | private 27 | 28 | def redis_key 29 | @redis_key ||= ['sidekiq-healthcheck', ::Socket.gethostname, ::Process.pid].join('::') 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Engine root is used by rails_configuration to correctly 4 | # load fixtures and support files 5 | require 'pathname' 6 | ENGINE_ROOT = Pathname.new(File.expand_path('..', __dir__)) 7 | 8 | require 'combustion' 9 | 10 | begin 11 | Combustion.initialize! :action_controller do 12 | config.log_level = :fatal if ENV['LOG'].to_s.empty? 13 | config.i18n.available_locales = %i[ru en] 14 | config.i18n.default_locale = :ru 15 | end 16 | rescue StandardError => e 17 | # Fail fast if application couldn't be loaded 18 | warn "💥 Failed to load the app: #{e.message}\n#{e.backtrace.join("\n")}" 19 | exit(1) 20 | end 21 | 22 | require 'rspec/rails' 23 | 24 | # Add additional requires below this line. Rails is not loaded until this point! 25 | 26 | Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ruby: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | RUBY_VERSION: ${RUBY_VERSION:-3.3} 8 | BUNDLER_VERSION: 2.4.22 9 | RUBYGEMS_VERSION: 3.4.22 10 | image: http_health_check-dev:0.1.0-ruby_${RUBY_VERSION:-3.3} 11 | environment: 12 | HISTFILE: /app/tmp/.bash_history 13 | BUNDLE_PATH: /usr/local/bundle 14 | BUNDLE_CONFIG: /app/.bundle/config 15 | REDIS_URL: redis://:supersecret@redis:6379/10 16 | command: bash 17 | working_dir: /app 18 | depends_on: 19 | redis: 20 | condition: service_healthy 21 | volumes: 22 | - .:/app:cached 23 | - ${SBMT_RUBYGEMS_PATH:-..}:/app/vendor/gems:cached 24 | - bundler_data:/usr/local/bundle 25 | 26 | redis: 27 | image: bitnami/redis:6.2 28 | environment: 29 | REDIS_PASSWORD: supersecret 30 | volumes: 31 | - redis:/data 32 | healthcheck: 33 | test: redis-cli -a supersecret ping 34 | interval: 10s 35 | ports: 36 | - '6379' 37 | 38 | volumes: 39 | bundler_data: 40 | redis: 41 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: "nstmrt/rubygems/templates" 3 | ref: master 4 | file: "build-rubygems.yml" 5 | 6 | lint: 7 | stage: test 8 | image: ${BUILD_CONF_HARBOR_REGISTRY}/dhub/library/ruby:3.3 9 | tags: 10 | - paas-tests 11 | script: 12 | - bundle install 13 | - bundle exec rubocop 14 | 15 | tests: 16 | stage: test 17 | image: ${BUILD_CONF_HARBOR_REGISTRY}/dhub/library/ruby:$RUBY_VERSION 18 | tags: 19 | - paas-tests 20 | services: 21 | - name: ${BUILD_CONF_HARBOR_REGISTRY}/dhub/bitnami/redis:6.2 22 | alias: redis 23 | variables: 24 | REDIS_PASSWORD: supersecret 25 | parallel: 26 | matrix: 27 | - RUBY_VERSION: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 28 | before_script: 29 | - gem sources --remove https://rubygems.org/ 30 | - gem sources --add ${RUBYGEMS_PUBLIC_SOURCE} 31 | - gem install bundler -v 2.3.26 32 | - bin/setup 33 | script: 34 | - bundle exec appraisal rspec --format RspecJunitFormatter --out test-results/rspec_$RUBY_VERSION.xml --format documentation 35 | artifacts: 36 | reports: 37 | junit: test-results/rspec*.xml 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 SberMarket Tech 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG.md 2 | 3 | ## 1.1.0 (2025-03-28) 4 | 5 | Features: 6 | 7 | - Support rack v3 8 | 9 | ## 1.0.0 (2024-12-28) 10 | 11 | Features: 12 | 13 | - Drop ruby 2.5/2.6 support 14 | - Add appraisals 15 | 16 | Fix: 17 | 18 | - Fix ruby-kafka health-check bug for some ActiveSupport versions 19 | 20 | ## 0.5.0 (2023-08-16) 21 | 22 | Features: 23 | 24 | - Add Sidekiq 6+ support [PR#4](https://github.com/SberMarket-Tech/http_health_check/pull/4) 25 | 26 | ## 0.4.1 (2022-08-05) 27 | 28 | Fix: 29 | 30 | - Fix DelayedJob probe [PR#2](https://github.com/SberMarket-Tech/http_health_check/pull/2) 31 | 32 | ## 0.4.0 (2022-07-20) 33 | 34 | Features: 35 | 36 | - add karafka consumer groups utility function 37 | 38 | ## 0.3.0 (2022-07-19) 39 | 40 | Features: 41 | 42 | - add ruby-kafka probe 43 | 44 | ## 0.2.1 (2022-07-18) 45 | 46 | Fix: 47 | 48 | - fix gemspec 49 | 50 | ## 0.2.0 (2022-07-18) 51 | 52 | Features: 53 | 54 | - add an ability to configure logger 55 | 56 | Fix: 57 | 58 | - fix builtin probes requirement 59 | 60 | ## 0.1.1 (2022-07-17) 61 | 62 | Features: 63 | 64 | - implement basic functionality 65 | - add builtin sidekiq probe 66 | - add builtin delayed job probe 67 | -------------------------------------------------------------------------------- /lib/http_health_check/utils/karafka.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'thor' 4 | 5 | module HttpHealthCheck 6 | module Utils 7 | module Karafka 8 | # returns a list of consumer groups configured for current process 9 | # 10 | # @param karafka_app descendant of Karafka::App 11 | def self.consumer_groups(karafka_app, program_name: $PROGRAM_NAME, argv: ARGV) # rubocop:disable Metrics/AbcSize 12 | all_groups = karafka_app.consumer_groups.map(&:id) 13 | client_id_prefix = "#{karafka_app.config.client_id.gsub('-', '_')}_" 14 | 15 | return all_groups if program_name.split('/').last != 'karafka' 16 | return all_groups if argv[0] != 'server' 17 | 18 | parsed_option = Thor::Options.new( 19 | consumer_groups: Thor::Option.new(:consumer_groups, type: :array, default: nil, aliases: :g) 20 | ).parse(argv).fetch('consumer_groups', []).first.to_s 21 | return all_groups if parsed_option == '' 22 | 23 | groups_from_option = parsed_option.split(' ').map { |g| client_id_prefix + g } & all_groups 24 | groups_from_option.empty? ? all_groups : groups_from_option 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | env: 13 | RUBY_VERSION: "3.3" 14 | name: Rubocop 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | - name: Setup Ruby w/ same version as image 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: "3.3" 22 | - name: Install dependencies 23 | run: | 24 | gem install dip 25 | dip bundle install 26 | - name: Run linter 27 | run: dip rubocop 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | ruby: [ '2.7', '3.0', '3.1', '3.2', '3.3' ] 35 | env: 36 | RUBY_VERSION: ${{ matrix.ruby }} 37 | name: Ruby ${{ matrix.ruby }} 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Setup Ruby w/ same version as image 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby }} 45 | - name: Install dependencies 46 | run: | 47 | gem install dip 48 | dip provision 49 | - name: Run tests 50 | run: dip appraisal rspec --format RspecJunitFormatter --out test-results/rspec_${{ matrix.ruby }}.xml --format documentation 51 | -------------------------------------------------------------------------------- /lib/http_health_check/rack_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'logger' 5 | 6 | module HttpHealthCheck 7 | class RackApp 8 | HEADERS = { 'content-type' => 'application/json' } # rubocop:disable Style/MutableConstant 9 | DEFAULT_FALLBACK_APP = ->(_env) { [404, HEADERS, ['{"error": "not_found"}']] } 10 | LIVENESS_CHECK_APP = ->(_env) { [200, HEADERS, ["{}\n"]] } 11 | 12 | def self.configure 13 | config = Config::Dsl.new 14 | yield config 15 | 16 | fallback_app = config.configured_fallback_app || DEFAULT_FALLBACK_APP 17 | new(config.routes, fallback_app: fallback_app, logger: config.configured_logger) 18 | end 19 | 20 | def initialize(routes, fallback_app: DEFAULT_FALLBACK_APP, logger: nil) 21 | @logger = logger || Logger.new(IO::NULL, level: Logger::Severity::UNKNOWN) 22 | @fallback_app = ensure_callable!(fallback_app) 23 | @routes = routes.each_with_object('/liveness' => LIVENESS_CHECK_APP) do |(path, handler), acc| 24 | acc[path.to_s] = ensure_callable!(handler) 25 | end 26 | end 27 | attr_reader :routes, :fallback_app, :logger 28 | 29 | def call(env) 30 | result = routes.fetch(env[Rack::REQUEST_PATH], fallback_app).call(env) 31 | return result unless result.is_a?(Probe::Result) 32 | 33 | [result.ok? ? 200 : 500, HEADERS, [result.meta.to_json]] 34 | end 35 | 36 | private 37 | 38 | def ensure_callable!(obj) 39 | return obj if obj.respond_to?(:call) 40 | 41 | raise ::HttpHealthCheck::ConfigurationError, 'HTTP handler must be callable' 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /dip.yml: -------------------------------------------------------------------------------- 1 | version: '7' 2 | 3 | environment: 4 | RUBY_VERSION: '3.3' 5 | 6 | compose: 7 | files: 8 | - docker-compose.yml 9 | 10 | interaction: 11 | bash: 12 | description: Open the Bash shell in app's container 13 | service: ruby 14 | command: /bin/bash 15 | 16 | bundle: 17 | description: Run Bundler commands 18 | service: ruby 19 | command: bundle 20 | 21 | rails: 22 | description: Run RoR commands 23 | service: ruby 24 | command: bundle exec rails 25 | 26 | appraisal: 27 | description: Run Appraisal commands 28 | service: ruby 29 | command: bundle exec appraisal 30 | 31 | rspec: 32 | description: Run Rspec commands 33 | service: ruby 34 | command: bundle exec rspec 35 | subcommands: 36 | all: 37 | command: bundle exec appraisal rspec 38 | rails-6.0: 39 | command: bundle exec appraisal rails-6.0 rspec 40 | rails-6.1: 41 | command: bundle exec appraisal rails-6.1 rspec 42 | rails-7.0: 43 | command: bundle exec appraisal rails-7.0 rspec 44 | rails-7.1: 45 | command: bundle exec appraisal rails-7.1 rspec 46 | rails-7.2: 47 | command: bundle exec appraisal rails-7.2 rspec 48 | 49 | rubocop: 50 | description: Run Ruby linter 51 | service: ruby 52 | command: bundle exec rubocop 53 | 54 | setup: 55 | description: Install deps 56 | service: ruby 57 | command: bin/setup 58 | 59 | test: 60 | description: Run linters, run all tests 61 | service: ruby 62 | command: bin/test 63 | 64 | provision: 65 | - dip compose down --volumes 66 | - rm -f Gemfile.lock 67 | - rm -f gemfiles/*gemfile* 68 | - dip setup 69 | -------------------------------------------------------------------------------- /spec/http_health_check/probes/delayed_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Delayed 4 | module Job; end 5 | end 6 | 7 | require 'redis' 8 | require_relative '../../../lib/http_health_check/probes/delayed_job' 9 | 10 | describe HttpHealthCheck::Probes::DelayedJob do 11 | subject { described_class.new(delayed_job: delayed_job) } 12 | let(:delayed_job) { double } 13 | let(:enqueued_job) { double } 14 | let(:existing_jobs) { [double] } 15 | 16 | it 'returns ok-result on success' do 17 | expect(delayed_job).to receive(:where) 18 | .with(queue: HttpHealthCheck::Probes::DelayedJob::HealthCheckJob.queue_name) 19 | .and_return(existing_jobs) 20 | 21 | expect(delayed_job).to receive(:enqueue) 22 | .with(HttpHealthCheck::Probes::DelayedJob::HealthCheckJob) 23 | .and_return(enqueued_job) 24 | 25 | expect(existing_jobs[0]).to receive(:destroy!).and_return(true) 26 | expect(enqueued_job).to receive(:destroy!).and_return(true) 27 | 28 | result = subject.call(nil) 29 | expect(result).to be_ok 30 | end 31 | 32 | it 'wraps exceptions into error-result' do 33 | expect(delayed_job).to receive(:where) 34 | .with(queue: HttpHealthCheck::Probes::DelayedJob::HealthCheckJob.queue_name) 35 | .and_return(existing_jobs) 36 | 37 | expect(delayed_job).to receive(:enqueue) 38 | .with(HttpHealthCheck::Probes::DelayedJob::HealthCheckJob) 39 | .and_raise(RuntimeError, 'boom') 40 | 41 | expect(existing_jobs[0]).to receive(:destroy!).and_return(true) 42 | 43 | result = subject.call(nil) 44 | expect(result).not_to be_ok 45 | expect(result.meta[:error_class]).to eq('RuntimeError') 46 | expect(result.meta[:error_message]).to eq('boom') 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/http_health_check/probe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe HttpHealthCheck::Probe do 6 | subject(:probe) { probe_class.new(probe_action, probe_meta) } 7 | 8 | let(:probe_class) do 9 | Class.new do 10 | include HttpHealthCheck::Probe 11 | 12 | def initialize(action, meta) 13 | @action = action 14 | @meta = meta 15 | end 16 | attr_reader :meta 17 | 18 | def probe(env) 19 | @action.call(env) 20 | probe_ok probe: :ok 21 | end 22 | end 23 | end 24 | 25 | let(:probe_meta) { {} } 26 | let(:probe_action) { ->(_env) { :ok } } 27 | 28 | context 'when probe raises an exception' do 29 | let(:probe_action) { ->(_env) { raise StandardError, 'boom' } } 30 | 31 | context 'without meta' do 32 | it 'returns result with details' do 33 | result = probe.call(:fake) 34 | expect(result).to be_an_instance_of(HttpHealthCheck::Probe::Result) 35 | expect(result).not_to be_ok 36 | expect(result.meta).to eq(error_class: 'StandardError', error_message: 'boom') 37 | end 38 | end 39 | 40 | context 'with meta' do 41 | let(:probe_meta) { { foo: :bar } } 42 | 43 | it 'includes meta into result' do 44 | result = probe.call(:fake) 45 | expect(result.meta).to eq(foo: :bar, error_class: 'StandardError', error_message: 'boom') 46 | end 47 | end 48 | end 49 | 50 | context 'when probe returns ok' do 51 | let(:probe_meta) { { foo: :bar } } 52 | 53 | it 'wraps it into result struct including meta' do 54 | result = probe.call(:fake) 55 | expect(result).to be_ok 56 | expect(result.meta).to eq(foo: :bar, probe: :ok) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/http_health_check/utils/karafka_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | 5 | require_relative '../../../lib/http_health_check/utils/karafka' 6 | 7 | describe HttpHealthCheck::Utils::Karafka do 8 | describe '.consumer_groups' do 9 | let(:karafka_app) do 10 | OpenStruct.new( 11 | consumer_groups: %w[foo bar baz].map { |cg| OpenStruct.new(id: "foo_app_#{cg}") }, 12 | config: OpenStruct.new(client_id: 'foo-app') 13 | ) 14 | end 15 | 16 | let(:program_name) { '/bin/karafka' } 17 | let(:result) do 18 | described_class.consumer_groups(karafka_app, argv: argv, program_name: program_name) 19 | end 20 | 21 | context 'when script executed with --consumer_groups option' do 22 | let(:argv) { ['server', '--foo', 'bar', '--consumer-groups', 'bar baz'] } 23 | 24 | it 'returns CLI-selected consumer groups' do 25 | expect(result).to eq(%w[foo_app_bar foo_app_baz]) 26 | end 27 | end 28 | 29 | context 'when script executed with --g shortcut option' do 30 | let(:argv) { ['server', '-g', 'foo baz'] } 31 | 32 | it 'returns CLI-selected consumer groups' do 33 | expect(result).to eq(%w[foo_app_foo foo_app_baz]) 34 | end 35 | end 36 | 37 | context 'when script executed without consumer group opts' do 38 | let(:argv) { ['server'] } 39 | 40 | it 'returns all karafka app\'s groups' do 41 | expect(result).to eq(%w[foo_app_foo foo_app_bar foo_app_baz]) 42 | end 43 | end 44 | 45 | context 'when unknown consumer group given as cli arg' do 46 | let(:argv) { ['server', '-g', 'foo xxx'] } 47 | 48 | it 'filters it out' do 49 | expect(result).to eq(%w[foo_app_foo]) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/http_health_check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | require 'rackup/handler/webrick' if Gem::Version.new(::Rack.release) >= Gem::Version.new('3') 6 | 7 | require_relative 'http_health_check/version' 8 | require_relative 'http_health_check/config/dsl' 9 | require_relative 'http_health_check/probe' 10 | require_relative 'http_health_check/rack_app' 11 | require_relative 'http_health_check/probes' 12 | require_relative 'http_health_check/utils' 13 | 14 | module HttpHealthCheck 15 | class Error < StandardError; end 16 | 17 | class ConfigurationError < Error; end 18 | 19 | def self.configure(&block) 20 | @rack_app = RackApp.configure(&block) 21 | end 22 | 23 | def self.rack_app 24 | @rack_app ||= RackApp.configure { |c| add_builtin_probes(c) } 25 | end 26 | 27 | def self.add_builtin_probes(conf) 28 | if defined?(::Sidekiq) 29 | require_relative 'http_health_check/probes/sidekiq' unless defined?(Probes::Sidekiq) 30 | conf.probe '/readiness/sidekiq', Probes::Sidekiq.new 31 | end 32 | 33 | if defined?(::Delayed::Job) 34 | require_relative 'http_health_check/probes/delayed_job' unless defined?(Probes::DelayedJob) 35 | conf.probe '/readiness/delayed_job', Probes::DelayedJob.new 36 | end 37 | 38 | conf 39 | end 40 | 41 | def self.run_server_async(opts) 42 | Thread.new { run_server(**opts) } 43 | end 44 | 45 | def self.run_server(port:, host: '0.0.0.0', rack_app: nil) 46 | rack_app ||= ::HttpHealthCheck.rack_app 47 | rack_app_with_logger = ::Rack::CommonLogger.new(rack_app, rack_app.logger) 48 | 49 | webrick.run(rack_app_with_logger, 50 | Host: host, 51 | Port: port, 52 | AccessLog: [], 53 | Logger: rack_app.logger) 54 | end 55 | 56 | def self.webrick 57 | return ::Rack::Handler::WEBrick if Gem::Version.new(::Rack.release) < Gem::Version.new('3') 58 | 59 | ::Rackup::Handler::WEBrick 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /http_health_check.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/http_health_check/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'http_health_check' 7 | spec.version = HttpHealthCheck::VERSION 8 | spec.licenses = ['MIT'] 9 | spec.authors = ['Kuper Ruby Platform Team'] 10 | 11 | spec.summary = 'Simple and extensible HTTP health checks server.' 12 | spec.description = spec.summary 13 | spec.homepage = 'https://github.com/Kuper-Tech/http_health_check' 14 | spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0') 15 | 16 | spec.metadata['allowed_push_host'] = ENV.fetch('NEXUS_URL', 'https://rubygems.org') 17 | 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = spec.homepage 20 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 21 | 22 | spec.files = Dir.chdir(__dir__) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 25 | end 26 | end 27 | spec.bindir = 'exe' 28 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 29 | spec.require_paths = ['lib'] 30 | 31 | spec.add_dependency 'rack', '>= 2' 32 | spec.add_dependency 'webrick' 33 | 34 | spec.add_development_dependency 'activesupport', '>= 6.0' 35 | spec.add_development_dependency 'appraisal', '>= 2.4' 36 | spec.add_development_dependency 'bundler', '>= 2.3' 37 | spec.add_development_dependency 'combustion', '>= 1.3' 38 | spec.add_development_dependency 'dotenv', '~> 2.7.6' 39 | spec.add_development_dependency 'rake', '>= 13.0' 40 | spec.add_development_dependency 'redis' 41 | spec.add_development_dependency 'redis-client' 42 | spec.add_development_dependency 'rspec', '>= 3.0' 43 | spec.add_development_dependency 'rspec_junit_formatter' 44 | spec.add_development_dependency 'rspec-rails' 45 | spec.add_development_dependency 'rubocop', '~> 0.81' 46 | spec.add_development_dependency 'thor', '>= 0.20' 47 | end 48 | -------------------------------------------------------------------------------- /spec/http_health_check/rack_app_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe HttpHealthCheck::RackApp do 6 | describe '.configure' do 7 | it 'provides DSL for app configuration' do 8 | app = described_class.configure do |c| 9 | c.probe '/test', (proc { [418, {}, ["I'm a teapot"]] }) 10 | c.fallback_app do |_env| 11 | [999, {}, [':(']] 12 | end 13 | end 14 | 15 | expect(app.routes['/test'].call(:env).first).to eq(418) 16 | expect(app.fallback_app.call(:env).first).to eq(999) 17 | end 18 | 19 | it 'uses default fallback app if none given' do 20 | app = described_class.configure {} 21 | expect(app.fallback_app).to eq(described_class::DEFAULT_FALLBACK_APP) 22 | end 23 | 24 | it 'validates handler is callable' do 25 | expect do 26 | described_class.configure do |c| 27 | c.probe 'foo', 'bar' 28 | end 29 | end.to raise_error(HttpHealthCheck::ConfigurationError) 30 | end 31 | end 32 | 33 | describe 'call' do 34 | let(:rack_app) do 35 | described_class.configure do |c| 36 | c.probe '/foo' do |_env| 37 | HttpHealthCheck::Probe::Result.ok(foo: 42) 38 | end 39 | 40 | c.probe('/bar') { |_env| [200, {}, ['ok']] } 41 | 42 | c.fallback_app { [404, {}, [':(']] } 43 | end 44 | end 45 | 46 | context 'when it returns a result struct' do 47 | it 'converts it into rack response' do 48 | result = rack_app.call('REQUEST_PATH' => '/foo') 49 | expect(result).to eq([200, described_class::HEADERS, ['{"foo":42}']]) 50 | end 51 | end 52 | 53 | context 'when path matches route' do 54 | it 'calls an app' do 55 | result = rack_app.call('REQUEST_PATH' => '/bar') 56 | expect(result).to eq([200, {}, ['ok']]) 57 | end 58 | end 59 | 60 | context 'when path does not match any route' do 61 | it 'calls fallback handler' do 62 | result = rack_app.call('REQUEST_PATH' => '/unknown') 63 | expect(result).to eq([404, {}, [':(']]) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/http_health_check/probes/sidekiq_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'redis' 4 | require 'redis-client' 5 | module Sidekiq; end 6 | require_relative '../../../lib/http_health_check/probes/sidekiq' 7 | 8 | describe HttpHealthCheck::Probes::Sidekiq do 9 | subject { described_class.new(sidekiq: sidekiq) } 10 | let(:fake_sidekiq) do 11 | Class.new do 12 | def initialize(redis) 13 | @redis = redis 14 | end 15 | 16 | def redis 17 | yield @redis 18 | end 19 | end 20 | end 21 | 22 | let(:sidekiq) { fake_sidekiq.new(redis) } 23 | 24 | shared_context :connected_redis do 25 | let(:redis_url) { ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/1') } 26 | end 27 | 28 | shared_context :disconnected_redis do 29 | let(:redis_url) { 'redis://127.0.0.1:63799/999' } 30 | end 31 | 32 | shared_examples :positive_probe do 33 | it 'writes temporary key into redis and returns positive result' do 34 | result = subject.call(nil) 35 | expect(result).to be_ok 36 | 37 | expect(redis.call('GET', result.meta[:redis_key]).to_i).to eq(described_class::MAGIC_NUMBER) 38 | 39 | ttl = redis.call('TTL', result.meta[:redis_key]).to_i 40 | expect(ttl).to be <= described_class::TTL_SEC 41 | expect(ttl).to be > 0 42 | end 43 | end 44 | 45 | shared_examples :negative_probe do 46 | it 'returns an error' do 47 | result = subject.call(nil) 48 | expect(result).not_to be_ok 49 | expect(result.meta[:error_class].split('::').last).to eq('CannotConnectError') 50 | end 51 | end 52 | 53 | context 'with redis' do 54 | let(:redis) { Redis.new(url: redis_url) } 55 | 56 | context 'when server is available', redis: true do 57 | include_context :connected_redis 58 | 59 | it_behaves_like :positive_probe 60 | end 61 | 62 | context 'when server is not available', redis: true do 63 | include_context :disconnected_redis 64 | 65 | it_behaves_like :negative_probe 66 | end 67 | end 68 | 69 | context 'with redis-client' do 70 | let(:redis) { RedisClient.new(url: redis_url) } 71 | 72 | context 'when server is available', redis: true do 73 | include_context :connected_redis 74 | 75 | it_behaves_like :positive_probe 76 | end 77 | 78 | context 'when redis-client is not available', redis: true do 79 | include_context :disconnected_redis 80 | 81 | it_behaves_like :negative_probe 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | 19 | ENV['RAILS_ENV'] = 'test' 20 | 21 | require 'bundler/setup' 22 | 23 | require 'dotenv/load' 24 | require 'http_health_check' 25 | 26 | if ENV['TEST_COVERAGE'] == 'true' 27 | require 'simplecov' 28 | require 'simplecov-cobertura' 29 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 30 | 31 | SimpleCov.start 'rails' 32 | end 33 | 34 | RSpec.configure do |config| 35 | config.filter_run_excluding redis: true if ENV['SKIP_REDIS_SPECS'] 36 | 37 | # rspec-expectations config goes here. You can use an alternate 38 | # assertion/expectation library such as wrong or the stdlib/minitest 39 | # assertions if you prefer. 40 | config.expect_with :rspec do |expectations| 41 | # This option will default to `true` in RSpec 4. It makes the `description` 42 | # and `failure_message` of custom matchers include text for helper methods 43 | # defined using `chain`, e.g.: 44 | # be_bigger_than(2).and_smaller_than(4).description 45 | # # => "be bigger than 2 and smaller than 4" 46 | # ...rather than: 47 | # # => "be bigger than 2" 48 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 49 | end 50 | 51 | # rspec-mocks config goes here. You can use an alternate test double 52 | # library (such as bogus or mocha) by changing the `mock_with` option here. 53 | config.mock_with :rspec do |mocks| 54 | # Prevents you from mocking or stubbing a method that does not exist on 55 | # a real object. This is generally recommended, and will default to 56 | # `true` in RSpec 4. 57 | mocks.verify_partial_doubles = true 58 | end 59 | 60 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 61 | # have no way to turn it off -- the option exists only for backwards 62 | # compatibility in RSpec 3). It causes shared context metadata to be 63 | # inherited by the metadata hash of host groups and examples, rather than 64 | # triggering implicit auto-inclusion in groups with matching metadata. 65 | config.shared_context_metadata_behavior = :apply_to_host_groups 66 | end 67 | -------------------------------------------------------------------------------- /lib/http_health_check/probes/ruby_kafka.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HttpHealthCheck 4 | module Probes 5 | class RubyKafka 6 | Heartbeat = Struct.new(:time, :group, :topic_partitions) 7 | include ::HttpHealthCheck::Probe 8 | 9 | def initialize(opts = {}) 10 | @heartbeat_event_name = opts.fetch(:heartbeat_event_name, /heartbeat.consumer.kafka/) 11 | @heartbeat_interval_sec = opts.fetch(:heartbeat_interval_sec, 10) 12 | @verbose = opts.fetch(:verbose, false) 13 | @consumer_groups = opts.fetch(:consumer_groups) 14 | .each_with_object(Hash.new(0)) { |group, hash| hash[group] += 1 } 15 | @heartbeats = {} 16 | @timer = opts.fetch(:timer, Time) 17 | 18 | setup_subscriptions 19 | end 20 | 21 | def probe(_env) 22 | now = @timer.now.to_i 23 | failed_heartbeats = select_failed_heartbeats(now) 24 | return probe_ok groups: meta_from_heartbeats(@heartbeats, now) if failed_heartbeats.empty? 25 | 26 | probe_error failed_groups: meta_from_heartbeats(failed_heartbeats, now) 27 | end 28 | 29 | private 30 | 31 | def select_failed_heartbeats(now) 32 | @consumer_groups.each_with_object({}) do |(group, concurrency), hash| 33 | heartbeats = @heartbeats[group] || {} 34 | ok_heartbeats_count = heartbeats.count { |_id, hb| hb.time + @heartbeat_interval_sec >= now } 35 | hash[group] = heartbeats if ok_heartbeats_count < concurrency 36 | end 37 | end 38 | 39 | def meta_from_heartbeats(heartbeats_hash, now) # rubocop: disable Metrics/MethodLength, Metrics/AbcSize 40 | heartbeats_hash.each_with_object({}) do |(group, heartbeats), hash| 41 | concurrency = @consumer_groups[group] 42 | if heartbeats.empty? 43 | hash[group] = { had_heartbeat: false, concurrency: concurrency } 44 | next 45 | end 46 | 47 | hash[group] = { had_heartbeat: true, concurrency: concurrency, threads: {} } 48 | heartbeats.each do |thread_id, heartbeat| 49 | thread_meta = { seconds_since_last_heartbeat: now - heartbeat.time } 50 | thread_meta[:topic_partitions] = heartbeat.topic_partitions if @verbose 51 | hash[group][:threads][thread_id] = thread_meta 52 | end 53 | end 54 | end 55 | 56 | def setup_subscriptions 57 | ActiveSupport::Notifications.subscribe(@heartbeat_event_name) do |*args| 58 | event = ActiveSupport::Notifications::Event.new(*args) 59 | group = event.payload[:group_id] 60 | 61 | @heartbeats[group] ||= {} 62 | @heartbeats[group][event.transaction_id] = Heartbeat.new( 63 | event_time(event), group, event.payload[:topic_partitions] 64 | ) 65 | end 66 | end 67 | 68 | def event_time(event) 69 | # event.time in millis in ActiveSupport >= 7.0 && ActiveSupport< 7.1.4 70 | # see ActiveSupport::Notifications::Event.initialize 71 | active_support_version = ActiveSupport.gem_version 72 | if active_support_version >= Gem::Version.new('7.0.0') && active_support_version < Gem::Version.new('7.1.4') 73 | return (event.time.to_i / 1000) 74 | end 75 | 76 | event.time.to_i 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/http_health_check/http_health_check_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'net/http' 5 | require 'uri' 6 | require 'socket' 7 | 8 | describe HttpHealthCheck do 9 | let(:port) { find_free_tcp_port } 10 | 11 | def find_free_tcp_port 12 | server = TCPServer.new('127.0.0.1', 0) 13 | port = server.addr[1] 14 | server.close 15 | 16 | port 17 | end 18 | 19 | def request(path_with_query) 20 | uri = URI.parse("http://127.0.0.1/#{path_with_query}") 21 | uri.port = port 22 | 23 | Net::HTTP.get_response(uri) 24 | end 25 | 26 | def wait_server_started(attempts_left = 25) 27 | Socket.tcp('127.0.0.1', port) {} 28 | rescue StandardError 29 | raise if attempts_left == 0 30 | 31 | sleep(0.01) 32 | wait_server_started(attempts_left - 1) 33 | end 34 | 35 | def start_server(opts = {}) 36 | HttpHealthCheck.run_server_async(opts.merge(port: port)).tap { wait_server_started } 37 | end 38 | 39 | describe '#run_server_async' do 40 | after { Thread.kill(server) } 41 | 42 | context 'with global configuration' do 43 | let!(:server) { start_server } 44 | 45 | it 'starts http server' do 46 | expect(request('/liveness').code).to eq('200') 47 | expect(request('/foobar').code).to eq('404') 48 | end 49 | end 50 | 51 | context 'with custom configuration' do 52 | class MyCustomProbe 53 | include HttpHealthCheck::Probe 54 | 55 | def probe(env) 56 | if env[Rack::QUERY_STRING].include?('fail') 57 | probe_error query: env[Rack::QUERY_STRING] 58 | elsif env[Rack::QUERY_STRING].include?('raise') 59 | raise 'boom' 60 | else 61 | probe_ok ok: true 62 | end 63 | end 64 | 65 | def meta 66 | { foo: :bar } 67 | end 68 | end 69 | 70 | let(:rack_app) do 71 | HttpHealthCheck::RackApp.configure do |c| 72 | c.probe('/foobar') { |_env| [204, {}, [':)']] } 73 | c.probe('/custom', MyCustomProbe.new) 74 | c.fallback_app { |_env| [418, {}, ['+_+']] } 75 | 76 | HttpHealthCheck.add_builtin_probes(c) 77 | end 78 | end 79 | let!(:server) { start_server rack_app: rack_app } 80 | 81 | it 'starts http server' do 82 | expect(request('/liveness').code).to eq('200') 83 | expect(request('/foobar').code).to eq('204') 84 | expect(request('/bazqux').code).to eq('418') 85 | 86 | resp_custom_ex = request('/custom?raise=true') 87 | expect(resp_custom_ex.code).to eq('500') 88 | expect(JSON.parse(resp_custom_ex.body)).to eq( 89 | 'foo' => 'bar', 90 | 'error_class' => 'RuntimeError', 91 | 'error_message' => 'boom' 92 | ) 93 | 94 | resp_custom_err = request('/custom?fail=true') 95 | expect(resp_custom_err.code).to eq('500') 96 | expect(JSON.parse(resp_custom_err.body)).to eq('foo' => 'bar', 'query' => 'fail=true') 97 | 98 | resp_custom_ok = request('/custom') 99 | expect(resp_custom_ok.code).to eq('200') 100 | expect(JSON.parse(resp_custom_ok.body)).to eq('foo' => 'bar', 'ok' => true) 101 | end 102 | end 103 | 104 | context 'with configured logger' do 105 | let(:io) { StringIO.new } 106 | let(:logger) { Logger.new(io, level: Logger::Severity::INFO) } 107 | 108 | let(:rack_app) do 109 | HttpHealthCheck::RackApp.configure do |c| 110 | HttpHealthCheck.add_builtin_probes(c) 111 | c.logger(logger) 112 | end 113 | end 114 | let!(:server) { start_server rack_app: rack_app } 115 | 116 | it 'logs server start and requests' do 117 | expect(request('/liveness').code).to eq('200') 118 | io.rewind 119 | logs = io.read 120 | 121 | expect(logs).to include('WEBrick::HTTPServer#start') 122 | expect(logs).to include('GET /liveness HTTP/1.1') 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/http_health_check/probes/ruby_kafka_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/notifications' 4 | require_relative '../../../lib/http_health_check/probes/ruby_kafka' 5 | 6 | describe HttpHealthCheck::Probes::RubyKafka do 7 | let(:timer) { double(Time) } 8 | let(:event_name) { 'fake.heartbeat.consumer.kafka' } 9 | let(:consumer_groups) { nil } 10 | let(:verbose) { true } 11 | let!(:probe) do 12 | described_class.new( 13 | consumer_groups: consumer_groups, 14 | timer: timer, 15 | heartbeat_event_name: event_name, 16 | verbose: verbose 17 | ) 18 | end 19 | 20 | def emit_hb_event(group, topic_partitions: nil) 21 | topic_partitions ||= { 'some_topic' => %w[1 2] } 22 | ActiveSupport::Notifications.instrument(event_name, group_id: group, topic_partitions: topic_partitions) {} 23 | end 24 | 25 | context 'with list of consumer groups' do 26 | let(:group) { 'important-consumer' } 27 | let(:consumer_groups) { [group] } 28 | 29 | context 'when heartbeat is expired' do 30 | it 'returns an error' do 31 | topic_partitions = { 'foo' => %w[1 2], 'bar' => %w[3 4] } 32 | emit_hb_event(group, topic_partitions: topic_partitions) 33 | 34 | expect(timer).to receive(:now).and_return(Time.now + 15) 35 | result = probe.call(nil) 36 | expect(result).not_to be_ok 37 | 38 | meta = result.meta[:failed_groups][group] 39 | expect(meta[:had_heartbeat]).to eq(true) 40 | expect(meta[:threads].size).to eq(1) 41 | 42 | thread_meta = meta[:threads].values.first 43 | expect(thread_meta[:seconds_since_last_heartbeat]).to be_within(1).of(15) 44 | expect(thread_meta[:topic_partitions]).to eq(topic_partitions) 45 | end 46 | end 47 | 48 | context 'with multiple threads' do 49 | let(:consumer_groups) { [group, group] } 50 | 51 | context 'when number of heartbeats is equal to group concurrency' do 52 | it 'returns ok' do 53 | emit_hb_event(group) 54 | Thread.new { emit_hb_event(group) } 55 | sleep(0.01) 56 | 57 | expect(timer).to receive(:now).and_return(Time.now) 58 | result = probe.call(nil) 59 | expect(result).to be_ok 60 | end 61 | end 62 | 63 | context 'when some heartbeats are missing' do 64 | it 'returns an error' do 65 | Thread.new { emit_hb_event(group) } 66 | sleep(0.01) 67 | 68 | expect(timer).to receive(:now).and_return(Time.now) 69 | result = probe.call(nil) 70 | expect(result).not_to be_ok 71 | 72 | meta = result.meta[:failed_groups][group] 73 | expect(meta[:had_heartbeat]).to eq(true) 74 | expect(meta[:threads].size).to eq(1) 75 | end 76 | end 77 | end 78 | 79 | context 'when heartbeat had not been emitted yet' do 80 | it 'return an error' do 81 | expect(timer).to receive(:now).and_return(Time.now) 82 | 83 | result = probe.call(nil) 84 | expect(result).not_to be_ok 85 | 86 | meta = result.meta[:failed_groups][group] 87 | expect(meta[:had_heartbeat]).to eq(false) 88 | end 89 | end 90 | 91 | context 'when it noted specified group heartbeat recently' do 92 | it 'returns ok' do 93 | emit_hb_event(group) 94 | 95 | expect(timer).to receive(:now).and_return(Time.now + 5) 96 | result = probe.call(nil) 97 | expect(result).to be_ok 98 | 99 | meta = result.meta[:groups][group] 100 | thread_meta = meta[:threads].values.first 101 | expect(thread_meta[:seconds_since_last_heartbeat]).to be_within(1).of(5) 102 | end 103 | end 104 | 105 | context 'when verbose=false' do 106 | let(:verbose) { false } 107 | 108 | it 'does not include topic_partitions into result meta' do 109 | emit_hb_event(group) 110 | 111 | expect(timer).to receive(:now).and_return(Time.now + 5) 112 | result = probe.call(nil) 113 | expect(result).to be_ok 114 | 115 | meta = result.meta[:groups][group] 116 | thread_meta = meta[:threads].values.first 117 | expect(thread_meta.keys).not_to include(:topic_partitions) 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HttpHealthCheck 2 | 3 | [![Gem Version](https://badge.fury.io/rb/http_health_check.svg)](https://badge.fury.io/rb/http_health_check) 4 | 5 | HttpHealthCheck is a tiny framework for building health check for your application components. It provides a set of built-in checkers (a.k.a. probes) and utilities for building your own. 6 | 7 | HttpHealthCheck is built with kubernetes health probes in mind, but it can be used with http health checker. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'http_health_check', '~> 0.4.1' 15 | ``` 16 | 17 | And then execute: 18 | 19 | $ bundle install 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install http_health_check 24 | 25 | ## Usage 26 | 27 | ### Sidekiq 28 | 29 | Sidekiq health check is available at `/readiness/sidekiq`. 30 | 31 | ```ruby 32 | # ./config/initializers/sidekiq.rb 33 | Sidekiq.configure_server do |config| 34 | HttpHealthCheck.run_server_async(port: 5555) 35 | end 36 | ``` 37 | 38 | ### Delayed Job 39 | 40 | DelayedJob health check is available at `/readiness/delayed_job`. 41 | 42 | ```ruby 43 | # ./script/delayed_job 44 | module Delayed::AfterFork 45 | def after_fork 46 | HttpHealthCheck.run_server_async(port: 5555) 47 | super 48 | end 49 | end 50 | ``` 51 | 52 | ### Karafka ~> 1.4 53 | 54 | Ruby-kafka probe is disabled by default as it requires app-specific configuration to work properly. Example usage with karafka framework: 55 | 56 | ```ruby 57 | # ./karafka.rb 58 | 59 | class KarafkaApp < Karafka::App 60 | # ... 61 | # karafka app configuration 62 | # ... 63 | end 64 | 65 | KarafkaApp.boot! 66 | 67 | HttpHealthCheck.run_server_async( 68 | port: 5555, 69 | rack_app: HttpHealthCheck::RackApp.configure do |c| 70 | c.probe '/readiness/karafka', HttpHealthCheck::Probes::RubyKafka.new( 71 | consumer_groups: HttpHealthCheck::Utils::Karafka.consumer_groups(KarafkaApp), 72 | # default heartbeat interval is 3 seconds, but we want to give it 73 | # an ability to skip a few before failing the probe 74 | heartbeat_interval_sec: 10, 75 | # includes a list of topics and partitions into response for every consumer thread. false by default 76 | verbose: false 77 | ) 78 | end 79 | ) 80 | ``` 81 | 82 | Ruby kafka probe supports multi-threaded setups, i.e. if you are using karafka and you define multiple blocks with the same consumer group like 83 | 84 | ```ruby 85 | class KarafkaApp < Karafka::App 86 | consumer_groups.draw do 87 | consumer_group 'foo' do 88 | # ... 89 | end 90 | end 91 | 92 | consumer_groups.draw do 93 | consumer_group 'foo' do 94 | # ... 95 | end 96 | end 97 | end 98 | 99 | HttpHealthCheck::Utils::Karafka.consumer_groups(KarafkaApp) 100 | # => ['foo', 'foo'] 101 | ``` 102 | 103 | ruby-kafka probe will count heartbeats from multiple threads. 104 | 105 | ### Kubernetes deployment example 106 | 107 | ```yaml 108 | apiVersion: apps/v1 109 | kind: Deployment 110 | metadata: 111 | name: sidekiq 112 | spec: 113 | replicas: 1 114 | selector: 115 | matchLabels: 116 | app: sidekiq 117 | template: 118 | metadata: 119 | labels: 120 | app: sidekiq 121 | spec: 122 | containers: 123 | - name: sidekiq 124 | image: my-app:latest 125 | livenessProbe: 126 | httpGet: 127 | path: /liveness 128 | port: 5555 129 | scheme: HTTP 130 | readinessProbe: 131 | httpGet: 132 | path: /readiness/sidekiq 133 | port: 5555 134 | scheme: HTTP 135 | ``` 136 | 137 | ### Changing global configuration 138 | 139 | ```ruby 140 | HttpHealthCheck.configure do |c| 141 | # add probe with any callable class 142 | c.probe '/health/my_service', MyProbe.new 143 | 144 | # or with block 145 | c.probe '/health/fake' do |_env| 146 | [200, {}, ['OK']] 147 | end 148 | 149 | # optionally add built-in probes 150 | HttpHealthCheck.add_builtin_probes(c) 151 | 152 | # optionally override fallback (route not found) handler 153 | c.fallback_handler do |env| 154 | [404, {}, ['not found :(']] 155 | end 156 | 157 | # configure requests logger. Disabled by default 158 | c.logger Rails.logger 159 | end 160 | ``` 161 | 162 | ### Running server with custom rack app 163 | 164 | ```ruby 165 | rack_app = HttpHealthCheck::RackApp.configure do |c| 166 | c.probe '/health/my_service', MyProbe.new 167 | end 168 | HttpHealthCheck.run_server_async(port: 5555, rack_app: rack_app) 169 | ``` 170 | 171 | ### Writing your own probes 172 | 173 | Probes are built around [HttpHealthCheck::Probe](./lib/http_health_check/probe.rb) mixin. Every probe defines **probe** method which receives [rack env](https://www.rubydoc.info/gems/rack/Rack/Request/Env) 174 | and should return [HttpHealthCheck::Probe::Result](./lib/http_health_check/probe/result.rb) or rack-compatible response (status-headers-body tuple). 175 | Probe-mixin provides convenience methods `probe_ok` and `probe_error` for creating [HttpHealthCheck::Probe::Result](./lib/http_health_check/probe/result.rb) instance. Both of them accept optional metadata hash that will be added to the response body. 176 | Any exception (StandardError) will be captured and converted into error-result. 177 | 178 | ```ruby 179 | class MyProbe 180 | include HttpHealthCheck::Probe 181 | 182 | def probe(_env) 183 | status = MyService.status 184 | return probe_ok if status == :ok 185 | 186 | probe_error status: status 187 | end 188 | end 189 | ``` 190 | 191 | ```ruby 192 | HttpHealthCheck.configure do |config| 193 | config.probe '/readiness/my_service', MyProbe.new 194 | end 195 | ``` 196 | 197 | ### Built-in probes 198 | 199 | #### [Sidekiq](./lib/http_health_check/probes/sidekiq.rb) 200 | 201 | Sidekiq probe ensures that sidekiq is ready by checking redis is available and writable. It uses sidekiq's redis connection pool to avoid spinning up extra connections. 202 | Be aware that this approach does not cover issues with sidekiq being stuck processing slow/endless jobs. Such cases are nearly impossible to cover without false-positive alerts. 203 | 204 | ```ruby 205 | HttpHealthCheck.configure do |config| 206 | config.probe '/readiness/sidekiq', HttpHealthCheck::Probes::Sidekiq.new 207 | end 208 | ``` 209 | 210 | #### [DelayedJob](./lib/http_health_check/probes/delayed_job.rb) (active record) 211 | 212 | Delayed Job probe is intended to work with [active record backend](https://github.com/collectiveidea/delayed_job_active_record). 213 | It checks DelayedJob is healthy by enqueuing an empty job which will be deleted right after insertion. This allows us to be sure that the underlying database is connectable and writable. 214 | Be aware that by enqueuing a new job with every health check, we are incrementing the primary key sequence. 215 | 216 | ```ruby 217 | HttpHealthCheck.configure do |config| 218 | config.probe '/readiness/delayed_job', HttpHealthCheck::Probes::DelayedJob.new 219 | end 220 | ``` 221 | 222 | #### [ruby-kafka](./lib/http_health_check/probes/ruby_kafka.rb) 223 | 224 | ruby-kafka probe is expected to be configured with consumer groups list. It subscribes to ruby-kafka's `heartbeat.consumer.kafka` ActiveSupport notification and tracks heartbeats for every given consumer group. 225 | It expects a heartbeat every `:heartbeat_interval_sec` (10 seconds by default). 226 | 227 | ```ruby 228 | heartbeat_app = HttpHealthCheck::RackApp.configure do |c| 229 | c.probe '/readiness/kafka', HttpHealthCheck::Probes::Karafka.new( 230 | consumer_groups: ['consumer-one', 'consumer-two'], 231 | heartbeat_interval_sec: 42 232 | ) 233 | end 234 | ``` 235 | 236 | ## Development 237 | 238 | After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 239 | Some specs require redis to be run. You can use your own installation or start one via docker-compose. 240 | 241 | ```shell 242 | docker-compose up redis 243 | ``` 244 | 245 | ## Deployment 246 | 247 | 1. Update changelog and git add it 248 | 2. 249 | 250 | ```sh 251 | bump2version patch --allow-dirty 252 | ``` 253 | 254 | 3. git push && git push --tags 255 | 4. gem build 256 | 5. gem push http_health_check-x.x.x.gem 257 | --------------------------------------------------------------------------------