katalyst-healthcheck 0.2.8

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 95661561842df409390e4a798c57b959bcbc39d0803f526fe39c4781b1e87903
4
+ data.tar.gz: a022ff2050eb06f96fa641bad74b0a1a0132b09b2e09cd9171654e2ce93f179d
5
+ SHA512:
6
+ metadata.gz: bc2f21383a0ae5f28f40c072e746e265d481d5a3d99659f2b9dfe4bc2e73e85471a6d3a452f66ce4d3f26aed2eedecd36eab6fc9e4efd1e1936a33a7603a0c91
7
+ data.tar.gz: f6ade891f379e6abd9a959f07568e9e230a3d7ddeea83cd8337656bc8594536091a39bdcac3c948e9da42a4c7efeab8f41de074e8d9c7f285772d937bf03e086
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2021 Andy Williams
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Katalyst::Healthcheck
2
+
3
+ Rails application and background task health monitoring.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'katalyst-healthcheck'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install katalyst-healthcheck
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Configuration:
28
+
29
+ Redis URL will automatically be set from Rails configuration, but can be configured
30
+ manually in an initializer, e.g.
31
+ ```
32
+ Katalyst::Healthcheck.configure do |config|
33
+ config.store.redis.options.url = "redis://hostname:port"
34
+ end
35
+ ```
36
+
37
+ Add to routes:
38
+
39
+ ```
40
+ get '/healthcheck', to: Katalyst::Healthcheck::Route.static(200, "OK")
41
+ get '/healthcheck/status', to: Katalyst::Healthcheck::Route.from_tasks
42
+ get '/healthcheck/dashboard', to: Katalyst::Healthcheck::Route.from_tasks(detail: true)
43
+ ```
44
+
45
+ With the above configuration, the following routes will be available:
46
+
47
+ ### /healthcheck
48
+
49
+ A basic check that tests that the rails application is up and running.
50
+
51
+ ### /healthcheck/status
52
+
53
+ Tests that all application background tasks are running as expected.
54
+
55
+ All background tasks to be monitored must register successful completion of their work using
56
+ the `Katalyst::Healthcheck::Monitored` concern's `healthy!` and `unhealthy` methods, e.g.
57
+
58
+ ``` ruby
59
+ include Katalyst::Healthcheck::Monitored
60
+
61
+ define_task :my_task, "My task description", interval: 1.day
62
+
63
+ def do_task
64
+ ... task code here ...
65
+ healthy!(:my_task)
66
+ rescue Exception => e
67
+ unhealthy!(:my_task, e.message)
68
+ end
69
+ ```
70
+
71
+ Call `healthy!` at successful completion of a task to mark the task as healthy.
72
+ Optionally, calling `unhealthy!` immediately marks that task as unhealthy.
73
+ If a task is not marked as `healthy!` within a predefined interval, that task is considered unhealthy.
74
+
75
+ ## License
76
+
77
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module Katalyst
6
+ module Healthcheck
7
+ module Actions
8
+ class Sidekiq
9
+ include Katalyst::Healthcheck::Monitored
10
+ include ::Sidekiq::Worker
11
+
12
+ sidekiq_options retry: false
13
+
14
+ define_task :sidekiq_health, "Sidekiq background processing", interval: 60
15
+
16
+ def self.call
17
+ perform_async
18
+ end
19
+
20
+ def perform
21
+ self.class.healthy! :sidekiq_health
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ class Config
6
+ attr_reader :store
7
+
8
+ def initialize
9
+ self.store = :redis
10
+ end
11
+
12
+ def store=(name)
13
+ @store = build_store(name)
14
+ end
15
+
16
+ private
17
+
18
+ def build_store(name)
19
+ klass = "Katalyst::Healthcheck::Store::#{name.to_s.camelize}".constantize
20
+ klass.new
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ module Monitored
6
+ extend ActiveSupport::Concern
7
+
8
+ module HealthMethods
9
+ # Define a task to be monitored
10
+ # @param name [Symbol] The name of the task
11
+ # @param description [String] A description of the task's function
12
+ # @param interval [Integer,ActiveSupport::Duration] Expected frequency that this task runs, e.g. 1.day
13
+ def define_task(name, description, interval:)
14
+ defined_healthcheck_tasks[name] = Task.new(name: name, description: description, interval: interval)
15
+ end
16
+
17
+ # Mark a task as healthy
18
+ # @param name [Symbol] The name of the task
19
+ def healthy!(name)
20
+ find_or_create_task(name).healthy!
21
+ end
22
+
23
+ # Mark a task as unhealthy
24
+ # @param name [Symbol] The name of the task
25
+ # @param error [String] Optional error message
26
+ def unhealthy!(name, error = nil)
27
+ find_or_create_task(name).unhealthy!(error)
28
+ end
29
+
30
+ private
31
+
32
+ def find_or_create_task(name)
33
+ task = Task.find(name)
34
+ if task.nil?
35
+ task = defined_healthcheck_tasks[name]
36
+ raise "task #{name} not found" if task.nil?
37
+
38
+ task.save
39
+ end
40
+ task
41
+ end
42
+ end
43
+
44
+ class_methods do
45
+ include HealthMethods
46
+
47
+ attr_accessor :healthcheck_task_definitions
48
+
49
+ # @return [Hash] Defined tasks keyed by name
50
+ def defined_healthcheck_tasks
51
+ self.healthcheck_task_definitions ||= {}
52
+ end
53
+ end
54
+
55
+ include HealthMethods
56
+
57
+ # @return [Hash] Defined tasks keyed by name
58
+ def defined_healthcheck_tasks
59
+ self.class.healthcheck_task_definitions ||= {}
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ path = File.expand_path(__dir__)
8
+ Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ # Represents a status and a message that can be used in a rails route
6
+ class Route
7
+ class << self
8
+ def static(status, message)
9
+ Proc.new { |_env| [status, { "Content-Type" => "text/plain" }, [message]] }
10
+ end
11
+
12
+ def from_tasks(detail: false)
13
+ Proc.new do |_env|
14
+ tasks = Task.all
15
+ status = tasks.all?(&:ok?) ? 200 : 500
16
+ message = status == 200 ? "OK" : "FAIL"
17
+ message = Task.summary if detail
18
+ [status, { "Content-Type" => "text/plain" }, [message]]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ module Katalyst
6
+ module Healthcheck
7
+ module Store
8
+ # In-memory store, intended for spec tests and debugging
9
+ class Memory
10
+ attr_reader :state
11
+
12
+ def initialize(options = {})
13
+ @options = options
14
+ @state = {}
15
+ end
16
+
17
+ # Read state from memory
18
+ # @return [Array<Hash>] List of tasks attribute data
19
+ def read
20
+ state.values
21
+ end
22
+
23
+ # Write state to memory
24
+ # @param name [String] name of the task
25
+ # @param task_state [Hash,nil] Task state
26
+ def update(name, task_state = {})
27
+ if task_state.nil?
28
+ state.delete(name)
29
+ else
30
+ state[name] = task_state
31
+ end
32
+ end
33
+
34
+ # Remove task state
35
+ # @param name [String] name of the task
36
+ def delete(name)
37
+ update(name, nil)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "json"
5
+
6
+ module Katalyst
7
+ module Healthcheck
8
+ module Store
9
+ class Redis
10
+ DEFAULT_CACHE_KEY = "katalyst_healthcheck_tasks"
11
+ MAX_WRITE_TIME = 5000 # milliseconds
12
+ DEFAULT_HOST = "localhost"
13
+ DEFAULT_PORT = 6379
14
+ DEFAULT_OPTIONS = {
15
+ url: "redis://#{DEFAULT_HOST}:#{DEFAULT_PORT}",
16
+ cache_key: DEFAULT_CACHE_KEY,
17
+ }.freeze
18
+
19
+ class << self
20
+ # @return [String] Redis URL defined in rails config
21
+ def rails_redis_url
22
+ redis_config = rails_redis_config || {}
23
+ host = redis_config[:host] || DEFAULT_HOST
24
+ port = redis_config[:port] || DEFAULT_PORT
25
+ "redis://#{host}:#{port}"
26
+ end
27
+
28
+ def rails_redis_config
29
+ Rails.application&.config_for(:redis)
30
+ rescue StandardError
31
+ {}
32
+ end
33
+ end
34
+
35
+ # @attr_reader options [OpenStruct] Redis configuration options
36
+ attr_reader :options
37
+
38
+ def initialize(options = {})
39
+ options = { url: self.class.rails_redis_url }.merge(options) if defined?(Rails)
40
+ options = DEFAULT_OPTIONS.merge(options)
41
+ @options = Struct.new(:url, :cache_key).new(options[:url], options[:cache_key])
42
+ end
43
+
44
+ # @return [Array<Hash>] List of tasks attribute data
45
+ def read
46
+ data = fetch
47
+ tasks = data["tasks"] || {}
48
+ tasks.values
49
+ end
50
+
51
+ # Write task state
52
+ # @param name [String] name of the task
53
+ # @param task_state [Hash,nil] task details. If null, task state will be removed
54
+ def update(name, task_state = {})
55
+ lock_manager.lock!("#{cache_key}_lock", MAX_WRITE_TIME, {}) do
56
+ now = Time.now
57
+ data = fetch
58
+ data["version"] = Katalyst::Healthcheck::VERSION
59
+ data["updated_at"] = now
60
+ data["created_at"] ||= now
61
+ task_data = data["tasks"] ||= {}
62
+ if task_state.nil?
63
+ task_data.delete(name)
64
+ else
65
+ task_data[name] = serialize(task_state)
66
+ end
67
+ client.set(cache_key, JSON.generate(data))
68
+ end
69
+ end
70
+
71
+ # Remove task state
72
+ # @param name [String] name of the task
73
+ def delete(name)
74
+ update(name, nil)
75
+ end
76
+
77
+ private
78
+
79
+ # @param task_state [Hash]
80
+ def serialize(task_state)
81
+ task_state.transform_values do |value|
82
+ case value
83
+ when ActiveSupport::TimeWithZone, DateTime
84
+ value.strftime("%d/%m/%Y %H:%M:%S %z")
85
+ else
86
+ value
87
+ end
88
+ end
89
+ end
90
+
91
+ def cache_key
92
+ options.cache_key || DEFAULT_CACHE_KEY
93
+ end
94
+
95
+ # @return [Hash] Redis data for all tasks
96
+ def fetch
97
+ data = JSON.parse(client.get(cache_key) || "{}")
98
+ data = {} if data["version"] != Katalyst::Healthcheck::VERSION
99
+ data
100
+ end
101
+
102
+ def client
103
+ @client ||= ::Redis.new(options.to_h)
104
+ end
105
+
106
+ def lock_manager
107
+ raise "redis url is required" if options.url.blank?
108
+
109
+ @lock_manager ||= ::Redlock::Client.new([options.url])
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ # Represents a background task that runs periodically in an application
6
+ class Task
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+
10
+ TASK_GRACE_PERIOD = 10 * 60 # 10 minutes
11
+
12
+ class << self
13
+ # @return tasks [Array<Task>] All tasks that have been registered
14
+ def all
15
+ store.read.map { |i| Task.new(i) }
16
+ end
17
+
18
+ # @param name [Symbol] name of the task
19
+ def find(name)
20
+ all.find { |task| task.name == name.to_s }
21
+ end
22
+
23
+ def find!(name)
24
+ find(name) || raise("Undefined task '#{name}'")
25
+ end
26
+
27
+ def destroy!(name)
28
+ store.delete(name)
29
+ end
30
+
31
+ def store
32
+ Katalyst::Healthcheck.config.store
33
+ end
34
+
35
+ # @return [String] Summary of task status
36
+ def summary
37
+ output = []
38
+ all.each do |task|
39
+ output << "#{task.name}:"
40
+ fields = [
41
+ ["Status", task.ok? ? "OK" : "FAIL"],
42
+ ["Description", task.description],
43
+ ["Error", task.error],
44
+ ]
45
+ fields.select { |i| i[1] }.each { |i| output << " #{i.join(': ')}" }
46
+ end
47
+
48
+ output.join("\n")
49
+ end
50
+ end
51
+
52
+ attribute :name
53
+ attribute :last_time, :datetime
54
+ attribute :created_at, :datetime
55
+ attribute :updated_at, :datetime
56
+ attribute :interval, :integer
57
+ attribute :status
58
+ attribute :error
59
+ attribute :server
60
+ attribute :description
61
+
62
+ def ok?
63
+ status&.to_sym != :fail && on_schedule?
64
+ end
65
+
66
+ # @return [Boolean] true if this background task is running on schedule
67
+ def on_schedule?
68
+ next_time.nil? || next_time + TASK_GRACE_PERIOD > Time.current
69
+ end
70
+
71
+ def next_time
72
+ return nil if interval.blank?
73
+
74
+ (last_time || created_at) + interval
75
+ end
76
+
77
+ # Mark this task as healthy and save state
78
+ # @return [Task] This task
79
+ def healthy!
80
+ self.last_time = Time.current
81
+ self.error = nil
82
+ self.status = :ok
83
+
84
+ save
85
+ end
86
+
87
+ # Mark this task as unhealthy and save state
88
+ # @return [Task] This task
89
+ def unhealthy!(error = nil)
90
+ self.error = error.presence || "Fail"
91
+ self.status = :fail
92
+
93
+ save
94
+ end
95
+
96
+ # Save task state
97
+ # @return [Task] This task
98
+ def save
99
+ self.created_at ||= Time.now
100
+ self.updated_at = Time.now
101
+ store.update(name, attributes)
102
+ self
103
+ end
104
+
105
+ def reload
106
+ Task.find(name)
107
+ end
108
+
109
+ private
110
+
111
+ def store
112
+ self.class.store
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :katalyst_healthcheck do
4
+ desc "Display health check information"
5
+ task info: :environment do
6
+ puts Katalyst::Healthcheck::Task.summary
7
+ end
8
+
9
+ desc "Clear status for a health check task"
10
+ task clear_task: :environment do
11
+ task_name = ENV["task_name"]
12
+ if task_name.nil?
13
+ puts "usage: rake #{ARGV[0]} task_name=name\n task name is required."
14
+ exit 1
15
+ end
16
+
17
+ Katalyst::Healthcheck::Task.destroy!(task_name)
18
+ puts "cleared task status for task: #{task_name}"
19
+ end
20
+
21
+ desc "Call the sidekiq health check action to check that sidekiq is able to process background tasks"
22
+ task sidekiq: :environment do
23
+ Katalyst::Healthcheck::Actions::Sidekiq.call
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Healthcheck
5
+ VERSION = "0.2.8"
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_model"
5
+ require "redlock"
6
+
7
+ require "katalyst/healthcheck/version"
8
+ require "katalyst/healthcheck/railtie" if defined?(Rails)
9
+ require "katalyst/healthcheck/config"
10
+ require "katalyst/healthcheck/store/memory"
11
+ require "katalyst/healthcheck/store/redis"
12
+ require "katalyst/healthcheck/task"
13
+ require "katalyst/healthcheck/monitored"
14
+ require "katalyst/healthcheck/route"
15
+ require "katalyst/healthcheck/actions/sidekiq"
16
+
17
+ module Katalyst
18
+ module Healthcheck
19
+ class << self
20
+ def config
21
+ @config ||= Config.new
22
+ end
23
+
24
+ def configure
25
+ instance_eval(&:block)
26
+ end
27
+ end
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: katalyst-healthcheck
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.8
5
+ platform: ruby
6
+ authors:
7
+ - Katalyst Interactive
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-04-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 3.3.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 3.3.5
41
+ - !ruby/object:Gem::Dependency
42
+ name: redlock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 1.2.2
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 1.2.2
55
+ description:
56
+ email:
57
+ - admin@katalyst.com.au
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - MIT-LICENSE
63
+ - README.md
64
+ - Rakefile
65
+ - lib/katalyst/healthcheck.rb
66
+ - lib/katalyst/healthcheck/actions/sidekiq.rb
67
+ - lib/katalyst/healthcheck/config.rb
68
+ - lib/katalyst/healthcheck/monitored.rb
69
+ - lib/katalyst/healthcheck/railtie.rb
70
+ - lib/katalyst/healthcheck/route.rb
71
+ - lib/katalyst/healthcheck/store/memory.rb
72
+ - lib/katalyst/healthcheck/store/redis.rb
73
+ - lib/katalyst/healthcheck/task.rb
74
+ - lib/katalyst/healthcheck/tasks/healthcheck_tasks.rake
75
+ - lib/katalyst/healthcheck/version.rb
76
+ homepage: https://github.com/katalyst/katalyst-healthcheck
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ allowed_push_host: https://rubygems.org
81
+ rubygems_mfa_required: 'true'
82
+ homepage_uri: https://github.com/katalyst/katalyst-healthcheck
83
+ source_code_uri: https://github.com/katalyst/katalyst-healthcheck
84
+ changelog_uri: https://github.com/katalyst/katalyst-healthcheck/blob/master/CHANGELOG.md
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '2.7'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.1.6
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Health check routes and functions
104
+ test_files: []