vitals_monitor 0.1.0

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.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ class VitalsController < ActionController::Base
5
+ before_action :set_format
6
+
7
+ def index
8
+ @results = check_all_components
9
+ @overall_status = overall_status(@results)
10
+ status_code = @overall_status == :unhealthy ? 503 : 200
11
+
12
+ respond_to do |format|
13
+ format.html { render status: status_code }
14
+ format.json { render json: format_json_response(@results, @overall_status), status: status_code }
15
+ end
16
+ end
17
+
18
+ def show
19
+ component = params[:component].to_sym
20
+
21
+ unless VitalsMonitor.config.enabled?(component)
22
+ respond_to do |format|
23
+ format.html { render status: 404 }
24
+ format.json { render json: { error: "Component #{component} is not enabled" }, status: 404 }
25
+ end
26
+ return
27
+ end
28
+
29
+ result = check_component(component)
30
+ @result = result
31
+ @component = component
32
+ @status = result[:status]
33
+ status_code = result[:status] == :unhealthy ? 503 : 200
34
+
35
+ respond_to do |format|
36
+ format.html { render status: status_code }
37
+ format.json { render json: format_single_json_response(component, result), status: status_code }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def set_format
44
+ request.format = :html unless request.format.json?
45
+ end
46
+
47
+ def check_all_components
48
+ results = {}
49
+ %i[postgres redis sidekiq].each do |component|
50
+ results[component] = check_component(component) if VitalsMonitor.config.enabled?(component)
51
+ end
52
+ results
53
+ end
54
+
55
+ def check_component(component)
56
+ case component
57
+ when :postgres
58
+ VitalsMonitor::Checks::Postgres.new.check
59
+ when :redis
60
+ VitalsMonitor::Checks::Redis.new.check
61
+ when :sidekiq
62
+ VitalsMonitor::Checks::Sidekiq.new.check
63
+ else
64
+ { status: :unhealthy, message: "Unknown component: #{component}" }
65
+ end
66
+ end
67
+
68
+ def overall_status(results)
69
+ return :unhealthy if results.empty?
70
+ return :unhealthy if results.values.any? { |r| r[:status] == :unhealthy }
71
+
72
+ :healthy
73
+ end
74
+
75
+ def format_json_response(results, overall_status)
76
+ {
77
+ status: overall_status,
78
+ components: results.transform_values do |result|
79
+ {
80
+ status: result[:status],
81
+ message: result[:message]
82
+ }
83
+ end
84
+ }
85
+ end
86
+
87
+ def format_single_json_response(component, result)
88
+ {
89
+ component: component.to_s,
90
+ status: result[:status],
91
+ message: result[:message]
92
+ }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,85 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Vitals Monitor - System Health</title>
5
+ <style>
6
+ body {
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8
+ max-width: 800px;
9
+ margin: 50px auto;
10
+ padding: 20px;
11
+ background-color: #f5f5f5;
12
+ }
13
+ h1 {
14
+ color: #333;
15
+ border-bottom: 2px solid #ddd;
16
+ padding-bottom: 10px;
17
+ }
18
+ .component {
19
+ background: white;
20
+ padding: 15px;
21
+ margin: 10px 0;
22
+ border-radius: 5px;
23
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
24
+ }
25
+ .component-name {
26
+ font-weight: bold;
27
+ font-size: 1.2em;
28
+ margin-bottom: 5px;
29
+ }
30
+ .status-healthy {
31
+ color: #28a745;
32
+ }
33
+ .status-unhealthy {
34
+ color: #dc3545;
35
+ }
36
+ .overall-status {
37
+ padding: 15px;
38
+ margin: 20px 0;
39
+ border-radius: 5px;
40
+ font-size: 1.3em;
41
+ font-weight: bold;
42
+ }
43
+ .overall-healthy {
44
+ background-color: #d4edda;
45
+ color: #155724;
46
+ border: 1px solid #c3e6cb;
47
+ }
48
+ .overall-unhealthy {
49
+ background-color: #f8d7da;
50
+ color: #721c24;
51
+ border: 1px solid #f5c6cb;
52
+ }
53
+ .message {
54
+ color: #666;
55
+ font-size: 0.9em;
56
+ margin-top: 5px;
57
+ }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <h1>System Health Status</h1>
62
+
63
+ <div class="overall-status <%= @overall_status == :healthy ? 'overall-healthy' : 'overall-unhealthy' %>">
64
+ Overall Status: <span class="status-<%= @overall_status %>"><%= @overall_status.to_s.upcase %></span>
65
+ </div>
66
+
67
+ <% if @results.empty? %>
68
+ <div class="component">
69
+ <p>No components are enabled.</p>
70
+ </div>
71
+ <% else %>
72
+ <% @results.each do |component, result| %>
73
+ <div class="component">
74
+ <div class="component-name">
75
+ <%= component.to_s.capitalize %>:
76
+ <span class="status-<%= result[:status] %>"><%= result[:status].to_s.upcase %></span>
77
+ </div>
78
+ <% if result[:message] %>
79
+ <div class="message"><%= result[:message] %></div>
80
+ <% end %>
81
+ </div>
82
+ <% end %>
83
+ <% end %>
84
+ </body>
85
+ </html>
@@ -0,0 +1,68 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Vitals Monitor - <%= @component.to_s.capitalize %> Status</title>
5
+ <style>
6
+ body {
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8
+ max-width: 600px;
9
+ margin: 50px auto;
10
+ padding: 20px;
11
+ background-color: #f5f5f5;
12
+ }
13
+ h1 {
14
+ color: #333;
15
+ border-bottom: 2px solid #ddd;
16
+ padding-bottom: 10px;
17
+ }
18
+ .component {
19
+ background: white;
20
+ padding: 20px;
21
+ margin: 20px 0;
22
+ border-radius: 5px;
23
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
24
+ }
25
+ .component-name {
26
+ font-weight: bold;
27
+ font-size: 1.5em;
28
+ margin-bottom: 10px;
29
+ }
30
+ .status-healthy {
31
+ color: #28a745;
32
+ font-size: 1.2em;
33
+ }
34
+ .status-unhealthy {
35
+ color: #dc3545;
36
+ font-size: 1.2em;
37
+ }
38
+ .message {
39
+ color: #666;
40
+ font-size: 1em;
41
+ margin-top: 10px;
42
+ padding-top: 10px;
43
+ border-top: 1px solid #eee;
44
+ }
45
+ a {
46
+ color: #007bff;
47
+ text-decoration: none;
48
+ }
49
+ a:hover {
50
+ text-decoration: underline;
51
+ }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <h1><%= @component.to_s.capitalize %> Health Status</h1>
56
+
57
+ <div class="component">
58
+ <div class="component-name">
59
+ Status: <span class="status-<%= @status %>"><%= @status.to_s.upcase %></span>
60
+ </div>
61
+ <% if @result[:message] %>
62
+ <div class="message"><%= @result[:message] %></div>
63
+ <% end %>
64
+ </div>
65
+
66
+ <p><a href="<%= vitals_monitor.vitals_path %>">← Back to all vitals</a></p>
67
+ </body>
68
+ </html>
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ VitalsMonitor.configure do |config|
4
+ # Enable or disable specific components
5
+ # By default, all components are enabled
6
+ config.enable(:postgres)
7
+ config.enable(:redis)
8
+ config.enable(:sidekiq)
9
+
10
+ # Or disable specific components
11
+ # config.disable(:sidekiq)
12
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ VitalsMonitor::Engine.routes.draw do
4
+ get "/", to: "vitals#index", as: :vitals
5
+ get "/:component", to: "vitals#show", as: :vitals_component, constraints: { component: /postgres|redis|sidekiq/ }
6
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :vitals_monitor do
4
+ desc "Generate initializer file for VitalsMonitor"
5
+ task :install do
6
+ require "fileutils"
7
+
8
+ initializer_path = File.join(Dir.pwd, "config", "initializers", "vitals_monitor.rb")
9
+
10
+ # Check if we're in a Rails app
11
+ config_dir = File.join(Dir.pwd, "config")
12
+ application_rb = File.join(config_dir, "application.rb")
13
+ routes_rb = File.join(config_dir, "routes.rb")
14
+
15
+ unless File.directory?(config_dir) && (File.exist?(application_rb) || File.exist?(routes_rb))
16
+ puts "Error: This doesn't appear to be a Rails application."
17
+ puts "Please run this task from your Rails application root directory."
18
+ exit 1
19
+ end
20
+
21
+ # Create initializers directory if it doesn't exist
22
+ FileUtils.mkdir_p(File.dirname(initializer_path))
23
+
24
+ # Check if initializer already exists
25
+ if File.exist?(initializer_path)
26
+ puts "Initializer already exists at #{initializer_path}"
27
+ puts "Skipping generation. Delete the file first if you want to regenerate it."
28
+ exit 0
29
+ end
30
+
31
+ # Generate initializer content
32
+ initializer_content = <<~RUBY
33
+ # frozen_string_literal: true
34
+
35
+ VitalsMonitor.configure do |config|
36
+ # Enable or disable specific components
37
+ # By default, all components are enabled
38
+ config.enable(:postgres)
39
+ config.enable(:redis)
40
+ config.enable(:sidekiq)
41
+
42
+ # Or disable specific components
43
+ # config.disable(:sidekiq)
44
+ end
45
+ RUBY
46
+
47
+ # Write the initializer file
48
+ File.write(initializer_path, initializer_content)
49
+
50
+ puts "✓ Generated initializer at #{initializer_path}"
51
+
52
+ # Add mount statement to routes.rb (host app's routes, not gem's routes)
53
+ mount_statement = " mount VitalsMonitor::Engine => '/vitals'"
54
+ mount_pattern = /mount\s+VitalsMonitor::Engine/
55
+
56
+ if File.exist?(routes_rb)
57
+ routes_content = File.read(routes_rb)
58
+
59
+ # Skip if this is the gem's own routes file (contains VitalsMonitor::Engine.routes.draw)
60
+ if routes_content.include?("VitalsMonitor::Engine.routes.draw")
61
+ puts "⚠ Skipping routes modification (detected gem's routes file)"
62
+ puts " Please manually add to your Rails app's config/routes.rb:"
63
+ puts " #{mount_statement.strip}"
64
+ # Check if mount statement already exists
65
+ elsif routes_content.match?(mount_pattern)
66
+ puts "✓ Mount statement already exists in #{routes_rb}"
67
+ else
68
+ # Add mount statement after Rails.application.routes.draw do
69
+ if routes_content.include?("Rails.application.routes.draw do")
70
+ routes_content.gsub!(/(Rails\.application\.routes\.draw\s+do)/, "\\1\n#{mount_statement}")
71
+ File.write(routes_rb, routes_content)
72
+ puts "✓ Added mount statement to #{routes_rb}"
73
+ # Handle alternative route file formats
74
+ elsif routes_content.include?("routes.draw do") && !routes_content.include?("VitalsMonitor::Engine")
75
+ routes_content.gsub!(/(routes\.draw\s+do)/, "\\1\n#{mount_statement}")
76
+ File.write(routes_rb, routes_content)
77
+ puts "✓ Added mount statement to #{routes_rb}"
78
+ # If no draw block found, add it at the end
79
+ else
80
+ routes_content += "\n#{mount_statement}\n"
81
+ File.write(routes_rb, routes_content)
82
+ puts "✓ Added mount statement to #{routes_rb}"
83
+ end
84
+ end
85
+ else
86
+ puts "⚠ Warning: Could not find #{routes_rb}"
87
+ puts " Please manually add: #{mount_statement.strip}"
88
+ end
89
+
90
+ puts ""
91
+ puts "Setup complete! Customize the configuration in #{initializer_path} if needed."
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module VitalsMonitor
6
+ module Checks
7
+ class Base
8
+ TIMEOUT = 5 # seconds
9
+
10
+ def check
11
+ raise NotImplementedError, "Subclasses must implement #check"
12
+ end
13
+
14
+ protected
15
+
16
+ def healthy(message = nil)
17
+ { status: :healthy, message: message }
18
+ end
19
+
20
+ def unhealthy(message)
21
+ { status: :unhealthy, message: message }
22
+ end
23
+
24
+ def timeout
25
+ Timeout.timeout(TIMEOUT) do
26
+ yield
27
+ end
28
+ rescue Timeout::Error
29
+ unhealthy("Health check timed out after #{TIMEOUT} seconds")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ module Checks
5
+ class Postgres < Base
6
+ def check
7
+ timeout do
8
+ connection = ActiveRecord::Base.connection
9
+ connection.execute("SELECT 1")
10
+ healthy
11
+ end
12
+ rescue StandardError => e
13
+ unhealthy("PostgreSQL connection failed: #{e.message}")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ module Checks
5
+ class Redis < Base
6
+ def check
7
+ timeout do
8
+ redis_client = redis_connection
9
+ redis_client.ping
10
+ healthy
11
+ end
12
+ rescue StandardError => e
13
+ unhealthy("Redis connection failed: #{e.message}")
14
+ end
15
+
16
+ private
17
+
18
+ def redis_connection
19
+ if defined?(::Redis) && ::Redis.respond_to?(:current)
20
+ ::Redis.current
21
+ elsif defined?(::Redis) && Rails.application.config.respond_to?(:redis)
22
+ ::Redis.new(Rails.application.config.redis)
23
+ elsif defined?(Sidekiq) && Sidekiq.respond_to?(:redis)
24
+ Sidekiq.redis { |conn| conn }
25
+ else
26
+ raise "Redis connection not configured"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ module Checks
5
+ class Sidekiq < Base
6
+ def check
7
+ timeout do
8
+ unless defined?(::Sidekiq)
9
+ return unhealthy("Sidekiq is not available")
10
+ end
11
+
12
+ stats = ::Sidekiq::Stats.new
13
+
14
+ # Check if Sidekiq can connect to Redis
15
+ stats.processed
16
+
17
+ healthy("Processed: #{stats.processed}, Failed: #{stats.failed}, Enqueued: #{stats.enqueued}")
18
+ end
19
+ rescue StandardError => e
20
+ unhealthy("Sidekiq health check failed: #{e.message}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ class Configuration
5
+ attr_accessor :enabled_components
6
+
7
+ def initialize
8
+ @enabled_components = {
9
+ postgres: true,
10
+ redis: true,
11
+ sidekiq: true
12
+ }
13
+ end
14
+
15
+ def enabled?(component)
16
+ @enabled_components[component.to_sym] == true
17
+ end
18
+
19
+ def enable(component)
20
+ @enabled_components[component.to_sym] = true
21
+ end
22
+
23
+ def disable(component)
24
+ @enabled_components[component.to_sym] = false
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module VitalsMonitor
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace VitalsMonitor
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec
11
+ g.fixture_replacement :factory_bot, dir: "spec/factories"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ module Formatters
5
+ class Json
6
+ def self.format_all(results, overall_status)
7
+ {
8
+ status: overall_status,
9
+ components: results.transform_values do |result|
10
+ {
11
+ status: result[:status],
12
+ message: result[:message]
13
+ }
14
+ end
15
+ }
16
+ end
17
+
18
+ def self.format_single(component, result)
19
+ {
20
+ component: component.to_s,
21
+ status: result[:status],
22
+ message: result[:message]
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsMonitor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vitals_monitor/version"
4
+ require_relative "vitals_monitor/configuration"
5
+
6
+ # Health checks
7
+ require_relative "vitals_monitor/checks/base"
8
+ require_relative "vitals_monitor/checks/postgres"
9
+ require_relative "vitals_monitor/checks/redis"
10
+ require_relative "vitals_monitor/checks/sidekiq"
11
+
12
+ # Require engine only if Rails is available
13
+ if defined?(Rails)
14
+ require_relative "vitals_monitor/engine"
15
+ end
16
+
17
+ module VitalsMonitor
18
+ class Error < StandardError; end
19
+
20
+ def self.config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def self.configure
25
+ yield config if block_given?
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ module VitalsMonitor
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ def self.config: () -> Configuration
8
+ def self.configure: () { (Configuration) -> void } -> void
9
+
10
+ class Configuration
11
+ attr_accessor enabled_components: Hash[Symbol, bool]
12
+
13
+ def initialize: () -> void
14
+ def enabled?: (Symbol | String component) -> bool
15
+ def enable: (Symbol | String component) -> void
16
+ def disable: (Symbol | String component) -> void
17
+ end
18
+
19
+ module Checks
20
+ class Base
21
+ TIMEOUT: Integer
22
+
23
+ def check: () -> { status: :healthy | :unhealthy, message: String? }
24
+
25
+ protected
26
+
27
+ def healthy: (?String? message) -> { status: :healthy, message: String? }
28
+ def unhealthy: (String message) -> { status: :unhealthy, message: String }
29
+ def timeout: () { () -> untyped } -> untyped
30
+ end
31
+
32
+ class Postgres < Base
33
+ def check: () -> { status: :healthy | :unhealthy, message: String? }
34
+ end
35
+
36
+ class Redis < Base
37
+ def check: () -> { status: :healthy | :unhealthy, message: String? }
38
+ end
39
+
40
+ class Sidekiq < Base
41
+ def check: () -> { status: :healthy | :unhealthy, message: String? }
42
+ end
43
+ end
44
+
45
+ class Engine < ::Rails::Engine
46
+ end
47
+
48
+ class VitalsController < ActionController::Base
49
+ def index: () -> void
50
+ def show: () -> void
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/vitals_monitor/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "vitals_monitor"
7
+ spec.version = VitalsMonitor::VERSION
8
+ spec.authors = ["Tomáš Landovský"]
9
+ spec.email = ["landovsky@gmail.com"]
10
+
11
+ spec.summary = "Rails engine providing health check endpoints for Postgres, Redis, and Sidekiq"
12
+ spec.description = "VitalsMonitor is a Rails engine that provides /vitals endpoints to monitor the health status of Postgres, Redis, and Sidekiq. Components can be enabled/disabled via configuration, and endpoints return HTML or JSON with appropriate HTTP status codes for monitoring."
13
+ spec.homepage = "https://github.com/landovsky/vitals_monitor"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/landovsky/vitals_monitor"
19
+ spec.metadata["changelog_uri"] = "https://github.com/landovsky/vitals_monitor/blob/main/CHANGELOG.md"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
26
+ end
27
+ end
28
+ spec.require_paths = ["lib"]
29
+
30
+ # Runtime dependencies
31
+ spec.add_dependency "rails", ">= 5.2"
32
+
33
+ # Optional dependencies (for health checks)
34
+ spec.add_development_dependency "pg", "~> 1.0"
35
+ spec.add_development_dependency "redis", "~> 4.0"
36
+ spec.add_development_dependency "sidekiq", "~> 6.0"
37
+
38
+ # For more information and examples about making a new gem, check out our
39
+ # guide at: https://bundler.io/guides/creating_gem.html
40
+ end