standard_health 0.5.1 → 0.6.1
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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +301 -14
- data/app/controllers/concerns/standard_health/diagnostics_authentication.rb +64 -0
- data/app/controllers/standard_health/diagnostics_controller.rb +5 -0
- data/app/controllers/standard_health/health_controller.rb +12 -0
- data/config/routes.rb +13 -2
- data/lib/generators/standard_health/install/templates/initializer.rb.erb +13 -0
- data/lib/standard_health/aggregate_report.rb +116 -0
- data/lib/standard_health/aggregator.rb +76 -22
- data/lib/standard_health/checks/env_spec_audit.rb +4 -8
- data/lib/standard_health/configuration.rb +236 -4
- data/lib/standard_health/diagnostics_basic_auth.rb +94 -0
- data/lib/standard_health/notifiers/logger.rb +19 -0
- data/lib/standard_health/notifiers/metrics.rb +31 -9
- data/lib/standard_health/probe_paths.rb +78 -0
- data/lib/standard_health/subscribers.rb +3 -1
- data/lib/standard_health/version.rb +1 -1
- data/lib/standard_health.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c32e1366e2b116f86a8be7780d4717a35b07952402f14e213a2533530c30fe8
|
|
4
|
+
data.tar.gz: 7c0fe9a0993be6e1ef46adcd95f3ba6c05dc029e7f621557ea314eb0a7062f0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad3e4a667c77a923dfa0ab49aa1de21f86923e7a1e30e7f7204c9ebb4c471f284f61c4f1d0a768155345da3fd46f10b7b77216d6feeeb0c9b799bc7f29bc02f7
|
|
7
|
+
data.tar.gz: '00853d5d4423f12269bf47f67692bcea3c43bc7da5ac9b64ff39fb9f080364131bea90156cc3fd41387d37421cef886695965c66d28fa024c9e3de418b07458b'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.1] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A check that raises on the aggregate tier is reported to `Rails.error` again.** Before 0.6.0 every host's aggregate controller rescued a raising check and called `Rails.error.report(e, handled: true, ...)`. When the engine took over the tier, the exception was only logged by the Logger notifier, so a buggy aggregate check stopped reaching Sentry (noted in the jumpdrive-web and sidekick-web adoptions). The Aggregator now reports it as handled with context `{ health_check: <name>, tier: "aggregate" }`. Severity follows the readiness Sentry notifier: `:error` for a critical check, `:warning` otherwise. The readiness tier is unchanged. Its failures still reach Sentry through the transition-gated `ready.evaluated` notifier, which keeps a ~6/min probe from flooding the tracker. Checks that return a `:fail` row (every built-in check) are not reported. The report is rescued, so a broken error subscriber cannot 500 the tier.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- The aggregate tier re-runs the `register_check` readiness checks by default (`aggregate_readiness_checks = true`), so a failing critical check (database, Solid Queue) makes `/health` answer **503 `unavailable`**, not only `/ready`.
|
|
19
|
+
- The aggregate body reports `"unavailable"`, never StandardCircuit's `"critical"`. Monitors matching on `"critical"` need updating (as noted in 0.6.0's migration notes).
|
|
20
|
+
- `StandardHealth::DiagnosticsAuthentication` is the request-time gate behind `diagnostics_basic_auth`. The README now notes that a host can include it into its own diagnostics controller, as sidekick-web does. It is not yet a documented, semver-stable extension point.
|
|
21
|
+
|
|
22
|
+
## [0.6.0] - 2026-09-24
|
|
23
|
+
|
|
24
|
+
DX release: absorbs the code every consumer app copy-pasted around the engine.
|
|
25
|
+
Everything is **additive and opt-in** — no default changes, no existing route
|
|
26
|
+
returns anything different, and readiness event payloads are byte-identical —
|
|
27
|
+
so this is a pure `bundle update` for consumers on `~> 0.5`. Each feature's
|
|
28
|
+
README section has a "replace your host code with" snippet.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `config.diagnostics_basic_auth` — built-in HTTP Basic gate for
|
|
33
|
+
`/diagnostics/env`. `true` uses `ADMIN_BASIC_AUTH_USERNAME` /
|
|
34
|
+
`ADMIN_BASIC_AUTH_PASSWORD`; a Hash takes `username:` / `password:`
|
|
35
|
+
(String or callable, resolved per request), `realm:` (default
|
|
36
|
+
"Health Diagnostics") and `allow_unconfigured:` (default false). **Fails
|
|
37
|
+
closed** with a 403 when credentials are blank or their lookup raises;
|
|
38
|
+
secure_compare over digests on both halves. Replaces each app's
|
|
39
|
+
`StandardHealthHostController` + `diagnostics_parent_controller` wiring.
|
|
40
|
+
- `Configuration#register_default_checks` — registers `:database`
|
|
41
|
+
(ActiveRecord, critical), `:solid_queue` (critical), `:solid_cache` and
|
|
42
|
+
`:audit_retention` (non-critical), skipping any whose backing library isn't
|
|
43
|
+
loaded or whose name is already registered. Per-check `false` or override
|
|
44
|
+
Hash (`name:`, `critical:`, `timeout:`, constructor options).
|
|
45
|
+
- `StandardHealth::PROBE_PATHS`, `StandardHealth.probe_path?(path, mount:,
|
|
46
|
+
up:, aggregate:, extra:)` and `probe_path_pattern` — the orchestrator probe
|
|
47
|
+
regex (`/up`, `/health`, `/health/alive`, `/health/ready`), built from
|
|
48
|
+
`StandardHealth::PROBE_ACTIONS`, which the engine's routes are now drawn
|
|
49
|
+
from. Excludes the doctor tier.
|
|
50
|
+
- `config.metrics_enabled` (drop only the Metrics notifier) and
|
|
51
|
+
`config.metric_events` (allow-list of event names, validated at boot);
|
|
52
|
+
`Notifiers::Metrics::EVENTS` / `PER_POLL_EVENTS`. Replaces sidekick-web's
|
|
53
|
+
`StandardHealthPollMetricSuppression` prepend.
|
|
54
|
+
- `register_check` forwards extra keywords to the check constructor (e.g.
|
|
55
|
+
`EnvSpecAudit`'s `fail_on:`), validated against its signature at
|
|
56
|
+
registration. `klass` may also be a String, resolved when the check runs.
|
|
57
|
+
- Opt-in aggregate tier: `config.aggregate_endpoint = true` serves
|
|
58
|
+
`{ status, checks, circuits, generated_at }` at the engine root (`GET
|
|
59
|
+
/health`), folding in `StandardCircuit.health_report` when loaded
|
|
60
|
+
(`aggregate_circuits`), the readiness checks (`aggregate_readiness_checks`)
|
|
61
|
+
and aggregate-only checks (`register_aggregate_check`). 503 only on
|
|
62
|
+
`unavailable` (a critical check failure or a `:critical` circuit red);
|
|
63
|
+
redacted like `/ready`. The route is constrained per request, so with the
|
|
64
|
+
flag off a bare `/health` still cascades to the host. Emits
|
|
65
|
+
`standard_health.aggregate.evaluated` (logged; ignored by Sentry and
|
|
66
|
+
Metrics) and tags aggregate-run check events with `tier: :aggregate`.
|
|
67
|
+
- Specs for `Checks::SolidQueue`, `Checks::SolidCache`, `Notifiers::Metrics`,
|
|
68
|
+
`Subscribers` and `EventEmitter`.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- `Checks::EnvSpecAudit` docs: narrow with `fail_on:` at registration instead
|
|
73
|
+
of subclassing (subclassing still works).
|
|
74
|
+
|
|
10
75
|
## [0.5.1] - 2026-09-24
|
|
11
76
|
|
|
12
77
|
CI and tooling only — no runtime code changes, so this is a pure
|
data/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Mount it once and you get:
|
|
|
7
7
|
- `GET /health/alive` — liveness probe (always 200 if Rails is up)
|
|
8
8
|
- `GET /health/ready` — readiness probe; runs every registered check and rolls them up into an overall status
|
|
9
9
|
- `GET /health/diagnostics/env` — audits the host app's `ENV` against a declarative spec
|
|
10
|
+
- `GET /health` — *opt-in* aggregate tier: checks plus StandardCircuit state (see [below](#let-the-engine-serve-the-aggregate-tier-060))
|
|
10
11
|
|
|
11
12
|
Built-in checks cover ActiveRecord, SolidQueue, and SolidCache. Host apps can register additional checks via the configuration block.
|
|
12
13
|
|
|
@@ -45,9 +46,9 @@ This wires up:
|
|
|
45
46
|
|
|
46
47
|
### The aggregate `GET /health` is yours to draw — before the mount
|
|
47
48
|
|
|
48
|
-
**
|
|
49
|
-
|
|
50
|
-
the ordering is not optional:
|
|
49
|
+
**Unless you opt into [`aggregate_endpoint`](#let-the-engine-serve-the-aggregate-tier-060)
|
|
50
|
+
(0.6.0), the engine serves no bare `GET /health`.** The aggregate tier is then
|
|
51
|
+
the host's responsibility, and the ordering is not optional:
|
|
51
52
|
|
|
52
53
|
```ruby
|
|
53
54
|
get "/health", to: "health_aggregate#show" # aggregate — FIRST
|
|
@@ -79,6 +80,90 @@ Readiness gates **only** on hard infra the app owns. A soft upstream that
|
|
|
79
80
|
degrades must not pull an instance out of rotation — put those on the aggregate
|
|
80
81
|
tier instead.
|
|
81
82
|
|
|
83
|
+
### Let the engine serve the aggregate tier (0.6.0)
|
|
84
|
+
|
|
85
|
+
Instead of drawing the aggregate route yourself, opt in and the engine serves
|
|
86
|
+
it at its root:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
StandardHealth.configure do |c|
|
|
90
|
+
c.aggregate_endpoint = true
|
|
91
|
+
# c.aggregate_readiness_checks = true # default: re-run the register_check registry
|
|
92
|
+
# c.aggregate_circuits = true # default: fold StandardCircuit.health_report in, when loaded
|
|
93
|
+
c.register_aggregate_check :solid_cable, StandardHealth::Checks::SolidCable # aggregate-ONLY
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{ "status": "degraded",
|
|
99
|
+
"checks": [{ "name": "database", "critical": true, "status": "ok", "latency_ms": 2 },
|
|
100
|
+
{ "name": "solid_cable", "critical": false, "status": "fail",
|
|
101
|
+
"error_class": "PG::ConnectionBad", "error_code": "pg_connection_bad" }],
|
|
102
|
+
"circuits": [{ "name": "google_oauth", "color": "green", "locked": false, "criticality": "critical" }],
|
|
103
|
+
"generated_at": "2026-09-24T00:00:00Z" }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Status | HTTP | When |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `unavailable` | 503 | a critical check failed, or a `:critical` circuit is red |
|
|
109
|
+
| `degraded` | 200 | a non-critical check failed or was skipped, or the circuit roll-up is `:degraded` |
|
|
110
|
+
| `ok` | 200 | otherwise |
|
|
111
|
+
|
|
112
|
+
**The aggregate tier re-runs your readiness checks by default.** With
|
|
113
|
+
`aggregate_readiness_checks` on (the default), every `register_check`
|
|
114
|
+
registration runs here as well as on `/ready`. A failing critical check, such
|
|
115
|
+
as `:database` or `:solid_queue` from `register_default_checks`, therefore turns
|
|
116
|
+
`/health` into a **503 `unavailable`**, not just `/ready`. Point an uptime
|
|
117
|
+
monitor at `/health` and a database outage pages as a 503. If your aggregate
|
|
118
|
+
tier should only report soft signals (sidekick), set
|
|
119
|
+
`aggregate_readiness_checks = false`.
|
|
120
|
+
|
|
121
|
+
StandardCircuit's own word `critical` is translated to `unavailable`, so both
|
|
122
|
+
tiers speak one vocabulary. The aggregate body never says `"critical"`. `circuits` is omitted when StandardCircuit isn't
|
|
123
|
+
loaded (or `aggregate_circuits = false`); if `health_report` raises (circuit
|
|
124
|
+
store down) the tier degrades and reports `circuits_error: { error_class,
|
|
125
|
+
error_code }` instead of 500ing. Check rows are redacted exactly like `/ready`,
|
|
126
|
+
with the same `detail_token` break-glass. `register_aggregate_check` checks
|
|
127
|
+
run **only** here — never on `/ready` — and default to `critical: false`.
|
|
128
|
+
|
|
129
|
+
A check that **raises** on this tier, rather than returning a `:fail` row, is
|
|
130
|
+
also reported to `Rails.error` as handled (since 0.6.1), with context
|
|
131
|
+
`{ health_check:, tier: "aggregate" }` and severity `:error` for a critical
|
|
132
|
+
check or `:warning` otherwise. That matches what the pre-0.6 host controllers
|
|
133
|
+
did. `/ready` does not do this: its failures reach Sentry through the
|
|
134
|
+
transition-gated `ready.evaluated` notifier.
|
|
135
|
+
|
|
136
|
+
With `aggregate_endpoint` off (the default) the engine's root route carries a
|
|
137
|
+
per-request constraint that never matches, so a bare `/health` cascades to your
|
|
138
|
+
own route exactly as before. Once on, the engine answers `/health` regardless
|
|
139
|
+
of where your own route is drawn relative to the mount.
|
|
140
|
+
|
|
141
|
+
**Migration** — replace your host code with the flag:
|
|
142
|
+
|
|
143
|
+
- **fundbright / nutripod / jumpdrive** (`get "/health", to: "standard_circuit/health#show"`):
|
|
144
|
+
set `c.aggregate_endpoint = true`, delete that route (and the
|
|
145
|
+
`require "standard_circuit/health_controller"` if nothing else uses it —
|
|
146
|
+
fundbright's `/circuits` alias still does). The body gains `checks[]`, and
|
|
147
|
+
a red critical circuit reports `"unavailable"` rather than `"critical"`
|
|
148
|
+
(still 503). Update any dashboard or monitor matching on `"critical"`.
|
|
149
|
+
- **luminality** (`HealthAggregateController` merging `Aggregator` + circuits):
|
|
150
|
+
set the flag, delete the route and the controller. Same envelope and status
|
|
151
|
+
words — it is this controller.
|
|
152
|
+
- **sidekick** (`HealthAggregateController` with soft checks only):
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
c.aggregate_endpoint = true
|
|
156
|
+
c.aggregate_readiness_checks = false # sidekick's aggregate never re-ran readiness
|
|
157
|
+
c.register_aggregate_check :solid_cable, StandardHealth::Checks::SolidCable
|
|
158
|
+
c.register_aggregate_check :attestation_roots, "AttestationRootsCheck" # String: resolved per request
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
then delete the route and the controller. Status words move from
|
|
162
|
+
`critical` to `unavailable`, as above.
|
|
163
|
+
|
|
164
|
+
Aggregate evaluations emit `standard_health.aggregate.evaluated`, **not**
|
|
165
|
+
`ready.evaluated` — see [Instrumentation](#instrumentation).
|
|
166
|
+
|
|
82
167
|
## Configuration
|
|
83
168
|
|
|
84
169
|
Create `config/initializers/standard_health.rb` (or let the generator write it):
|
|
@@ -105,6 +190,40 @@ StandardHealth.configure do |c|
|
|
|
105
190
|
end
|
|
106
191
|
```
|
|
107
192
|
|
|
193
|
+
### `register_default_checks` (0.6.0)
|
|
194
|
+
|
|
195
|
+
Every consumer registers the same set by hand. One call does it:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
# replaces:
|
|
199
|
+
# c.register_check :database, StandardHealth::Checks::ActiveRecord, critical: true
|
|
200
|
+
# c.register_check :solid_queue, StandardHealth::Checks::SolidQueue, critical: true
|
|
201
|
+
# c.register_check :solid_cache, StandardHealth::Checks::SolidCache, critical: false
|
|
202
|
+
# c.register_check :audit_retention, StandardAudit::Checks::Retention, critical: false
|
|
203
|
+
c.register_default_checks
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
| Key | Registered as | Class | `critical` | Skipped unless |
|
|
207
|
+
|---|---|---|---|---|
|
|
208
|
+
| `database` | `:database` | `Checks::ActiveRecord` | `true` | `ActiveRecord::Base` is loaded |
|
|
209
|
+
| `solid_queue` | `:solid_queue` | `Checks::SolidQueue` | `true` | `SolidQueue` is loaded |
|
|
210
|
+
| `solid_cache` | `:solid_cache` | `Checks::SolidCache` | `false` | `SolidCache` is loaded |
|
|
211
|
+
| `audit_retention` | `:audit_retention` | `StandardAudit::Checks::Retention` | `false` | `standard_audit` is loaded |
|
|
212
|
+
|
|
213
|
+
A check whose backing library isn't loaded is skipped rather than registered
|
|
214
|
+
to fail forever. A name that's already registered is skipped too, so it's safe
|
|
215
|
+
to mix with hand-registered checks (in either order) and to call twice. Each
|
|
216
|
+
key takes `true` (defaults), `false` (skip), or a Hash of overrides —
|
|
217
|
+
`name:`, `critical:`, `timeout:`, plus any [constructor options](#custom-checks):
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
c.register_default_checks solid_cache: false, # jumpdrive: has SolidCache, doesn't check it
|
|
221
|
+
solid_queue: { timeout: 2 }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Returns the names it registered. Check order in the response follows
|
|
225
|
+
registration order.
|
|
226
|
+
|
|
108
227
|
## EnvSpec
|
|
109
228
|
|
|
110
229
|
The DSL has three declarations:
|
|
@@ -350,6 +469,32 @@ end
|
|
|
350
469
|
|
|
351
470
|
`with_timing` captures `latency_ms` on success and converts any `StandardError` into `{ status: :fail, error: <message> }`.
|
|
352
471
|
|
|
472
|
+
**Per-registration options (0.6.0).** Keywords other than `critical:` and
|
|
473
|
+
`timeout:` are forwarded to the check's constructor, and validated against its
|
|
474
|
+
signature at registration — a typo fails at boot, not on every probe:
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
class QueueDepthCheck < StandardHealth::Check
|
|
478
|
+
def initialize(name:, critical: false, max_depth: 1_000)
|
|
479
|
+
super(name: name, critical: critical)
|
|
480
|
+
@max_depth = max_depth
|
|
481
|
+
end
|
|
482
|
+
# ...
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
c.register_check :queue_depth, QueueDepthCheck, max_depth: 5_000
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**String class names.** `klass` may be a String, resolved each time the check
|
|
489
|
+
runs. That lets an initializer register an autoloaded app constant (which isn't
|
|
490
|
+
resolvable yet at initializer time) without a `to_prepare` block, and picks up
|
|
491
|
+
class reloads in development. An unresolvable name reports a failing row
|
|
492
|
+
(`error_class: "NameError"`) rather than raising.
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
c.register_check :runner, "RunnerHealthCheck"
|
|
496
|
+
```
|
|
497
|
+
|
|
353
498
|
**A check must never raise.** `Aggregator` rescues `StandardError` per check, so a buggy check degrades to `:fail` rather than 500ing the endpoint — but don't rely on that as the only line of defence. Route fallible work through `with_timing`.
|
|
354
499
|
|
|
355
500
|
## Opt-in checks
|
|
@@ -407,15 +552,12 @@ still serving traffic correctly, and de-rotating it converts a warning into an
|
|
|
407
552
|
outage. Registering it `critical: true` asserts "this app must not serve at all
|
|
408
553
|
with a bad env" — a real but rare posture. Know which you want.
|
|
409
554
|
|
|
410
|
-
|
|
411
|
-
|
|
555
|
+
To narrow what counts as a failure, pass `fail_on:` at registration (0.6.0+
|
|
556
|
+
forwards it to the constructor — no subclass needed):
|
|
412
557
|
|
|
413
558
|
```ruby
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
super(name: name, critical: critical, fail_on: %i[forbidden])
|
|
417
|
-
end
|
|
418
|
-
end
|
|
559
|
+
c.register_check :forbidden_toggles, StandardHealth::Checks::EnvSpecAudit,
|
|
560
|
+
fail_on: %i[forbidden]
|
|
419
561
|
```
|
|
420
562
|
|
|
421
563
|
Note the failure message is subject to [redaction](#failure-detail-is-redacted)
|
|
@@ -427,9 +569,67 @@ Sentry. The row carries a stable `error_class` of
|
|
|
427
569
|
|
|
428
570
|
## Auth
|
|
429
571
|
|
|
430
|
-
`/alive` and `/ready` are typically left open for orchestrator probes. `/diagnostics/env` enumerates which env vars are missing — that's potentially sensitive, so
|
|
572
|
+
`/alive` and `/ready` are typically left open for orchestrator probes. `/diagnostics/env` enumerates which env vars are missing — that's potentially sensitive, so it must be protected.
|
|
573
|
+
|
|
574
|
+
### Built-in basic auth for diagnostics (0.6.0)
|
|
575
|
+
|
|
576
|
+
The simplest option is to let the engine gate it:
|
|
431
577
|
|
|
432
|
-
|
|
578
|
+
```ruby
|
|
579
|
+
StandardHealth.configure do |c|
|
|
580
|
+
c.diagnostics_basic_auth = true # ADMIN_BASIC_AUTH_USERNAME / ADMIN_BASIC_AUTH_PASSWORD
|
|
581
|
+
end
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
or, with your own credential source (Strings or callables, resolved **per
|
|
585
|
+
request**, so rotation needs no restart):
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
c.diagnostics_basic_auth = {
|
|
589
|
+
username: -> { Current.config.admin_basic_auth_username },
|
|
590
|
+
password: -> { Current.config.admin_basic_auth_password },
|
|
591
|
+
realm: "Health Diagnostics", # default
|
|
592
|
+
allow_unconfigured: -> { Rails.env.local? } # default: false
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
- Only `/diagnostics/env` is gated; `/alive`, `/ready` and the aggregate tier stay anonymous.
|
|
597
|
+
- **Fails closed.** If either credential resolves blank (or the lookup raises),
|
|
598
|
+
the endpoint answers **403** `{"error":"diagnostics refused", ...}` rather
|
|
599
|
+
than serving env state. 403, not 503: DigitalOcean App Platform's edge
|
|
600
|
+
replaces an app 5xx with its own error page, which reads as "app down". Not
|
|
601
|
+
401: there are no credentials that could succeed. `allow_unconfigured`
|
|
602
|
+
(boolean or callable) opts a credential-less environment — typically local
|
|
603
|
+
dev — into passing through.
|
|
604
|
+
- Both halves are compared with `secure_compare` over SHA-256 digests, so a
|
|
605
|
+
wrong username still costs a password comparison and differing lengths leak
|
|
606
|
+
nothing.
|
|
607
|
+
- Off by default; independent of `diagnostics_parent_controller` (if you set
|
|
608
|
+
both, the parent's callbacks run first).
|
|
609
|
+
- The request-time gate is the `StandardHealth::DiagnosticsAuthentication`
|
|
610
|
+
concern, which the engine includes into its own `DiagnosticsController`.
|
|
611
|
+
sidekick-web also includes it into its own `HealthDiagnosticsController`
|
|
612
|
+
(an `ActionController::API` subclass) so that controller shares the same
|
|
613
|
+
gate. That works: the concern is a no-op until `diagnostics_basic_auth` is
|
|
614
|
+
set, and it adds one `before_action`. It is not yet a documented,
|
|
615
|
+
semver-stable extension point, so pin your minor version if you depend on it.
|
|
616
|
+
|
|
617
|
+
**Replace your host code with it.** Delete
|
|
618
|
+
`app/controllers/standard_health_host_controller.rb` and the
|
|
619
|
+
`c.diagnostics_parent_controller = "StandardHealthHostController"` line, then:
|
|
620
|
+
|
|
621
|
+
| App | Was | Set |
|
|
622
|
+
|---|---|---|
|
|
623
|
+
| jumpdrive | fail-closed 403 on unset `ADMIN_BASIC_AUTH_*` | `c.diagnostics_basic_auth = true` |
|
|
624
|
+
| sidekick | 503 when unset in staging/preview/production, open locally | `c.diagnostics_basic_auth = { allow_unconfigured: -> { !%w[staging preview production].include?(ENV["APP_ENVIRONMENT"]) } }` |
|
|
625
|
+
| fundbright, luminality | open when unset (boot-enforced in deployed envs) | `c.diagnostics_basic_auth = { allow_unconfigured: -> { Rails.env.local? } }` |
|
|
626
|
+
| nutripod | `Current.config.admin_basic_auth_*` | `c.diagnostics_basic_auth = { username: -> { Current.config.admin_basic_auth_username }, password: -> { Current.config.admin_basic_auth_password }, allow_unconfigured: -> { Rails.env.local? } }` |
|
|
627
|
+
|
|
628
|
+
(Sidekick moves from 503 to 403 on a missing gate — see above for why.)
|
|
629
|
+
|
|
630
|
+
### Bring your own parent controller
|
|
631
|
+
|
|
632
|
+
The pre-0.6.0 pattern is to point `parent_controller` at a host app controller that enforces auth:
|
|
433
633
|
|
|
434
634
|
```ruby
|
|
435
635
|
# app/controllers/internal_health_controller.rb
|
|
@@ -490,6 +690,53 @@ Now `/health/alive` and `/health/ready` are unauthenticated (probe-friendly) whi
|
|
|
490
690
|
|
|
491
691
|
When `diagnostics_parent_controller` is unset, `DiagnosticsController` falls back to `parent_controller`, matching v0.1.0 behavior exactly.
|
|
492
692
|
|
|
693
|
+
## Probe paths (0.6.0)
|
|
694
|
+
|
|
695
|
+
Hosts copy the probe regex into `production.rb` and their Sentry samplers. The
|
|
696
|
+
gem now owns it, built from the same `PROBE_ACTIONS` list its routes are drawn
|
|
697
|
+
from, so it cannot drift:
|
|
698
|
+
|
|
699
|
+
```ruby
|
|
700
|
+
StandardHealth::PROBE_PATHS
|
|
701
|
+
# => matches /up, /health, /health/alive, /health/ready (optional trailing /)
|
|
702
|
+
# never /health/diagnostics/env, never /healthy-habits
|
|
703
|
+
StandardHealth.probe_path?(path) # predicate, same default
|
|
704
|
+
StandardHealth.probe_path?(path, mount: "/_status") # engine mounted elsewhere
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Anchored at both ends. The doctor tier is deliberately excluded — it's authed,
|
|
708
|
+
hit by on-call rather than on a timer, and belongs in logs and APM.
|
|
709
|
+
|
|
710
|
+
**If you mount the engine anywhere but `/health`, `PROBE_PATHS` is wrong for
|
|
711
|
+
you** — call `probe_path?(path, mount: "/your-prefix")` or build your own with
|
|
712
|
+
`StandardHealth.probe_path_pattern(mount:, up: true, aggregate: true, extra: [])`.
|
|
713
|
+
`aggregate: false` reproduces the old exact set without the bare `/health`;
|
|
714
|
+
`extra:` adds exact paths such as fundbright's `/circuits` alias.
|
|
715
|
+
|
|
716
|
+
Replace your host code with it:
|
|
717
|
+
|
|
718
|
+
```ruby
|
|
719
|
+
# config/environments/production.rb
|
|
720
|
+
# was: %r{\A/(up|health/(alive|ready))\z} (x3 in fundbright/luminality/nutripod)
|
|
721
|
+
config.silence_healthcheck_path = StandardHealth::PROBE_PATHS
|
|
722
|
+
config.x.silence_console_paths = [StandardHealth::PROBE_PATHS] # luminality/sidekick
|
|
723
|
+
config.ssl_options = { redirect: { exclude: ->(r) { StandardHealth.probe_path?(r.path) } } }
|
|
724
|
+
config.host_authorization = { exclude: ->(r) { StandardHealth.probe_path?(r.path) } }
|
|
725
|
+
|
|
726
|
+
# config/initializers/sentry.rb traces_sampler
|
|
727
|
+
# was: Sentry::ProbePaths.probe?(path) (lib/sentry/probe_paths.rb in
|
|
728
|
+
# fundbright/luminality/sidekick) or SentryTracesSampler#health_or_stream?
|
|
729
|
+
# in jumpdrive, or the inline start_with? in nutripod
|
|
730
|
+
return 0.0 if StandardHealth.probe_path?(rack_env["PATH_INFO"])
|
|
731
|
+
return 0.0 if StandardHealth.probe_path?(path, extra: ["/circuits"]) # fundbright
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
Note the difference from the samplers' segment-prefix match: this is an
|
|
735
|
+
**exact** match on the probe routes the engine actually serves, so a future
|
|
736
|
+
`/health/<something>` sub-route is covered by a gem release rather than by a
|
|
737
|
+
prefix. Paths the gem doesn't serve (sidekick's `/api/v1/provisioning/health`)
|
|
738
|
+
go in `extra:`.
|
|
739
|
+
|
|
493
740
|
## Status semantics
|
|
494
741
|
|
|
495
742
|
`/ready` returns:
|
|
@@ -537,7 +784,14 @@ The gem emits events on whichever bus is live — `Rails.event` on Rails 8.1+,
|
|
|
537
784
|
|---|---|
|
|
538
785
|
| `standard_health.check.completed` | `name`, `critical`, `status`, `latency_ms`, `error_class`, `error_message` |
|
|
539
786
|
| `standard_health.check.timed_out` | `name`, `critical`, `timeout_s` |
|
|
540
|
-
| `standard_health.ready.evaluated` | `status`, `duration_ms`, `failed[]` |
|
|
787
|
+
| `standard_health.ready.evaluated` | `status`, `duration_ms`, `failed[]`, `failures[]` |
|
|
788
|
+
| `standard_health.aggregate.evaluated` | `status`, `duration_ms`, `failed[]`, `failures[]`, `circuits_status`, `red_circuits[]` (0.6.0, only with `aggregate_endpoint`) |
|
|
789
|
+
|
|
790
|
+
Checks run by the aggregate tier also emit `check.completed` / `check.timed_out`,
|
|
791
|
+
carrying `tier: :aggregate`; readiness payloads carry no `tier` key, exactly as
|
|
792
|
+
before 0.6.0. The aggregate evaluation deliberately gets its **own** event:
|
|
793
|
+
the Sentry notifier keeps per-process transition state on `ready.evaluated`,
|
|
794
|
+
and an aggregate that includes soft checks would make it flap.
|
|
541
795
|
|
|
542
796
|
Three subscribers are registered automatically. Their noise profiles differ on
|
|
543
797
|
purpose, because health events are **polls**, not state transitions — a 10s
|
|
@@ -551,7 +805,38 @@ probe period means ~6 evaluations/minute/instance:
|
|
|
551
805
|
poll would turn a five-minute outage into ~30 duplicate issues. Sentry is a
|
|
552
806
|
soft dependency; no gemspec entry.
|
|
553
807
|
- **Metrics** — every poll, deliberately. Counters plus latency
|
|
554
|
-
distributions are what make `latency_ms` chartable.
|
|
808
|
+
distributions are what make `latency_ms` chartable. Aggregate-tier check
|
|
809
|
+
counts carry a `tier` attribute.
|
|
810
|
+
|
|
811
|
+
The Logger also reports `aggregate.evaluated` (silent on ok); Sentry and
|
|
812
|
+
Metrics ignore it.
|
|
813
|
+
|
|
814
|
+
### Narrowing metrics (0.6.0)
|
|
815
|
+
|
|
816
|
+
The per-poll events are the metric volume (~2 evaluations × N checks per probe
|
|
817
|
+
interval per instance). To keep the rare, actionable timeout metric and drop
|
|
818
|
+
the per-poll firehose:
|
|
819
|
+
|
|
820
|
+
```ruby
|
|
821
|
+
c.metric_events = %w[standard_health.check.timed_out]
|
|
822
|
+
# or equivalently:
|
|
823
|
+
c.metric_events = StandardHealth::Notifiers::Metrics::EVENTS -
|
|
824
|
+
StandardHealth::Notifiers::Metrics::PER_POLL_EVENTS
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
or drop the Metrics notifier entirely — unlike `instrumentation_enabled =
|
|
828
|
+
false`, this keeps the Logger and Sentry notifiers:
|
|
829
|
+
|
|
830
|
+
```ruby
|
|
831
|
+
c.metrics_enabled = false
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
`metric_events` is an allow-list (nil, the default, records everything) and
|
|
835
|
+
unknown names raise at boot. **Replace your host code with it:** sidekick's
|
|
836
|
+
`config/initializers/standard_health_metrics.rb` (the
|
|
837
|
+
`StandardHealthPollMetricSuppression` prepend) becomes the first snippet above;
|
|
838
|
+
its spec's "is patched with the suppression module" example goes, the
|
|
839
|
+
behavioural examples stay.
|
|
555
840
|
|
|
556
841
|
```ruby
|
|
557
842
|
StandardHealth.configure do |c|
|
|
@@ -559,6 +844,8 @@ StandardHealth.configure do |c|
|
|
|
559
844
|
c.logger = Rails.logger # default: Rails.logger
|
|
560
845
|
c.sentry_enabled = true # default
|
|
561
846
|
c.metric_prefix = "health" # default
|
|
847
|
+
c.metrics_enabled = true # default (0.6.0)
|
|
848
|
+
c.metric_events = nil # default: all (0.6.0)
|
|
562
849
|
|
|
563
850
|
c.add_notifier(MyNotifier.new) # must respond to call(event_name, payload)
|
|
564
851
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module StandardHealth
|
|
6
|
+
# Request-time half of `config.diagnostics_basic_auth`. Included into
|
|
7
|
+
# `DiagnosticsController` only — never `HealthController` — so /alive and
|
|
8
|
+
# /ready stay anonymous for orchestrator probes.
|
|
9
|
+
#
|
|
10
|
+
# A NO-OP when `diagnostics_basic_auth` is unset, so hosts that gate
|
|
11
|
+
# diagnostics through `diagnostics_parent_controller` see no change. Both
|
|
12
|
+
# can be used together; the parent's callbacks run first.
|
|
13
|
+
module DiagnosticsAuthentication
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
# ActionController::API does not include this; ActionController::Base
|
|
18
|
+
# does, and including it twice is harmless.
|
|
19
|
+
include ActionController::HttpAuthentication::Basic::ControllerMethods
|
|
20
|
+
|
|
21
|
+
before_action :standard_health_diagnostics_basic_auth!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def standard_health_diagnostics_basic_auth!
|
|
27
|
+
settings = StandardHealth.config.diagnostics_basic_auth
|
|
28
|
+
return unless settings
|
|
29
|
+
|
|
30
|
+
user = settings.expected_username
|
|
31
|
+
pass = settings.expected_password
|
|
32
|
+
|
|
33
|
+
if user.empty? || pass.empty?
|
|
34
|
+
return if settings.allow_unconfigured?
|
|
35
|
+
|
|
36
|
+
return refuse_unconfigured_diagnostics!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
authenticate_or_request_with_http_basic(settings.realm) do |given_user, given_pass|
|
|
40
|
+
settings.authenticate(given_user, given_pass)
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
# A credential lookup that raises (missing credentials key, Current not
|
|
44
|
+
# set, ...) must fail CLOSED — never fall through to the audit, and
|
|
45
|
+
# never 500 (see below). The class name is logged, not rendered.
|
|
46
|
+
Rails.logger&.warn("[StandardHealth] diagnostics auth lookup failed: #{e.class}")
|
|
47
|
+
refuse_unconfigured_diagnostics!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 403, NOT 5xx — a platform constraint, not a preference. DigitalOcean
|
|
51
|
+
# App Platform's edge replaces an app 5xx with its own generic error page,
|
|
52
|
+
# so a 503 here is indistinguishable from the app being down and the
|
|
53
|
+
# explanation is thrown away. 403 passes through and is honest: access is
|
|
54
|
+
# refused. Not 401, because a challenge invites retrying with credentials
|
|
55
|
+
# that cannot work — none are configured to match.
|
|
56
|
+
def refuse_unconfigured_diagnostics!
|
|
57
|
+
render json: {
|
|
58
|
+
error: "diagnostics refused",
|
|
59
|
+
detail: "diagnostics basic-auth credentials are not configured, so this endpoint " \
|
|
60
|
+
"cannot be authenticated. Refusing rather than exposing env state."
|
|
61
|
+
}, status: :forbidden
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -11,7 +11,12 @@ module StandardHealth
|
|
|
11
11
|
# `before_action :auth, only: :env` on a dedicated diagnostics parent —
|
|
12
12
|
# without that callback leaking onto `HealthController` and tripping
|
|
13
13
|
# Rails 7.1's `raise_on_missing_callback_actions`.
|
|
14
|
+
#
|
|
15
|
+
# Or, since 0.6.0, set `config.diagnostics_basic_auth` and let the engine
|
|
16
|
+
# gate it (fail-closed) — see `DiagnosticsAuthentication`.
|
|
14
17
|
class DiagnosticsController < DiagnosticsApplicationController
|
|
18
|
+
include DiagnosticsAuthentication
|
|
19
|
+
|
|
15
20
|
# Audits the configured EnvSpec against the current process ENV and
|
|
16
21
|
# returns the result as JSON. When no EnvSpec is configured the
|
|
17
22
|
# endpoint returns an empty audit rather than a 404 so callers don't
|
|
@@ -25,6 +25,18 @@ module StandardHealth
|
|
|
25
25
|
status: http_status
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Aggregate tier (0.6.0, opt-in via `config.aggregate_endpoint`; the route
|
|
29
|
+
# does not match otherwise). Checks + StandardCircuit state in one body —
|
|
30
|
+
# see StandardHealth::AggregateReport. 503 only on :unavailable, i.e. a
|
|
31
|
+
# critical check failure or a :critical circuit RED. Redacted exactly like
|
|
32
|
+
# /ready, with the same break-glass.
|
|
33
|
+
def aggregate
|
|
34
|
+
report = StandardHealth::AggregateReport.call
|
|
35
|
+
http_status = report[:status] == :unavailable ? :service_unavailable : :ok
|
|
36
|
+
render json: StandardHealth::Redactor.call(report, expose: expose_errors?),
|
|
37
|
+
status: http_status
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
private
|
|
29
41
|
|
|
30
42
|
# Detail is exposed when the host opted in globally, or when the caller
|
data/config/routes.rb
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
StandardHealth::Engine.routes.draw do
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Probe routes are drawn from PROBE_ACTIONS (alive, ready) so
|
|
5
|
+
# StandardHealth::PROBE_PATHS / probe_path? cannot drift from what the
|
|
6
|
+
# engine serves.
|
|
7
|
+
StandardHealth::PROBE_ACTIONS.each do |action|
|
|
8
|
+
get "/#{action}", to: "health##{action}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Aggregate tier at the engine root (GET /health for the usual mount).
|
|
12
|
+
# OPT-IN: the constraint is evaluated per request, so with
|
|
13
|
+
# `aggregate_endpoint` off this route never matches and a bare /health
|
|
14
|
+
# cascades to the host's own route exactly as it did before 0.6.0.
|
|
15
|
+
get "/", to: "health#aggregate", as: :aggregate,
|
|
16
|
+
constraints: ->(_request) { StandardHealth.config.aggregate_endpoint }
|
|
6
17
|
|
|
7
18
|
namespace :diagnostics do
|
|
8
19
|
get :env
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
# tier silently has no aggregate tier at all — no boot error, and no failing
|
|
16
16
|
# route spec unless one exists. Add a route spec for `GET /health`.
|
|
17
17
|
#
|
|
18
|
+
# Alternatively (0.6.0+) let the engine serve the aggregate tier itself —
|
|
19
|
+
# checks plus StandardCircuit state — and draw no route at all:
|
|
20
|
+
# config.aggregate_endpoint = true
|
|
21
|
+
#
|
|
18
22
|
# Tiers: /alive = liveness (process up) · /ready = readiness (rotation gate)
|
|
19
23
|
# /health = aggregate (dashboards) · /diagnostics/env = authed doctor.
|
|
20
24
|
# ────────────────────────────────────────────────────────────────────────────
|
|
@@ -28,6 +32,10 @@ StandardHealth.configure do |config|
|
|
|
28
32
|
# it. Setting a diagnostics-only parent lets you put auth on the doctor tier
|
|
29
33
|
# without the callback leaking onto /alive and /ready.
|
|
30
34
|
# config.diagnostics_parent_controller = "DiagnosticsBaseController"
|
|
35
|
+
#
|
|
36
|
+
# Or let the engine gate it with HTTP Basic (fails CLOSED — 403 — when the
|
|
37
|
+
# credentials are unset). `true` reads ADMIN_BASIC_AUTH_USERNAME/_PASSWORD.
|
|
38
|
+
# config.diagnostics_basic_auth = true
|
|
31
39
|
|
|
32
40
|
# ── Checks ───────────────────────────────────────────────────────────────
|
|
33
41
|
#
|
|
@@ -35,6 +43,11 @@ StandardHealth.configure do |config|
|
|
|
35
43
|
# pulls the instance out of rotation, so `critical: true` belongs on the
|
|
36
44
|
# database and little else. Soft upstreams degrade; they never gate rotation.
|
|
37
45
|
config.register_check :database, StandardHealth::Checks::ActiveRecord, critical: true
|
|
46
|
+
#
|
|
47
|
+
# Or register the common set in one call — :database and :solid_queue
|
|
48
|
+
# (critical), :solid_cache and :audit_retention (non-critical), each skipped
|
|
49
|
+
# when its library isn't loaded:
|
|
50
|
+
# config.register_default_checks
|
|
38
51
|
# config.register_check :cache, StandardHealth::Checks::SolidCache
|
|
39
52
|
# config.register_check :queue, StandardHealth::Checks::SolidQueue
|
|
40
53
|
|