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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c16f507443b9c907f813a35fb05f7b5cb1517a84cf73b64198b69ee70a41325c
4
- data.tar.gz: 4ecfb084c5ca25b231785f8cd1c9069f4f8c5b7554ded6aa600d7723724d83a9
3
+ metadata.gz: 2c32e1366e2b116f86a8be7780d4717a35b07952402f14e213a2533530c30fe8
4
+ data.tar.gz: 7c0fe9a0993be6e1ef46adcd95f3ba6c05dc029e7f621557ea314eb0a7062f0e
5
5
  SHA512:
6
- metadata.gz: 004eb3f2f99b3472340be3f4662e9b0e1ed7b1688db29345f244374384438d07bfa6c914aa219b9f33e4ac891b643a86791ced646c0464911f37eafd807bd152
7
- data.tar.gz: 93e407d5e93d2c0c095244290a7aa2c67a7c2c8c67710c931507d18f1a5ca95e211ec9e039b1a66b8615e05473254360dfe64f7154111ab275bc7131ceee5446
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
- **The engine draws sub-paths only.** There is no `GET /health` in
49
- `config/routes.rb` here; the aggregate tier is the host's responsibility, and
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
- The aggregator instantiates checks with `name:`/`critical:` only, so to narrow
411
- what counts as a failure, subclass:
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
- class ForbiddenTogglesOnly < StandardHealth::Checks::EnvSpecAudit
415
- def initialize(name: :forbidden_toggles, critical: false)
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 the host app is responsible for protecting it.
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
- The recommended pattern is to point `parent_controller` at a host app controller that enforces auth:
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
- get "/alive", to: "health#alive"
5
- get "/ready", to: "health#ready"
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