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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c16f507443b9c907f813a35fb05f7b5cb1517a84cf73b64198b69ee70a41325c
4
- data.tar.gz: 4ecfb084c5ca25b231785f8cd1c9069f4f8c5b7554ded6aa600d7723724d83a9
3
+ metadata.gz: 5845fe7433611028fb103afb457683b22717a8f1aa79848f425a2cd5d7c9bd7a
4
+ data.tar.gz: c83d29991ba9b19b2ce234e5ef3cacabf198af323b97809927ef36608c49ba7e
5
5
  SHA512:
6
- metadata.gz: 004eb3f2f99b3472340be3f4662e9b0e1ed7b1688db29345f244374384438d07bfa6c914aa219b9f33e4ac891b643a86791ced646c0464911f37eafd807bd152
7
- data.tar.gz: 93e407d5e93d2c0c095244290a7aa2c67a7c2c8c67710c931507d18f1a5ca95e211ec9e039b1a66b8615e05473254360dfe64f7154111ab275bc7131ceee5446
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
- **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,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
- The aggregator instantiates checks with `name:`/`critical:` only, so to narrow
411
- what counts as a failure, subclass:
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
- 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
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 the host app is responsible for protecting it.
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
- The recommended pattern is to point `parent_controller` at a host app controller that enforces auth:
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
- 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