standard_health 0.5.1 → 0.6.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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +278 -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 +48 -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: 5845fe7433611028fb103afb457683b22717a8f1aa79848f425a2cd5d7c9bd7a
|
|
4
|
+
data.tar.gz: c83d29991ba9b19b2ce234e5ef3cacabf198af323b97809927ef36608c49ba7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eddef6b2df86d0ff116a63793b009f3b3afe81ed3f437fcd5f1fea15be0545ca2a384ea346bae3c12050ffc8f95183af4ee5414a8739ead14910b2bcd0a45ea5
|
|
7
|
+
data.tar.gz: cb15f56c75ec0464412c16360d788b8f714d73c844a3e7bde8564295e56d2e56bdc63bb9c5987792efd65c8c3158e4e9b0edde991f0ea69e0a8dfecf93c9361f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
DX release: absorbs the code every consumer app copy-pasted around the engine.
|
|
13
|
+
Everything is **additive and opt-in** — no default changes, no existing route
|
|
14
|
+
returns anything different, and readiness event payloads are byte-identical —
|
|
15
|
+
so this is a pure `bundle update` for consumers on `~> 0.5`. Each feature's
|
|
16
|
+
README section has a "replace your host code with" snippet.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `config.diagnostics_basic_auth` — built-in HTTP Basic gate for
|
|
21
|
+
`/diagnostics/env`. `true` uses `ADMIN_BASIC_AUTH_USERNAME` /
|
|
22
|
+
`ADMIN_BASIC_AUTH_PASSWORD`; a Hash takes `username:` / `password:`
|
|
23
|
+
(String or callable, resolved per request), `realm:` (default
|
|
24
|
+
"Health Diagnostics") and `allow_unconfigured:` (default false). **Fails
|
|
25
|
+
closed** with a 403 when credentials are blank or their lookup raises;
|
|
26
|
+
secure_compare over digests on both halves. Replaces each app's
|
|
27
|
+
`StandardHealthHostController` + `diagnostics_parent_controller` wiring.
|
|
28
|
+
- `Configuration#register_default_checks` — registers `:database`
|
|
29
|
+
(ActiveRecord, critical), `:solid_queue` (critical), `:solid_cache` and
|
|
30
|
+
`:audit_retention` (non-critical), skipping any whose backing library isn't
|
|
31
|
+
loaded or whose name is already registered. Per-check `false` or override
|
|
32
|
+
Hash (`name:`, `critical:`, `timeout:`, constructor options).
|
|
33
|
+
- `StandardHealth::PROBE_PATHS`, `StandardHealth.probe_path?(path, mount:,
|
|
34
|
+
up:, aggregate:, extra:)` and `probe_path_pattern` — the orchestrator probe
|
|
35
|
+
regex (`/up`, `/health`, `/health/alive`, `/health/ready`), built from
|
|
36
|
+
`StandardHealth::PROBE_ACTIONS`, which the engine's routes are now drawn
|
|
37
|
+
from. Excludes the doctor tier.
|
|
38
|
+
- `config.metrics_enabled` (drop only the Metrics notifier) and
|
|
39
|
+
`config.metric_events` (allow-list of event names, validated at boot);
|
|
40
|
+
`Notifiers::Metrics::EVENTS` / `PER_POLL_EVENTS`. Replaces sidekick-web's
|
|
41
|
+
`StandardHealthPollMetricSuppression` prepend.
|
|
42
|
+
- `register_check` forwards extra keywords to the check constructor (e.g.
|
|
43
|
+
`EnvSpecAudit`'s `fail_on:`), validated against its signature at
|
|
44
|
+
registration. `klass` may also be a String, resolved when the check runs.
|
|
45
|
+
- Opt-in aggregate tier: `config.aggregate_endpoint = true` serves
|
|
46
|
+
`{ status, checks, circuits, generated_at }` at the engine root (`GET
|
|
47
|
+
/health`), folding in `StandardCircuit.health_report` when loaded
|
|
48
|
+
(`aggregate_circuits`), the readiness checks (`aggregate_readiness_checks`)
|
|
49
|
+
and aggregate-only checks (`register_aggregate_check`). 503 only on
|
|
50
|
+
`unavailable` (a critical check failure or a `:critical` circuit red);
|
|
51
|
+
redacted like `/ready`. The route is constrained per request, so with the
|
|
52
|
+
flag off a bare `/health` still cascades to the host. Emits
|
|
53
|
+
`standard_health.aggregate.evaluated` (logged; ignored by Sentry and
|
|
54
|
+
Metrics) and tags aggregate-run check events with `tier: :aggregate`.
|
|
55
|
+
- Specs for `Checks::SolidQueue`, `Checks::SolidCache`, `Notifiers::Metrics`,
|
|
56
|
+
`Subscribers` and `EventEmitter`.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- `Checks::EnvSpecAudit` docs: narrow with `fail_on:` at registration instead
|
|
61
|
+
of subclassing (subclassing still works).
|
|
62
|
+
|
|
10
63
|
## [0.5.1] - 2026-09-24
|
|
11
64
|
|
|
12
65
|
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,74 @@ 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
|
+
StandardCircuit's own word `critical` is translated to `unavailable`, so both
|
|
113
|
+
tiers speak one vocabulary. `circuits` is omitted when StandardCircuit isn't
|
|
114
|
+
loaded (or `aggregate_circuits = false`); if `health_report` raises (circuit
|
|
115
|
+
store down) the tier degrades and reports `circuits_error: { error_class,
|
|
116
|
+
error_code }` instead of 500ing. Check rows are redacted exactly like `/ready`,
|
|
117
|
+
with the same `detail_token` break-glass. `register_aggregate_check` checks
|
|
118
|
+
run **only** here — never on `/ready` — and default to `critical: false`.
|
|
119
|
+
|
|
120
|
+
With `aggregate_endpoint` off (the default) the engine's root route carries a
|
|
121
|
+
per-request constraint that never matches, so a bare `/health` cascades to your
|
|
122
|
+
own route exactly as before. Once on, the engine answers `/health` regardless
|
|
123
|
+
of where your own route is drawn relative to the mount.
|
|
124
|
+
|
|
125
|
+
**Migration** — replace your host code with the flag:
|
|
126
|
+
|
|
127
|
+
- **fundbright / nutripod / jumpdrive** (`get "/health", to: "standard_circuit/health#show"`):
|
|
128
|
+
set `c.aggregate_endpoint = true`, delete that route (and the
|
|
129
|
+
`require "standard_circuit/health_controller"` if nothing else uses it —
|
|
130
|
+
fundbright's `/circuits` alias still does). The body gains `checks[]`, and
|
|
131
|
+
a red critical circuit reports `"unavailable"` rather than `"critical"`
|
|
132
|
+
(still 503). Update any dashboard or monitor matching on `"critical"`.
|
|
133
|
+
- **luminality** (`HealthAggregateController` merging `Aggregator` + circuits):
|
|
134
|
+
set the flag, delete the route and the controller. Same envelope and status
|
|
135
|
+
words — it is this controller.
|
|
136
|
+
- **sidekick** (`HealthAggregateController` with soft checks only):
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
c.aggregate_endpoint = true
|
|
140
|
+
c.aggregate_readiness_checks = false # sidekick's aggregate never re-ran readiness
|
|
141
|
+
c.register_aggregate_check :solid_cable, StandardHealth::Checks::SolidCable
|
|
142
|
+
c.register_aggregate_check :attestation_roots, "AttestationRootsCheck" # String: resolved per request
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
then delete the route and the controller. Status words move from
|
|
146
|
+
`critical` to `unavailable`, as above.
|
|
147
|
+
|
|
148
|
+
Aggregate evaluations emit `standard_health.aggregate.evaluated`, **not**
|
|
149
|
+
`ready.evaluated` — see [Instrumentation](#instrumentation).
|
|
150
|
+
|
|
82
151
|
## Configuration
|
|
83
152
|
|
|
84
153
|
Create `config/initializers/standard_health.rb` (or let the generator write it):
|
|
@@ -105,6 +174,40 @@ StandardHealth.configure do |c|
|
|
|
105
174
|
end
|
|
106
175
|
```
|
|
107
176
|
|
|
177
|
+
### `register_default_checks` (0.6.0)
|
|
178
|
+
|
|
179
|
+
Every consumer registers the same set by hand. One call does it:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# replaces:
|
|
183
|
+
# c.register_check :database, StandardHealth::Checks::ActiveRecord, critical: true
|
|
184
|
+
# c.register_check :solid_queue, StandardHealth::Checks::SolidQueue, critical: true
|
|
185
|
+
# c.register_check :solid_cache, StandardHealth::Checks::SolidCache, critical: false
|
|
186
|
+
# c.register_check :audit_retention, StandardAudit::Checks::Retention, critical: false
|
|
187
|
+
c.register_default_checks
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| Key | Registered as | Class | `critical` | Skipped unless |
|
|
191
|
+
|---|---|---|---|---|
|
|
192
|
+
| `database` | `:database` | `Checks::ActiveRecord` | `true` | `ActiveRecord::Base` is loaded |
|
|
193
|
+
| `solid_queue` | `:solid_queue` | `Checks::SolidQueue` | `true` | `SolidQueue` is loaded |
|
|
194
|
+
| `solid_cache` | `:solid_cache` | `Checks::SolidCache` | `false` | `SolidCache` is loaded |
|
|
195
|
+
| `audit_retention` | `:audit_retention` | `StandardAudit::Checks::Retention` | `false` | `standard_audit` is loaded |
|
|
196
|
+
|
|
197
|
+
A check whose backing library isn't loaded is skipped rather than registered
|
|
198
|
+
to fail forever. A name that's already registered is skipped too, so it's safe
|
|
199
|
+
to mix with hand-registered checks (in either order) and to call twice. Each
|
|
200
|
+
key takes `true` (defaults), `false` (skip), or a Hash of overrides —
|
|
201
|
+
`name:`, `critical:`, `timeout:`, plus any [constructor options](#custom-checks):
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
c.register_default_checks solid_cache: false, # jumpdrive: has SolidCache, doesn't check it
|
|
205
|
+
solid_queue: { timeout: 2 }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Returns the names it registered. Check order in the response follows
|
|
209
|
+
registration order.
|
|
210
|
+
|
|
108
211
|
## EnvSpec
|
|
109
212
|
|
|
110
213
|
The DSL has three declarations:
|
|
@@ -350,6 +453,32 @@ end
|
|
|
350
453
|
|
|
351
454
|
`with_timing` captures `latency_ms` on success and converts any `StandardError` into `{ status: :fail, error: <message> }`.
|
|
352
455
|
|
|
456
|
+
**Per-registration options (0.6.0).** Keywords other than `critical:` and
|
|
457
|
+
`timeout:` are forwarded to the check's constructor, and validated against its
|
|
458
|
+
signature at registration — a typo fails at boot, not on every probe:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
class QueueDepthCheck < StandardHealth::Check
|
|
462
|
+
def initialize(name:, critical: false, max_depth: 1_000)
|
|
463
|
+
super(name: name, critical: critical)
|
|
464
|
+
@max_depth = max_depth
|
|
465
|
+
end
|
|
466
|
+
# ...
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
c.register_check :queue_depth, QueueDepthCheck, max_depth: 5_000
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**String class names.** `klass` may be a String, resolved each time the check
|
|
473
|
+
runs. That lets an initializer register an autoloaded app constant (which isn't
|
|
474
|
+
resolvable yet at initializer time) without a `to_prepare` block, and picks up
|
|
475
|
+
class reloads in development. An unresolvable name reports a failing row
|
|
476
|
+
(`error_class: "NameError"`) rather than raising.
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
c.register_check :runner, "RunnerHealthCheck"
|
|
480
|
+
```
|
|
481
|
+
|
|
353
482
|
**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
483
|
|
|
355
484
|
## Opt-in checks
|
|
@@ -407,15 +536,12 @@ still serving traffic correctly, and de-rotating it converts a warning into an
|
|
|
407
536
|
outage. Registering it `critical: true` asserts "this app must not serve at all
|
|
408
537
|
with a bad env" — a real but rare posture. Know which you want.
|
|
409
538
|
|
|
410
|
-
|
|
411
|
-
|
|
539
|
+
To narrow what counts as a failure, pass `fail_on:` at registration (0.6.0+
|
|
540
|
+
forwards it to the constructor — no subclass needed):
|
|
412
541
|
|
|
413
542
|
```ruby
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
super(name: name, critical: critical, fail_on: %i[forbidden])
|
|
417
|
-
end
|
|
418
|
-
end
|
|
543
|
+
c.register_check :forbidden_toggles, StandardHealth::Checks::EnvSpecAudit,
|
|
544
|
+
fail_on: %i[forbidden]
|
|
419
545
|
```
|
|
420
546
|
|
|
421
547
|
Note the failure message is subject to [redaction](#failure-detail-is-redacted)
|
|
@@ -427,9 +553,60 @@ Sentry. The row carries a stable `error_class` of
|
|
|
427
553
|
|
|
428
554
|
## Auth
|
|
429
555
|
|
|
430
|
-
`/alive` and `/ready` are typically left open for orchestrator probes. `/diagnostics/env` enumerates which env vars are missing — that's potentially sensitive, so
|
|
556
|
+
`/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.
|
|
557
|
+
|
|
558
|
+
### Built-in basic auth for diagnostics (0.6.0)
|
|
559
|
+
|
|
560
|
+
The simplest option is to let the engine gate it:
|
|
431
561
|
|
|
432
|
-
|
|
562
|
+
```ruby
|
|
563
|
+
StandardHealth.configure do |c|
|
|
564
|
+
c.diagnostics_basic_auth = true # ADMIN_BASIC_AUTH_USERNAME / ADMIN_BASIC_AUTH_PASSWORD
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
or, with your own credential source (Strings or callables, resolved **per
|
|
569
|
+
request**, so rotation needs no restart):
|
|
570
|
+
|
|
571
|
+
```ruby
|
|
572
|
+
c.diagnostics_basic_auth = {
|
|
573
|
+
username: -> { Current.config.admin_basic_auth_username },
|
|
574
|
+
password: -> { Current.config.admin_basic_auth_password },
|
|
575
|
+
realm: "Health Diagnostics", # default
|
|
576
|
+
allow_unconfigured: -> { Rails.env.local? } # default: false
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
- Only `/diagnostics/env` is gated; `/alive`, `/ready` and the aggregate tier stay anonymous.
|
|
581
|
+
- **Fails closed.** If either credential resolves blank (or the lookup raises),
|
|
582
|
+
the endpoint answers **403** `{"error":"diagnostics refused", ...}` rather
|
|
583
|
+
than serving env state. 403, not 503: DigitalOcean App Platform's edge
|
|
584
|
+
replaces an app 5xx with its own error page, which reads as "app down". Not
|
|
585
|
+
401: there are no credentials that could succeed. `allow_unconfigured`
|
|
586
|
+
(boolean or callable) opts a credential-less environment — typically local
|
|
587
|
+
dev — into passing through.
|
|
588
|
+
- Both halves are compared with `secure_compare` over SHA-256 digests, so a
|
|
589
|
+
wrong username still costs a password comparison and differing lengths leak
|
|
590
|
+
nothing.
|
|
591
|
+
- Off by default; independent of `diagnostics_parent_controller` (if you set
|
|
592
|
+
both, the parent's callbacks run first).
|
|
593
|
+
|
|
594
|
+
**Replace your host code with it.** Delete
|
|
595
|
+
`app/controllers/standard_health_host_controller.rb` and the
|
|
596
|
+
`c.diagnostics_parent_controller = "StandardHealthHostController"` line, then:
|
|
597
|
+
|
|
598
|
+
| App | Was | Set |
|
|
599
|
+
|---|---|---|
|
|
600
|
+
| jumpdrive | fail-closed 403 on unset `ADMIN_BASIC_AUTH_*` | `c.diagnostics_basic_auth = true` |
|
|
601
|
+
| sidekick | 503 when unset in staging/preview/production, open locally | `c.diagnostics_basic_auth = { allow_unconfigured: -> { !%w[staging preview production].include?(ENV["APP_ENVIRONMENT"]) } }` |
|
|
602
|
+
| fundbright, luminality | open when unset (boot-enforced in deployed envs) | `c.diagnostics_basic_auth = { allow_unconfigured: -> { Rails.env.local? } }` |
|
|
603
|
+
| 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? } }` |
|
|
604
|
+
|
|
605
|
+
(Sidekick moves from 503 to 403 on a missing gate — see above for why.)
|
|
606
|
+
|
|
607
|
+
### Bring your own parent controller
|
|
608
|
+
|
|
609
|
+
The pre-0.6.0 pattern is to point `parent_controller` at a host app controller that enforces auth:
|
|
433
610
|
|
|
434
611
|
```ruby
|
|
435
612
|
# app/controllers/internal_health_controller.rb
|
|
@@ -490,6 +667,53 @@ Now `/health/alive` and `/health/ready` are unauthenticated (probe-friendly) whi
|
|
|
490
667
|
|
|
491
668
|
When `diagnostics_parent_controller` is unset, `DiagnosticsController` falls back to `parent_controller`, matching v0.1.0 behavior exactly.
|
|
492
669
|
|
|
670
|
+
## Probe paths (0.6.0)
|
|
671
|
+
|
|
672
|
+
Hosts copy the probe regex into `production.rb` and their Sentry samplers. The
|
|
673
|
+
gem now owns it, built from the same `PROBE_ACTIONS` list its routes are drawn
|
|
674
|
+
from, so it cannot drift:
|
|
675
|
+
|
|
676
|
+
```ruby
|
|
677
|
+
StandardHealth::PROBE_PATHS
|
|
678
|
+
# => matches /up, /health, /health/alive, /health/ready (optional trailing /)
|
|
679
|
+
# never /health/diagnostics/env, never /healthy-habits
|
|
680
|
+
StandardHealth.probe_path?(path) # predicate, same default
|
|
681
|
+
StandardHealth.probe_path?(path, mount: "/_status") # engine mounted elsewhere
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Anchored at both ends. The doctor tier is deliberately excluded — it's authed,
|
|
685
|
+
hit by on-call rather than on a timer, and belongs in logs and APM.
|
|
686
|
+
|
|
687
|
+
**If you mount the engine anywhere but `/health`, `PROBE_PATHS` is wrong for
|
|
688
|
+
you** — call `probe_path?(path, mount: "/your-prefix")` or build your own with
|
|
689
|
+
`StandardHealth.probe_path_pattern(mount:, up: true, aggregate: true, extra: [])`.
|
|
690
|
+
`aggregate: false` reproduces the old exact set without the bare `/health`;
|
|
691
|
+
`extra:` adds exact paths such as fundbright's `/circuits` alias.
|
|
692
|
+
|
|
693
|
+
Replace your host code with it:
|
|
694
|
+
|
|
695
|
+
```ruby
|
|
696
|
+
# config/environments/production.rb
|
|
697
|
+
# was: %r{\A/(up|health/(alive|ready))\z} (x3 in fundbright/luminality/nutripod)
|
|
698
|
+
config.silence_healthcheck_path = StandardHealth::PROBE_PATHS
|
|
699
|
+
config.x.silence_console_paths = [StandardHealth::PROBE_PATHS] # luminality/sidekick
|
|
700
|
+
config.ssl_options = { redirect: { exclude: ->(r) { StandardHealth.probe_path?(r.path) } } }
|
|
701
|
+
config.host_authorization = { exclude: ->(r) { StandardHealth.probe_path?(r.path) } }
|
|
702
|
+
|
|
703
|
+
# config/initializers/sentry.rb traces_sampler
|
|
704
|
+
# was: Sentry::ProbePaths.probe?(path) (lib/sentry/probe_paths.rb in
|
|
705
|
+
# fundbright/luminality/sidekick) or SentryTracesSampler#health_or_stream?
|
|
706
|
+
# in jumpdrive, or the inline start_with? in nutripod
|
|
707
|
+
return 0.0 if StandardHealth.probe_path?(rack_env["PATH_INFO"])
|
|
708
|
+
return 0.0 if StandardHealth.probe_path?(path, extra: ["/circuits"]) # fundbright
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Note the difference from the samplers' segment-prefix match: this is an
|
|
712
|
+
**exact** match on the probe routes the engine actually serves, so a future
|
|
713
|
+
`/health/<something>` sub-route is covered by a gem release rather than by a
|
|
714
|
+
prefix. Paths the gem doesn't serve (sidekick's `/api/v1/provisioning/health`)
|
|
715
|
+
go in `extra:`.
|
|
716
|
+
|
|
493
717
|
## Status semantics
|
|
494
718
|
|
|
495
719
|
`/ready` returns:
|
|
@@ -537,7 +761,14 @@ The gem emits events on whichever bus is live — `Rails.event` on Rails 8.1+,
|
|
|
537
761
|
|---|---|
|
|
538
762
|
| `standard_health.check.completed` | `name`, `critical`, `status`, `latency_ms`, `error_class`, `error_message` |
|
|
539
763
|
| `standard_health.check.timed_out` | `name`, `critical`, `timeout_s` |
|
|
540
|
-
| `standard_health.ready.evaluated` | `status`, `duration_ms`, `failed[]` |
|
|
764
|
+
| `standard_health.ready.evaluated` | `status`, `duration_ms`, `failed[]`, `failures[]` |
|
|
765
|
+
| `standard_health.aggregate.evaluated` | `status`, `duration_ms`, `failed[]`, `failures[]`, `circuits_status`, `red_circuits[]` (0.6.0, only with `aggregate_endpoint`) |
|
|
766
|
+
|
|
767
|
+
Checks run by the aggregate tier also emit `check.completed` / `check.timed_out`,
|
|
768
|
+
carrying `tier: :aggregate`; readiness payloads carry no `tier` key, exactly as
|
|
769
|
+
before 0.6.0. The aggregate evaluation deliberately gets its **own** event:
|
|
770
|
+
the Sentry notifier keeps per-process transition state on `ready.evaluated`,
|
|
771
|
+
and an aggregate that includes soft checks would make it flap.
|
|
541
772
|
|
|
542
773
|
Three subscribers are registered automatically. Their noise profiles differ on
|
|
543
774
|
purpose, because health events are **polls**, not state transitions — a 10s
|
|
@@ -551,7 +782,38 @@ probe period means ~6 evaluations/minute/instance:
|
|
|
551
782
|
poll would turn a five-minute outage into ~30 duplicate issues. Sentry is a
|
|
552
783
|
soft dependency; no gemspec entry.
|
|
553
784
|
- **Metrics** — every poll, deliberately. Counters plus latency
|
|
554
|
-
distributions are what make `latency_ms` chartable.
|
|
785
|
+
distributions are what make `latency_ms` chartable. Aggregate-tier check
|
|
786
|
+
counts carry a `tier` attribute.
|
|
787
|
+
|
|
788
|
+
The Logger also reports `aggregate.evaluated` (silent on ok); Sentry and
|
|
789
|
+
Metrics ignore it.
|
|
790
|
+
|
|
791
|
+
### Narrowing metrics (0.6.0)
|
|
792
|
+
|
|
793
|
+
The per-poll events are the metric volume (~2 evaluations × N checks per probe
|
|
794
|
+
interval per instance). To keep the rare, actionable timeout metric and drop
|
|
795
|
+
the per-poll firehose:
|
|
796
|
+
|
|
797
|
+
```ruby
|
|
798
|
+
c.metric_events = %w[standard_health.check.timed_out]
|
|
799
|
+
# or equivalently:
|
|
800
|
+
c.metric_events = StandardHealth::Notifiers::Metrics::EVENTS -
|
|
801
|
+
StandardHealth::Notifiers::Metrics::PER_POLL_EVENTS
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
or drop the Metrics notifier entirely — unlike `instrumentation_enabled =
|
|
805
|
+
false`, this keeps the Logger and Sentry notifiers:
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
c.metrics_enabled = false
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
`metric_events` is an allow-list (nil, the default, records everything) and
|
|
812
|
+
unknown names raise at boot. **Replace your host code with it:** sidekick's
|
|
813
|
+
`config/initializers/standard_health_metrics.rb` (the
|
|
814
|
+
`StandardHealthPollMetricSuppression` prepend) becomes the first snippet above;
|
|
815
|
+
its spec's "is patched with the suppression module" example goes, the
|
|
816
|
+
behavioural examples stay.
|
|
555
817
|
|
|
556
818
|
```ruby
|
|
557
819
|
StandardHealth.configure do |c|
|
|
@@ -559,6 +821,8 @@ StandardHealth.configure do |c|
|
|
|
559
821
|
c.logger = Rails.logger # default: Rails.logger
|
|
560
822
|
c.sentry_enabled = true # default
|
|
561
823
|
c.metric_prefix = "health" # default
|
|
824
|
+
c.metrics_enabled = true # default (0.6.0)
|
|
825
|
+
c.metric_events = nil # default: all (0.6.0)
|
|
562
826
|
|
|
563
827
|
c.add_notifier(MyNotifier.new) # must respond to call(event_name, payload)
|
|
564
828
|
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
|
|