standard_health 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/MIT-LICENSE +20 -0
- data/README.md +144 -0
- data/Rakefile +16 -0
- data/app/controllers/standard_health/application_controller.rb +28 -0
- data/app/controllers/standard_health/diagnostics_controller.rb +26 -0
- data/app/controllers/standard_health/health_controller.rb +21 -0
- data/config/routes.rb +10 -0
- data/lib/standard_health/aggregator.rb +48 -0
- data/lib/standard_health/check.rb +49 -0
- data/lib/standard_health/checks/active_record.rb +22 -0
- data/lib/standard_health/checks/solid_cache.rb +27 -0
- data/lib/standard_health/checks/solid_queue.rb +36 -0
- data/lib/standard_health/configuration.rb +59 -0
- data/lib/standard_health/engine.rb +7 -0
- data/lib/standard_health/env_spec.rb +109 -0
- data/lib/standard_health/version.rb +5 -0
- data/lib/standard_health.rb +36 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 298b48147224460e942fb5dd9440596ef128f0967dc7212a507756b9b45ae1b5
|
|
4
|
+
data.tar.gz: 5008923ddf09d5637f9e6c07b95bf9073b15d0e79b2d0e00646170478e5d7aee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61f79addc2cba3dc84019036dd38b0917b5c812193b6b8af551b4efdd8578f93499a8efaed5f95b38efa07d71bf5d3bfe19495ff7f54eea145895169289aa7e5
|
|
7
|
+
data.tar.gz: ff348b35a931bed97044118e15b275ac1daed5fae93929c1f2976bc1db1947dbbeb8a05fe8029bc277311e6174e73e8be94c3afc060c880282a1fa1906406d63
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-04-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial release of `standard_health` — a mountable Rails engine providing `/alive`, `/ready`, and `/diagnostics/env` endpoints.
|
|
15
|
+
- `StandardHealth.configure` block with `register_check`, `parent_controller`, and `env_spec` accessors.
|
|
16
|
+
- `StandardHealth::EnvSpec` DSL for declaring required and recommended environment variables, with optional per-mode applicability via `in:`.
|
|
17
|
+
- Built-in checks: `Checks::ActiveRecord`, `Checks::SolidQueue`, `Checks::SolidCache`.
|
|
18
|
+
- `StandardHealth::Aggregator` rolls registered checks into `:ok` / `:degraded` / `:unavailable` overall status.
|
|
19
|
+
- `StandardHealth::Check` base class with a `with_timing` helper for subclasses.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Jaryl Sim
|
|
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 NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# StandardHealth
|
|
2
|
+
|
|
3
|
+
A drop-in health check and environment-spec engine for Rails 8 host apps.
|
|
4
|
+
|
|
5
|
+
Mount it once and you get:
|
|
6
|
+
|
|
7
|
+
- `GET /health/alive` — liveness probe (always 200 if Rails is up)
|
|
8
|
+
- `GET /health/ready` — readiness probe; runs every registered check and rolls them up into an overall status
|
|
9
|
+
- `GET /health/diagnostics/env` — audits the host app's `ENV` against a declarative spec
|
|
10
|
+
|
|
11
|
+
Built-in checks cover ActiveRecord, SolidQueue, and SolidCache. Host apps can register additional checks via the configuration block.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "standard_health"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then `bundle install`.
|
|
22
|
+
|
|
23
|
+
## Mounting
|
|
24
|
+
|
|
25
|
+
In `config/routes.rb`:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
mount StandardHealth::Engine => "/health"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This wires up:
|
|
32
|
+
|
|
33
|
+
- `GET /health/alive`
|
|
34
|
+
- `GET /health/ready`
|
|
35
|
+
- `GET /health/diagnostics/env`
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
Create `config/initializers/standard_health.rb`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
StandardHealth.configure do |c|
|
|
43
|
+
# Controllers under StandardHealth inherit from this class. Use a host
|
|
44
|
+
# app controller to apply auth before_actions to every endpoint.
|
|
45
|
+
c.parent_controller = "ApplicationController"
|
|
46
|
+
|
|
47
|
+
# Register checks. The first argument is a short name surfaced in JSON;
|
|
48
|
+
# `critical: true` means a failure flips overall status to :unavailable.
|
|
49
|
+
c.register_check :database, StandardHealth::Checks::ActiveRecord, critical: true
|
|
50
|
+
c.register_check :solid_queue, StandardHealth::Checks::SolidQueue, critical: true
|
|
51
|
+
c.register_check :solid_cache, StandardHealth::Checks::SolidCache, critical: false
|
|
52
|
+
|
|
53
|
+
# Declare the env vars your app expects.
|
|
54
|
+
c.env_spec = StandardHealth::EnvSpec.define do
|
|
55
|
+
required :SECRET_KEY_BASE
|
|
56
|
+
required :APP_ENVIRONMENT, in: %w[staging production]
|
|
57
|
+
required :DATABASE_URL, in: %w[production]
|
|
58
|
+
recommended :SENTRY_DSN, description: "Error tracking DSN"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## EnvSpec
|
|
64
|
+
|
|
65
|
+
The DSL has two declarations:
|
|
66
|
+
|
|
67
|
+
- `required :NAME` — missing value reports `status: :missing`
|
|
68
|
+
- `recommended :NAME` — missing value reports `status: :should_set`
|
|
69
|
+
|
|
70
|
+
Both accept:
|
|
71
|
+
|
|
72
|
+
- `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise
|
|
73
|
+
- `description: "..."` — surfaced verbatim in the audit JSON
|
|
74
|
+
|
|
75
|
+
Audit output (one row per applicable entry):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"name": "SECRET_KEY_BASE",
|
|
80
|
+
"level": "required",
|
|
81
|
+
"status": "ok",
|
|
82
|
+
"mode": "production"
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Possible `status` values are `ok`, `missing` (required + absent), and `should_set` (recommended + absent).
|
|
87
|
+
|
|
88
|
+
## Custom checks
|
|
89
|
+
|
|
90
|
+
Inherit from `StandardHealth::Check` and implement `#run`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class RedisCheck < StandardHealth::Check
|
|
94
|
+
def run
|
|
95
|
+
with_timing { Redis.current.ping }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
StandardHealth.configure do |c|
|
|
100
|
+
c.register_check :redis, RedisCheck, critical: false
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`with_timing` captures `latency_ms` on success and converts any `StandardError` into `{ status: :fail, error: <message> }`.
|
|
105
|
+
|
|
106
|
+
## Auth
|
|
107
|
+
|
|
108
|
+
`/alive` and `/ready` are typically left open for orchestrator probes. `/diagnostics/env` enumerates which env vars are missing — that's potentially sensitive, so the host app is responsible for protecting it.
|
|
109
|
+
|
|
110
|
+
The recommended pattern is to point `parent_controller` at a host app controller that enforces auth:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# app/controllers/internal_health_controller.rb
|
|
114
|
+
class InternalHealthController < ActionController::API
|
|
115
|
+
http_basic_authenticate_with(
|
|
116
|
+
name: ENV.fetch("HEALTH_USER"),
|
|
117
|
+
password: ENV.fetch("HEALTH_PASS"),
|
|
118
|
+
only: :env # only protect diagnostics
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# config/initializers/standard_health.rb
|
|
123
|
+
StandardHealth.configure do |c|
|
|
124
|
+
c.parent_controller = "InternalHealthController"
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
For a more granular setup, mount the engine inside an authenticated route block in your host app's `routes.rb`.
|
|
129
|
+
|
|
130
|
+
## Status semantics
|
|
131
|
+
|
|
132
|
+
`/ready` returns:
|
|
133
|
+
|
|
134
|
+
| Overall status | HTTP code | Meaning |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `ok` | 200 | All checks passed |
|
|
137
|
+
| `degraded` | 200 | A non-critical check failed |
|
|
138
|
+
| `unavailable` | 503 | A critical check failed |
|
|
139
|
+
|
|
140
|
+
The orchestrator should pull the instance out of rotation only on 503; degraded means "still serving, page someone."
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
|
6
|
+
load "rails/tasks/engine.rake" if File.exist?(APP_RAKEFILE)
|
|
7
|
+
|
|
8
|
+
require "bundler/gem_tasks"
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require "rspec/core/rake_task"
|
|
12
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
13
|
+
task default: :spec
|
|
14
|
+
rescue LoadError
|
|
15
|
+
# rspec not available — skip
|
|
16
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Base controller for all StandardHealth endpoints.
|
|
5
|
+
#
|
|
6
|
+
# Lazily inherits from `StandardHealth.config.parent_controller` so host
|
|
7
|
+
# apps can wire their own auth/rate-limiting/before_actions in once and
|
|
8
|
+
# have them apply to /alive, /ready, and /diagnostics/env. The default
|
|
9
|
+
# is `ActionController::API` so the engine works in API-only host apps
|
|
10
|
+
# without configuration.
|
|
11
|
+
#
|
|
12
|
+
# Resolution happens at the moment this class is first referenced, which
|
|
13
|
+
# is request time — by then the host app's controllers are loaded and
|
|
14
|
+
# the constant resolves cleanly. Caching the resolved class on the
|
|
15
|
+
# singleton avoids re-running `constantize` per request.
|
|
16
|
+
def self.parent_controller
|
|
17
|
+
@parent_controller ||= config.parent_controller.constantize
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Allow tests/host apps to bust the cached parent controller (e.g. when
|
|
21
|
+
# toggling config between examples).
|
|
22
|
+
def self.reset_parent_controller!
|
|
23
|
+
@parent_controller = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ApplicationController < parent_controller
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Diagnostics endpoints. Output here is potentially sensitive (it
|
|
5
|
+
# enumerates which env vars are missing), so host apps are responsible
|
|
6
|
+
# for wrapping these routes with authentication via `parent_controller`
|
|
7
|
+
# — typically a basic-auth `before_action` on `ApplicationController`.
|
|
8
|
+
class DiagnosticsController < ApplicationController
|
|
9
|
+
# Audits the configured EnvSpec against the current process ENV and
|
|
10
|
+
# returns the result as JSON. When no EnvSpec is configured the
|
|
11
|
+
# endpoint returns an empty audit rather than a 404 so callers don't
|
|
12
|
+
# have to special-case "feature not enabled".
|
|
13
|
+
def env
|
|
14
|
+
spec = StandardHealth.config.env_spec
|
|
15
|
+
mode = ENV["APP_ENVIRONMENT"].to_s
|
|
16
|
+
|
|
17
|
+
audit = spec ? spec.audit(ENV.to_h, mode: mode) : []
|
|
18
|
+
|
|
19
|
+
render json: {
|
|
20
|
+
mode: mode,
|
|
21
|
+
audit: audit,
|
|
22
|
+
generated_at: Time.now.utc.iso8601
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
class HealthController < ApplicationController
|
|
5
|
+
# Liveness probe. Returns 200 unconditionally — its only job is to
|
|
6
|
+
# confirm the Rails process is up and routing requests. Anything
|
|
7
|
+
# heavier belongs in /ready.
|
|
8
|
+
def alive
|
|
9
|
+
head :ok
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Readiness probe. Runs every registered check and returns:
|
|
13
|
+
# 200 if the rolled-up status is :ok or :degraded
|
|
14
|
+
# 503 if any critical check failed (:unavailable)
|
|
15
|
+
def ready
|
|
16
|
+
result = StandardHealth::Aggregator.call
|
|
17
|
+
http_status = result[:status] == :unavailable ? :service_unavailable : :ok
|
|
18
|
+
render json: result, status: http_status
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Runs all registered checks and rolls them up into a single status.
|
|
5
|
+
#
|
|
6
|
+
# Status semantics:
|
|
7
|
+
# :ok — every check returned :ok
|
|
8
|
+
# :degraded — at least one non-critical check failed
|
|
9
|
+
# :unavailable — at least one critical check failed
|
|
10
|
+
#
|
|
11
|
+
# The aggregator never raises. Each check is invoked through
|
|
12
|
+
# `safe_run` which catches `StandardError` so a buggy custom check
|
|
13
|
+
# cannot take down /ready.
|
|
14
|
+
class Aggregator
|
|
15
|
+
def self.call(checks: StandardHealth.config.checks, now: Time.now.utc)
|
|
16
|
+
check_rows = checks.map { |reg| safe_run(reg) }
|
|
17
|
+
{
|
|
18
|
+
status: overall_status(check_rows),
|
|
19
|
+
checks: check_rows,
|
|
20
|
+
generated_at: now.iso8601
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.safe_run(reg)
|
|
25
|
+
result = reg.klass.new(name: reg.name, critical: reg.critical).run
|
|
26
|
+
result.merge(name: reg.name, critical: reg.critical)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
{
|
|
29
|
+
name: reg.name,
|
|
30
|
+
critical: reg.critical,
|
|
31
|
+
status: :fail,
|
|
32
|
+
error: e.message
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
private_class_method :safe_run
|
|
36
|
+
|
|
37
|
+
def self.overall_status(rows)
|
|
38
|
+
return :ok if rows.empty?
|
|
39
|
+
|
|
40
|
+
failures = rows.reject { |r| r[:status] == :ok }
|
|
41
|
+
return :ok if failures.empty?
|
|
42
|
+
return :unavailable if failures.any? { |r| r[:critical] }
|
|
43
|
+
|
|
44
|
+
:degraded
|
|
45
|
+
end
|
|
46
|
+
private_class_method :overall_status
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Base class for health checks.
|
|
5
|
+
#
|
|
6
|
+
# Subclasses implement `#run`, returning a hash like:
|
|
7
|
+
#
|
|
8
|
+
# { status: :ok, latency_ms: 3 }
|
|
9
|
+
#
|
|
10
|
+
# or
|
|
11
|
+
#
|
|
12
|
+
# { status: :fail, error: "connection refused" }
|
|
13
|
+
#
|
|
14
|
+
# The `with_timing` helper wraps a block, captures latency, and converts
|
|
15
|
+
# any unhandled `StandardError` into a `:fail` row so subclasses don't
|
|
16
|
+
# have to repeat the pattern.
|
|
17
|
+
class Check
|
|
18
|
+
attr_reader :name, :critical
|
|
19
|
+
|
|
20
|
+
def initialize(name:, critical: false)
|
|
21
|
+
@name = name
|
|
22
|
+
@critical = critical
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def critical?
|
|
26
|
+
!!@critical
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Subclasses override this. Default implementation reports an
|
|
30
|
+
# unimplemented check rather than raising, so a misconfigured custom
|
|
31
|
+
# check degrades gracefully instead of taking down /ready.
|
|
32
|
+
def run
|
|
33
|
+
{ status: :fail, error: "not implemented" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wrap a block: time it, return :ok with latency_ms on success, or
|
|
37
|
+
# :fail with the error message on any StandardError. Useful for
|
|
38
|
+
# check implementations that boil down to "run this query, swallow
|
|
39
|
+
# exceptions, report status".
|
|
40
|
+
def with_timing
|
|
41
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
yield
|
|
43
|
+
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
44
|
+
{ status: :ok, latency_ms: latency_ms }
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
{ status: :fail, error: e.message }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "standard_health/check"
|
|
4
|
+
|
|
5
|
+
module StandardHealth
|
|
6
|
+
module Checks
|
|
7
|
+
# Verifies the primary database connection by executing `SELECT 1`.
|
|
8
|
+
# Critical by default — if the database is unreachable, the host app
|
|
9
|
+
# cannot serve meaningful traffic.
|
|
10
|
+
class ActiveRecord < Check
|
|
11
|
+
def initialize(name: :active_record, critical: true)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
with_timing do
|
|
17
|
+
::ActiveRecord::Base.connection.execute("SELECT 1")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "standard_health/check"
|
|
4
|
+
|
|
5
|
+
module StandardHealth
|
|
6
|
+
module Checks
|
|
7
|
+
# Verifies the Rails cache is reachable via a read-only probe.
|
|
8
|
+
#
|
|
9
|
+
# We deliberately don't write — a flapping cache shouldn't corrupt host
|
|
10
|
+
# app cache state. If the read raises (connection refused, decoding
|
|
11
|
+
# error, etc.), the check fails. Non-critical by default: a degraded
|
|
12
|
+
# cache shouldn't pull the app out of rotation.
|
|
13
|
+
class SolidCache < Check
|
|
14
|
+
PROBE_KEY = "standard_health_probe"
|
|
15
|
+
|
|
16
|
+
def initialize(name: :solid_cache, critical: false)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
with_timing do
|
|
22
|
+
::Rails.cache.read(PROBE_KEY)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "standard_health/check"
|
|
4
|
+
|
|
5
|
+
module StandardHealth
|
|
6
|
+
module Checks
|
|
7
|
+
# Verifies the SolidQueue backing store is reachable.
|
|
8
|
+
#
|
|
9
|
+
# SolidQueue can be configured to live on a dedicated database connection
|
|
10
|
+
# (`config.solid_queue.connects_to`). When that's the case we run the
|
|
11
|
+
# probe against `SolidQueue::Record.connection`. Otherwise we fall back
|
|
12
|
+
# to the primary AR connection — which is exactly where the queue tables
|
|
13
|
+
# live in single-DB setups.
|
|
14
|
+
class SolidQueue < Check
|
|
15
|
+
def initialize(name: :solid_queue, critical: true)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
with_timing do
|
|
21
|
+
connection.execute("SELECT 1")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def connection
|
|
28
|
+
if defined?(::SolidQueue::Record) && ::SolidQueue::Record.respond_to?(:connection)
|
|
29
|
+
::SolidQueue::Record.connection
|
|
30
|
+
else
|
|
31
|
+
::ActiveRecord::Base.connection
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Holds engine-wide configuration.
|
|
5
|
+
#
|
|
6
|
+
# Host apps configure the engine via:
|
|
7
|
+
#
|
|
8
|
+
# StandardHealth.configure do |c|
|
|
9
|
+
# c.parent_controller = "ApplicationController"
|
|
10
|
+
# c.register_check :custom, MyCheck, critical: true
|
|
11
|
+
# c.env_spec = StandardHealth::EnvSpec.define { ... }
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# A registered health check entry.
|
|
15
|
+
Registration = Struct.new(:name, :klass, :critical, keyword_init: true) do
|
|
16
|
+
def critical?
|
|
17
|
+
!!critical
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Class name of the controller that StandardHealth's controllers should
|
|
22
|
+
# inherit from. Resolved lazily via `constantize` at request time so the
|
|
23
|
+
# host app's controller (which may pull in auth concerns) is fully
|
|
24
|
+
# loaded before we touch it. Defaults to `ActionController::API` so the
|
|
25
|
+
# engine works in API-only host apps without configuration.
|
|
26
|
+
attr_accessor :parent_controller
|
|
27
|
+
|
|
28
|
+
# An optional `StandardHealth::EnvSpec` instance describing required and
|
|
29
|
+
# recommended environment variables for the host app. Audited via the
|
|
30
|
+
# /diagnostics/env endpoint.
|
|
31
|
+
attr_accessor :env_spec
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@parent_controller = "ActionController::API"
|
|
35
|
+
@env_spec = nil
|
|
36
|
+
@checks = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Register a health check class.
|
|
40
|
+
#
|
|
41
|
+
# @param name [Symbol] short identifier surfaced in /ready output
|
|
42
|
+
# @param klass [Class] subclass of StandardHealth::Check
|
|
43
|
+
# @param critical [Boolean] failure flips overall status to :unavailable
|
|
44
|
+
def register_check(name, klass, critical: false)
|
|
45
|
+
@checks << Registration.new(name: name.to_sym, klass: klass, critical: critical)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Array<Registration>] frozen view of registered checks
|
|
49
|
+
def checks
|
|
50
|
+
@checks.dup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Remove all registered checks. Mainly useful in tests where the host
|
|
54
|
+
# app and the engine share a process.
|
|
55
|
+
def reset_checks!
|
|
56
|
+
@checks = []
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# DSL for declaring required and recommended environment variables.
|
|
5
|
+
#
|
|
6
|
+
# Example:
|
|
7
|
+
#
|
|
8
|
+
# StandardHealth::EnvSpec.define do
|
|
9
|
+
# required :SECRET_KEY_BASE
|
|
10
|
+
# required :APP_ENVIRONMENT, in: %w[staging production]
|
|
11
|
+
# recommended :SENTRY_DSN, description: "Error tracking DSN"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Each entry has:
|
|
15
|
+
# - `name` (Symbol)
|
|
16
|
+
# - `level` (:required | :recommended)
|
|
17
|
+
# - `modes` (Array<String>, optional) — when set, the entry only applies
|
|
18
|
+
# while `APP_ENVIRONMENT` matches one of these modes. Otherwise it
|
|
19
|
+
# applies to every mode.
|
|
20
|
+
# - `description` (String, optional) — human-readable hint surfaced
|
|
21
|
+
# verbatim by `/diagnostics/env`.
|
|
22
|
+
class EnvSpec
|
|
23
|
+
Entry = Struct.new(:name, :level, :modes, :description, keyword_init: true) do
|
|
24
|
+
# Whether this entry's audit applies under the given runtime mode.
|
|
25
|
+
# Entries without a `modes:` constraint always apply.
|
|
26
|
+
def applies_to?(mode)
|
|
27
|
+
return true if modes.nil? || modes.empty?
|
|
28
|
+
modes.include?(mode.to_s)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<Entry>]
|
|
33
|
+
attr_reader :entries
|
|
34
|
+
|
|
35
|
+
# Build a spec via the DSL.
|
|
36
|
+
def self.define(&block)
|
|
37
|
+
new.tap { |spec| spec.instance_eval(&block) if block }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@entries = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Declare a required env var.
|
|
45
|
+
#
|
|
46
|
+
# @param name [Symbol, String]
|
|
47
|
+
# @param in [Array<String>, nil] limit applicability to these modes
|
|
48
|
+
# @param description [String, nil]
|
|
49
|
+
def required(name, **opts)
|
|
50
|
+
add(:required, name, **opts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Declare a recommended env var. A missing value never fails the audit;
|
|
54
|
+
# it surfaces as `:should_set`.
|
|
55
|
+
def recommended(name, **opts)
|
|
56
|
+
add(:recommended, name, **opts)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Run the audit against an env-like hash.
|
|
60
|
+
#
|
|
61
|
+
# @param env_hash [Hash{String, Symbol => String}] e.g. ENV.to_h
|
|
62
|
+
# @param mode [String, Symbol] current APP_ENVIRONMENT value
|
|
63
|
+
# @return [Array<Hash>] one row per applicable entry, each shaped like:
|
|
64
|
+
# { name:, level:, status: :ok | :missing | :should_set, mode: }
|
|
65
|
+
def audit(env_hash, mode:)
|
|
66
|
+
mode_str = mode.to_s
|
|
67
|
+
env = stringify(env_hash)
|
|
68
|
+
|
|
69
|
+
@entries.each_with_object([]) do |entry, out|
|
|
70
|
+
next unless entry.applies_to?(mode_str)
|
|
71
|
+
|
|
72
|
+
value = env[entry.name.to_s]
|
|
73
|
+
status = classify(entry, value)
|
|
74
|
+
|
|
75
|
+
row = {
|
|
76
|
+
name: entry.name,
|
|
77
|
+
level: entry.level,
|
|
78
|
+
status: status,
|
|
79
|
+
mode: mode_str
|
|
80
|
+
}
|
|
81
|
+
row[:description] = entry.description if entry.description
|
|
82
|
+
out << row
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def add(level, name, in: nil, description: nil)
|
|
89
|
+
modes = binding.local_variable_get(:in)
|
|
90
|
+
@entries << Entry.new(
|
|
91
|
+
name: name.to_sym,
|
|
92
|
+
level: level,
|
|
93
|
+
modes: modes ? Array(modes).map(&:to_s) : nil,
|
|
94
|
+
description: description
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def classify(entry, value)
|
|
99
|
+
present = !value.nil? && !value.to_s.empty?
|
|
100
|
+
return :ok if present
|
|
101
|
+
|
|
102
|
+
entry.level == :required ? :missing : :should_set
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stringify(env_hash)
|
|
106
|
+
env_hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "standard_health/version"
|
|
4
|
+
require "standard_health/engine"
|
|
5
|
+
require "standard_health/configuration"
|
|
6
|
+
require "standard_health/env_spec"
|
|
7
|
+
require "standard_health/check"
|
|
8
|
+
require "standard_health/checks/active_record"
|
|
9
|
+
require "standard_health/checks/solid_queue"
|
|
10
|
+
require "standard_health/checks/solid_cache"
|
|
11
|
+
require "standard_health/aggregator"
|
|
12
|
+
|
|
13
|
+
module StandardHealth
|
|
14
|
+
class << self
|
|
15
|
+
# Yields the configuration to a block.
|
|
16
|
+
#
|
|
17
|
+
# StandardHealth.configure do |c|
|
|
18
|
+
# c.parent_controller = "ApplicationController"
|
|
19
|
+
# c.register_check :db, StandardHealth::Checks::ActiveRecord, critical: true
|
|
20
|
+
# end
|
|
21
|
+
def configure
|
|
22
|
+
yield config if block_given?
|
|
23
|
+
config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def config
|
|
27
|
+
@config ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Mostly useful in tests — wipes the singleton config so each example
|
|
31
|
+
# gets a clean slate.
|
|
32
|
+
def reset_config!
|
|
33
|
+
@config = Configuration.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: standard_health
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jaryl Sim
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
description: StandardHealth is a mountable Rails engine providing /alive, /ready,
|
|
27
|
+
and /diagnostics/env endpoints, with a configuration block for registering custom
|
|
28
|
+
checks and a DSL for declaring required and recommended environment variables.
|
|
29
|
+
email:
|
|
30
|
+
- code@jaryl.dev
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- CHANGELOG.md
|
|
36
|
+
- MIT-LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- Rakefile
|
|
39
|
+
- app/controllers/standard_health/application_controller.rb
|
|
40
|
+
- app/controllers/standard_health/diagnostics_controller.rb
|
|
41
|
+
- app/controllers/standard_health/health_controller.rb
|
|
42
|
+
- config/routes.rb
|
|
43
|
+
- lib/standard_health.rb
|
|
44
|
+
- lib/standard_health/aggregator.rb
|
|
45
|
+
- lib/standard_health/check.rb
|
|
46
|
+
- lib/standard_health/checks/active_record.rb
|
|
47
|
+
- lib/standard_health/checks/solid_cache.rb
|
|
48
|
+
- lib/standard_health/checks/solid_queue.rb
|
|
49
|
+
- lib/standard_health/configuration.rb
|
|
50
|
+
- lib/standard_health/engine.rb
|
|
51
|
+
- lib/standard_health/env_spec.rb
|
|
52
|
+
- lib/standard_health/version.rb
|
|
53
|
+
homepage: https://github.com/rarebit-one/standard_health
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
homepage_uri: https://github.com/rarebit-one/standard_health
|
|
58
|
+
source_code_uri: https://github.com/rarebit-one/standard_health
|
|
59
|
+
changelog_uri: https://github.com/rarebit-one/standard_health/blob/main/CHANGELOG.md
|
|
60
|
+
bug_tracker_uri: https://github.com/rarebit-one/standard_health/issues
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.4'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.6.7
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: A drop-in health check and environment-spec engine for Rails 8 host apps.
|
|
78
|
+
test_files: []
|