standard_health 0.6.0 → 0.7.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 +72 -0
- data/README.md +83 -3
- data/app/controllers/concerns/standard_health/diagnostics_authentication.rb +9 -0
- data/app/controllers/standard_health/diagnostics_controller.rb +8 -3
- data/lib/standard_health/aggregator.rb +34 -0
- data/lib/standard_health/configuration.rb +46 -0
- data/lib/standard_health/diagnostics_assertions.rb +62 -0
- data/lib/standard_health/version.rb +1 -1
- data/lib/standard_health.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b9de071226844b0a4c1304b7b81d21ba11b963e3c4ad082aebd05a82a24fa92
|
|
4
|
+
data.tar.gz: dd4bab92dade63959f66db19ee8467c753e39a1f77dd12706adff721460bc14e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b6431be6025cdfb36f4059d1d295464c43d0daf927155cd53953c34cbac683ce04fbd810acc7217765be574fa4ea85eb59a8186d9326c617225705efdf878ef
|
|
7
|
+
data.tar.gz: 623ea6e63b5f4e11c281ad64d08e60ec10e858b960adcc6758ffa98970cc5e92cd4c0e64406d679aad2d44e387800de88f2bef521f5513899188ae81e811cba9
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
The Phase 4 release. 0.6 deprecated nothing, so nothing is removed. It adds
|
|
13
|
+
the one extension point the app adoptions still needed their own controller
|
|
14
|
+
for. The response of `/diagnostics/env` gains a key, and a failing assertion
|
|
15
|
+
changes its `status`, so this ships as a minor.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`Configuration#register_diagnostics_assertion(name, callable = nil, &block)`**
|
|
20
|
+
adds a runtime assertion to the doctor tier. `/diagnostics/env` now renders
|
|
21
|
+
`assertions: [...]` next to `audit`. The key is always present (`[]` when
|
|
22
|
+
none are registered). Each callable returns a Hash with `status:` (`:ok` /
|
|
23
|
+
`:warn` / `:error`) plus detail keys, and the gem sets `name:`. A raise
|
|
24
|
+
becomes an `:error` row with `error_class` / `error` and is reported to
|
|
25
|
+
`Rails.error` as handled (`context: { diagnostics_assertion: name }`,
|
|
26
|
+
`source: "standard_health"`). A non-Hash result or an unknown status also
|
|
27
|
+
becomes an `:error` row. Re-registering a name replaces it (reload-safe).
|
|
28
|
+
`diagnostics_assertions` / `reset_diagnostics_assertions!` are provided.
|
|
29
|
+
Assertions never run on `/alive`, `/ready` or the aggregate tier, and stay
|
|
30
|
+
behind `diagnostics_basic_auth`.
|
|
31
|
+
- **`StandardHealth::DiagnosticsAssertions.run` / `.failing?`**, public for
|
|
32
|
+
hosts that render their own diagnostics endpoint.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **`/diagnostics/env` `status` also rolls up assertions.** An `:error`
|
|
37
|
+
assertion makes it `incomplete` (`:warn` does not). The HTTP status is still
|
|
38
|
+
200. Nothing changes unless you register an assertion.
|
|
39
|
+
- **`StandardHealth::DiagnosticsAuthentication` is public, semver-stable
|
|
40
|
+
API.** Including it into a host controller (as sidekick-web does) is now a
|
|
41
|
+
supported extension point: the include, its single `before_action`, and the
|
|
42
|
+
401 challenge / 403 fail-closed refusal. Its private method names are not
|
|
43
|
+
part of the contract.
|
|
44
|
+
|
|
45
|
+
### Upgrade notes (0.6.x → 0.7.0)
|
|
46
|
+
|
|
47
|
+
Grepped `origin/main` of sidekick-web, jumpdrive-web, fundbright-web,
|
|
48
|
+
luminality-web and nutripod-web on 2026-09-24.
|
|
49
|
+
|
|
50
|
+
- **No required host change.** Nothing was removed.
|
|
51
|
+
- **Monitors or scripts that parse `/diagnostics/env`:** a new `assertions`
|
|
52
|
+
key appears, and `status` can become `incomplete` from an `:error`
|
|
53
|
+
assertion once you register one.
|
|
54
|
+
- **sidekick-web (optional, recommended):** move the three assertions in
|
|
55
|
+
`app/controllers/health_diagnostics_controller.rb` (`statement_timeout`,
|
|
56
|
+
`rate_limit_store`, `attestation_roots`) into
|
|
57
|
+
`register_diagnostics_assertion` calls in
|
|
58
|
+
`config/initializers/standard_health.rb`. Then delete the controller and its
|
|
59
|
+
`config/routes.rb` route that shadows the engine's `/health/diagnostics/env`.
|
|
60
|
+
Keep each assertion's Hash shape. Drop the per-assertion
|
|
61
|
+
`rescue => e; { status: :error, error: e.message }`, which the gem now
|
|
62
|
+
provides (it also adds `error_class`). The response gains `status`, which
|
|
63
|
+
the host controller never rendered.
|
|
64
|
+
- Other apps have no diagnostics controller of their own. Nothing to do.
|
|
65
|
+
|
|
66
|
+
### Documentation
|
|
67
|
+
|
|
68
|
+
- Aggregate-tier check **timeouts** are deliberately not sent to `Rails.error` (true since 0.6.1). A timeout is the gem's own budget firing on a slow dependency, already emitted as `standard_health.check.timed_out`, and the pre-0.6 host controllers had no per-check timeouts. A code comment and a spec now pin this.
|
|
69
|
+
|
|
70
|
+
## [0.6.1] - 2026-09-24
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **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.
|
|
75
|
+
|
|
76
|
+
### Documentation
|
|
77
|
+
|
|
78
|
+
- 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`.
|
|
79
|
+
- The aggregate body reports `"unavailable"`, never StandardCircuit's `"critical"`. Monitors matching on `"critical"` need updating (as noted in 0.6.0's migration notes).
|
|
80
|
+
- `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.
|
|
81
|
+
|
|
10
82
|
## [0.6.0] - 2026-09-24
|
|
11
83
|
|
|
12
84
|
DX release: absorbs the code every consumer app copy-pasted around the engine.
|
data/README.md
CHANGED
|
@@ -109,14 +109,31 @@ end
|
|
|
109
109
|
| `degraded` | 200 | a non-critical check failed or was skipped, or the circuit roll-up is `:degraded` |
|
|
110
110
|
| `ok` | 200 | otherwise |
|
|
111
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
|
+
|
|
112
121
|
StandardCircuit's own word `critical` is translated to `unavailable`, so both
|
|
113
|
-
tiers speak one vocabulary. `circuits` is omitted when StandardCircuit isn't
|
|
122
|
+
tiers speak one vocabulary. The aggregate body never says `"critical"`. `circuits` is omitted when StandardCircuit isn't
|
|
114
123
|
loaded (or `aggregate_circuits = false`); if `health_report` raises (circuit
|
|
115
124
|
store down) the tier degrades and reports `circuits_error: { error_class,
|
|
116
125
|
error_code }` instead of 500ing. Check rows are redacted exactly like `/ready`,
|
|
117
126
|
with the same `detail_token` break-glass. `register_aggregate_check` checks
|
|
118
127
|
run **only** here — never on `/ready` — and default to `critical: false`.
|
|
119
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. Per-check timeouts are not reported; they surface as
|
|
134
|
+
`standard_health.check.timed_out`. `/ready` does not do this: its failures reach Sentry through the
|
|
135
|
+
transition-gated `ready.evaluated` notifier.
|
|
136
|
+
|
|
120
137
|
With `aggregate_endpoint` off (the default) the engine's root route carries a
|
|
121
138
|
per-request constraint that never matches, so a bare `/health` cascades to your
|
|
122
139
|
own route exactly as before. Once on, the engine answers `/health` regardless
|
|
@@ -402,11 +419,13 @@ Since 0.4.1 the response carries a `status` alongside the audit, so a caller
|
|
|
402
419
|
can gate on one field instead of re-implementing the roll-up:
|
|
403
420
|
|
|
404
421
|
```json
|
|
405
|
-
{ "mode": "production", "status": "incomplete", "audit": [ ... ] }
|
|
422
|
+
{ "mode": "production", "status": "incomplete", "audit": [ ... ], "assertions": [ ... ] }
|
|
406
423
|
```
|
|
407
424
|
|
|
408
425
|
`incomplete` means at least one row is a **violation** — `missing`,
|
|
409
|
-
`forbidden`, or `mismatch
|
|
426
|
+
`forbidden`, or `mismatch` — or (since 0.7.0) a registered diagnostics
|
|
427
|
+
assertion reported `error`. `should_set` and an assertion's `warn` are
|
|
428
|
+
advisory and never affect it.
|
|
410
429
|
|
|
411
430
|
The level is deliberately not consulted for `mismatch`: a `recommended` var
|
|
412
431
|
declared with an `expected_value:` that does not hold is a failed assertion,
|
|
@@ -417,6 +436,56 @@ not advice.
|
|
|
417
436
|
free — which is why they joined this roll-up rather than getting a verdict of
|
|
418
437
|
their own.
|
|
419
438
|
|
|
439
|
+
### Runtime assertions: `register_diagnostics_assertion` (0.7.0)
|
|
440
|
+
|
|
441
|
+
Env presence can't prove that a setting took effect. For example, the live
|
|
442
|
+
`statement_timeout` may still be `0`, a rate limiter may have fallen back to
|
|
443
|
+
Solid Cache, or a pinned certificate may be about to expire. Register those
|
|
444
|
+
checks as assertions and the engine renders them on `/diagnostics/env` under
|
|
445
|
+
`assertions:`, behind the same `diagnostics_basic_auth` gate:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
StandardHealth.configure do |c|
|
|
449
|
+
c.register_diagnostics_assertion(:statement_timeout) do
|
|
450
|
+
value = ActiveRecord::Base.connection.select_value("SHOW statement_timeout").to_s
|
|
451
|
+
{ status: value.strip == "0" ? :warn : :ok, value: value, expected: "non-zero" }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Any callable works; reference app constants inside it, not at boot.
|
|
455
|
+
c.register_diagnostics_assertion(:rate_limit_store, -> { RateLimitAssertion.call })
|
|
456
|
+
end
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{ "mode": "production", "status": "ok", "audit": [ ... ],
|
|
461
|
+
"assertions": [ { "name": "statement_timeout", "status": "ok", "value": "15s", "expected": "non-zero" } ] }
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- The callable takes no arguments and returns a Hash with `status:` set to
|
|
465
|
+
`:ok`, `:warn` or `:error`. Other keys are rendered as-is. The gem sets
|
|
466
|
+
`name:`, and the callable can't override it.
|
|
467
|
+
- An assertion that raises becomes `{ status: "error", error_class:, error: }`
|
|
468
|
+
and is reported to `Rails.error` as handled. The tier is authenticated, so
|
|
469
|
+
the message is shown. A non-Hash result or an unknown status also becomes an
|
|
470
|
+
`:error` row. One broken assertion never takes the endpoint down.
|
|
471
|
+
- An `:error` row makes the top-level `status` `incomplete`. `:warn` does not.
|
|
472
|
+
The endpoint still returns 200.
|
|
473
|
+
- Assertions run per request, and only on this tier, never on `/alive`,
|
|
474
|
+
`/ready` or the aggregate. They have no timeout, so keep them cheap.
|
|
475
|
+
- Re-registering a name replaces it, so it is safe inside `to_prepare`.
|
|
476
|
+
`reset_diagnostics_assertions!` clears them in specs.
|
|
477
|
+
- A host that keeps its own diagnostics controller can render
|
|
478
|
+
`StandardHealth::DiagnosticsAssertions.run` itself.
|
|
479
|
+
|
|
480
|
+
**Replace your host code with it.** sidekick-web's
|
|
481
|
+
`app/controllers/health_diagnostics_controller.rb` exists to add three
|
|
482
|
+
assertions (`statement_timeout`, `rate_limit_store`, `attestation_roots`) to
|
|
483
|
+
the env audit. Move each into a `register_diagnostics_assertion`, delete the
|
|
484
|
+
controller, and delete its route
|
|
485
|
+
(`get "/health/diagnostics/env", to: "health_diagnostics#env"` ahead of the
|
|
486
|
+
engine mount). Its `rescue => e; { status: :error, error: e.message }`
|
|
487
|
+
wrappers go too, because the gem does that now.
|
|
488
|
+
|
|
420
489
|
### Surfacing the audit on a health tier
|
|
421
490
|
|
|
422
491
|
`/diagnostics/env` is authed and polled by nobody, so config drift declared in
|
|
@@ -590,6 +659,17 @@ c.diagnostics_basic_auth = {
|
|
|
590
659
|
nothing.
|
|
591
660
|
- Off by default; independent of `diagnostics_parent_controller` (if you set
|
|
592
661
|
both, the parent's callbacks run first).
|
|
662
|
+
- The request-time gate is the `StandardHealth::DiagnosticsAuthentication`
|
|
663
|
+
concern, which the engine includes into its own `DiagnosticsController`.
|
|
664
|
+
**Since 0.7.0 it is public, semver-stable API:** `include
|
|
665
|
+
StandardHealth::DiagnosticsAuthentication` into any `ActionController::API`
|
|
666
|
+
or `::Base` controller to put a host endpoint behind the same fail-closed
|
|
667
|
+
gate. The contract is the include, the one `before_action` it installs, and
|
|
668
|
+
the 401 challenge / 403 refusal behaviour. Its private method names are not
|
|
669
|
+
part of it. It is a no-op until `diagnostics_basic_auth` is set. If your
|
|
670
|
+
controller only exists to add runtime assertions, use
|
|
671
|
+
[`register_diagnostics_assertion`](#runtime-assertions-register_diagnostics_assertion-070)
|
|
672
|
+
instead and delete the controller.
|
|
593
673
|
|
|
594
674
|
**Replace your host code with it.** Delete
|
|
595
675
|
`app/controllers/standard_health_host_controller.rb` and the
|
|
@@ -7,6 +7,15 @@ module StandardHealth
|
|
|
7
7
|
# `DiagnosticsController` only — never `HealthController` — so /alive and
|
|
8
8
|
# /ready stay anonymous for orchestrator probes.
|
|
9
9
|
#
|
|
10
|
+
# PUBLIC API (since 0.7.0, semver-stable): a host that serves its own
|
|
11
|
+
# diagnostics endpoint can `include StandardHealth::DiagnosticsAuthentication`
|
|
12
|
+
# into any ActionController::API or ::Base controller to put it behind the
|
|
13
|
+
# same fail-closed gate. The contract is the include, the `before_action`
|
|
14
|
+
# it installs, and the 401 challenge / 403 refusal behaviour below; the
|
|
15
|
+
# private method names are not part of it. Prefer
|
|
16
|
+
# `register_diagnostics_assertion` when all the host controller adds is
|
|
17
|
+
# assertions.
|
|
18
|
+
#
|
|
10
19
|
# A NO-OP when `diagnostics_basic_auth` is unset, so hosts that gate
|
|
11
20
|
# diagnostics through `diagnostics_parent_controller` see no change. Both
|
|
12
21
|
# can be used together; the parent's callbacks run first.
|
|
@@ -27,11 +27,13 @@ module StandardHealth
|
|
|
27
27
|
root = defined?(Rails) ? Rails.root : nil
|
|
28
28
|
|
|
29
29
|
audit = spec ? spec.audit(ENV.to_h, mode: mode, root: root) : []
|
|
30
|
+
assertions = DiagnosticsAssertions.run
|
|
30
31
|
|
|
31
32
|
render json: {
|
|
32
33
|
mode: mode,
|
|
33
|
-
status: audit_status(audit),
|
|
34
|
+
status: audit_status(audit, assertions),
|
|
34
35
|
audit: audit,
|
|
36
|
+
assertions: assertions,
|
|
35
37
|
generated_at: Time.now.utc.iso8601
|
|
36
38
|
}
|
|
37
39
|
end
|
|
@@ -53,13 +55,16 @@ module StandardHealth
|
|
|
53
55
|
# advice. The advisory status stays `:should_set`, which is not a
|
|
54
56
|
# violation.
|
|
55
57
|
#
|
|
58
|
+
# A registered diagnostics assertion (`register_diagnostics_assertion`)
|
|
59
|
+
# that reports `:error` also makes it `:incomplete`; `:warn` does not.
|
|
60
|
+
#
|
|
56
61
|
# The endpoint still returns 200 either way, so nothing that asserts on
|
|
57
62
|
# the status code breaks.
|
|
58
|
-
def audit_status(audit)
|
|
63
|
+
def audit_status(audit, assertions = [])
|
|
59
64
|
violated = Array(audit).any? do |row|
|
|
60
65
|
EnvSpec::VIOLATION_STATUSES.include?(row[:status])
|
|
61
66
|
end
|
|
62
|
-
violated ? :incomplete : :ok
|
|
67
|
+
(violated || DiagnosticsAssertions.failing?(assertions)) ? :incomplete : :ok
|
|
63
68
|
end
|
|
64
69
|
end
|
|
65
70
|
end
|
|
@@ -111,6 +111,12 @@ module StandardHealth
|
|
|
111
111
|
emit_check(row, tier)
|
|
112
112
|
row
|
|
113
113
|
rescue CheckTimeout
|
|
114
|
+
# Deliberately NOT sent to Rails.error (unlike the StandardError branch
|
|
115
|
+
# below). A timeout is our own budget firing on a slow dependency, not a
|
|
116
|
+
# bug in the check: it is already visible as `check.timed_out` and as a
|
|
117
|
+
# failing row, and reporting it per poll would page on latency. The
|
|
118
|
+
# pre-0.6 host aggregate controllers had no per-check timeouts, so
|
|
119
|
+
# nothing reported there is lost.
|
|
114
120
|
emit("standard_health.check.timed_out",
|
|
115
121
|
name: reg.name, critical: reg.critical, timeout_s: timeout, **tier_attrs(tier))
|
|
116
122
|
row = {
|
|
@@ -131,10 +137,38 @@ module StandardHealth
|
|
|
131
137
|
error_class: e.class.name
|
|
132
138
|
}
|
|
133
139
|
emit_check(row, tier)
|
|
140
|
+
report_raised_check(e, reg, tier)
|
|
134
141
|
row
|
|
135
142
|
end
|
|
136
143
|
private_class_method :safe_run
|
|
137
144
|
|
|
145
|
+
# A check that RAISES (rather than returning a :fail row) on a
|
|
146
|
+
# non-readiness tier goes to `Rails.error` as handled. Before 0.6.0 every
|
|
147
|
+
# host's aggregate controller did exactly this; moving the tier into the
|
|
148
|
+
# engine left the exception only in the Logger notifier's line, so a buggy
|
|
149
|
+
# aggregate check stopped reaching the error tracker.
|
|
150
|
+
#
|
|
151
|
+
# Readiness is deliberately excluded: its failures already reach Sentry
|
|
152
|
+
# through the transition-gated `ready.evaluated` notifier, and reporting
|
|
153
|
+
# every raise from a ~6/min/instance probe would be exactly the noise that
|
|
154
|
+
# notifier exists to prevent. Severity mirrors that notifier: a critical
|
|
155
|
+
# check is an :error (it makes the tier :unavailable), otherwise :warning.
|
|
156
|
+
def self.report_raised_check(error, reg, tier)
|
|
157
|
+
return if tier == :ready
|
|
158
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
|
|
159
|
+
|
|
160
|
+
::Rails.error.report(
|
|
161
|
+
error,
|
|
162
|
+
handled: true,
|
|
163
|
+
severity: reg.critical ? :error : :warning,
|
|
164
|
+
context: { health_check: reg.name.to_s, tier: tier.to_s }
|
|
165
|
+
)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
# Never-raise: a broken error subscriber must not 500 the tier.
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
private_class_method :report_raised_check
|
|
171
|
+
|
|
138
172
|
def self.budget_exhausted?(budget, started)
|
|
139
173
|
return false unless budget
|
|
140
174
|
|
|
@@ -220,6 +220,7 @@ module StandardHealth
|
|
|
220
220
|
@env_spec = nil
|
|
221
221
|
@checks = []
|
|
222
222
|
@aggregate_checks = []
|
|
223
|
+
@diagnostics_assertions = []
|
|
223
224
|
@aggregate_endpoint = false
|
|
224
225
|
@aggregate_readiness_checks = true
|
|
225
226
|
@aggregate_circuits = true
|
|
@@ -368,6 +369,51 @@ module StandardHealth
|
|
|
368
369
|
registration
|
|
369
370
|
end
|
|
370
371
|
|
|
372
|
+
DiagnosticsAssertion = Struct.new(:name, :callable)
|
|
373
|
+
|
|
374
|
+
# Adds a runtime assertion to the doctor tier (`GET /diagnostics/env`),
|
|
375
|
+
# rendered under `assertions:` next to the env audit. For facts env
|
|
376
|
+
# presence can't prove: the live `statement_timeout`, the cache store a
|
|
377
|
+
# rate limiter actually resolved to, a pinned root's expiry. Replaces a
|
|
378
|
+
# host diagnostics controller that exists only to add these.
|
|
379
|
+
#
|
|
380
|
+
# c.register_diagnostics_assertion(:statement_timeout) do
|
|
381
|
+
# value = ActiveRecord::Base.connection.select_value("SHOW statement_timeout").to_s
|
|
382
|
+
# { status: value == "0" ? :warn : :ok, value: value, expected: "non-zero" }
|
|
383
|
+
# end
|
|
384
|
+
#
|
|
385
|
+
# The callable takes no arguments and returns a Hash with `status:`
|
|
386
|
+
# (`:ok`, `:warn` or `:error`) plus any detail keys. `name:` is set by
|
|
387
|
+
# the gem. It runs per request, on the authenticated tier only, never on
|
|
388
|
+
# /alive, /ready or the aggregate. A raise, a non-Hash, or an unknown
|
|
389
|
+
# status becomes an `:error` row (a raise is also reported to
|
|
390
|
+
# `Rails.error` as handled). An `:error` row makes the endpoint's
|
|
391
|
+
# `status` `incomplete`; `:warn` does not.
|
|
392
|
+
#
|
|
393
|
+
# Re-registering a name replaces it, so `configure` can safely re-run
|
|
394
|
+
# from `to_prepare` on reload.
|
|
395
|
+
def register_diagnostics_assertion(name, callable = nil, &block)
|
|
396
|
+
callable ||= block
|
|
397
|
+
unless callable.respond_to?(:call)
|
|
398
|
+
raise ArgumentError, "register_diagnostics_assertion(#{name.inspect}) needs a callable or a block"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
assertion = DiagnosticsAssertion.new(name.to_sym, callable)
|
|
402
|
+
@diagnostics_assertions.reject! { |existing| existing.name == assertion.name }
|
|
403
|
+
@diagnostics_assertions << assertion
|
|
404
|
+
assertion
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# @return [Array<DiagnosticsAssertion>] in registration order
|
|
408
|
+
def diagnostics_assertions
|
|
409
|
+
@diagnostics_assertions.dup
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Drop registered diagnostics assertions. Test hygiene.
|
|
413
|
+
def reset_diagnostics_assertions!
|
|
414
|
+
@diagnostics_assertions = []
|
|
415
|
+
end
|
|
416
|
+
|
|
371
417
|
# @return [Array<Registration>] aggregate-only checks
|
|
372
418
|
def aggregate_checks
|
|
373
419
|
@aggregate_checks.dup
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Runs the host's `register_diagnostics_assertion` callables for the doctor
|
|
5
|
+
# tier (`/diagnostics/env`, behind `diagnostics_basic_auth`).
|
|
6
|
+
#
|
|
7
|
+
# An assertion is a runtime fact that env presence cannot express: the live
|
|
8
|
+
# `statement_timeout`, which cache store a limiter actually resolved to, how
|
|
9
|
+
# close a pinned certificate is to expiry. Each callable returns a Hash with
|
|
10
|
+
# at least `status:` (`:ok`, `:warn` or `:error`); any other keys (`value:`,
|
|
11
|
+
# `expected:`, ...) are rendered as-is. The gem sets `name:`.
|
|
12
|
+
#
|
|
13
|
+
# Public: a host with its own diagnostics controller can render
|
|
14
|
+
# `StandardHealth::DiagnosticsAssertions.run` itself.
|
|
15
|
+
module DiagnosticsAssertions
|
|
16
|
+
STATUSES = %i[ok warn error].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @return [Array<Hash>] one row per registered assertion, in registration
|
|
21
|
+
# order. Never raises: an assertion that raises, returns a non-Hash, or
|
|
22
|
+
# returns an unknown status becomes an `:error` row.
|
|
23
|
+
def run(config = StandardHealth.config)
|
|
24
|
+
config.diagnostics_assertions.map { |assertion| evaluate(assertion) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when any row is `:error`. `:warn` is advisory.
|
|
28
|
+
def failing?(rows)
|
|
29
|
+
Array(rows).any? { |row| row[:status] == :error }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def evaluate(assertion)
|
|
33
|
+
result = assertion.callable.call
|
|
34
|
+
return invalid(assertion.name, "returned #{result.class}, expected a Hash") unless result.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
row = result.to_h.transform_keys(&:to_sym)
|
|
37
|
+
status = row[:status].respond_to?(:to_sym) ? row[:status].to_sym : nil
|
|
38
|
+
return invalid(assertion.name, "returned status #{row[:status].inspect}, expected one of #{STATUSES.inspect}") unless STATUSES.include?(status)
|
|
39
|
+
|
|
40
|
+
{ name: assertion.name }.merge(row.except(:name)).merge(status: status)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
report(e, assertion.name)
|
|
43
|
+
# The doctor tier is authenticated, so the message is shown (the /ready
|
|
44
|
+
# redaction rationale does not apply); the class is kept separately
|
|
45
|
+
# for grouping.
|
|
46
|
+
{ name: assertion.name, status: :error, error_class: e.class.name, error: e.message }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def invalid(name, detail)
|
|
50
|
+
{ name: name, status: :error, error: "diagnostics assertion #{name} #{detail}" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report(error, name)
|
|
54
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
|
|
55
|
+
|
|
56
|
+
::Rails.error.report(error, handled: true, severity: :warning,
|
|
57
|
+
context: { diagnostics_assertion: name }, source: "standard_health")
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/standard_health.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_health
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- lib/standard_health/checks/solid_cache.rb
|
|
55
55
|
- lib/standard_health/checks/solid_queue.rb
|
|
56
56
|
- lib/standard_health/configuration.rb
|
|
57
|
+
- lib/standard_health/diagnostics_assertions.rb
|
|
57
58
|
- lib/standard_health/diagnostics_basic_auth.rb
|
|
58
59
|
- lib/standard_health/engine.rb
|
|
59
60
|
- lib/standard_health/env_spec.rb
|