katalyst-healthcheck 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []