standard_health 0.7.0 → 0.8.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: 4b9de071226844b0a4c1304b7b81d21ba11b963e3c4ad082aebd05a82a24fa92
4
- data.tar.gz: dd4bab92dade63959f66db19ee8467c753e39a1f77dd12706adff721460bc14e
3
+ metadata.gz: 3cf58624de0179c4903a4159e227baa82079b2a78e97679122a429b186783918
4
+ data.tar.gz: 33963ed5e02ec9794a3133e4fbf3444cb443bc821045ec8bde6ecfb4b575f0a0
5
5
  SHA512:
6
- metadata.gz: 1b6431be6025cdfb36f4059d1d295464c43d0daf927155cd53953c34cbac683ce04fbd810acc7217765be574fa4ea85eb59a8186d9326c617225705efdf878ef
7
- data.tar.gz: 623ea6e63b5f4e11c281ad64d08e60ec10e858b960adcc6758ffa98970cc5e92cd4c0e64406d679aad2d44e387800de88f2bef521f5513899188ae81e811cba9
6
+ metadata.gz: 846b8334841aeb56c429f8a767f76c1e3e22e849da7f5de261370895cb3d7e735e2ee1fe3a72508637474f0338b34c05848bdda32e359bee783857ca3dd85e79
7
+ data.tar.gz: b6fefd789b745ec5a20819c46b89d610b226283b09024db1426ba5aacebee6838941f9d9c423b152bc55df2ab09bd341906cc5b15de6e2a7c0c59a5891d291f6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-22
11
+
12
+ ### Changed
13
+
14
+ - **Re-registering a check replaces it.** `register_check` and
15
+ `register_aggregate_check` replace an existing registration of the same name
16
+ in place (last writer wins) instead of appending a second one, matching
17
+ `register_diagnostics_assertion`. A `configure` block that runs twice, e.g.
18
+ from `to_prepare` on reload, no longer runs every check twice.
19
+ - **`env_spec` aliases are checked at boot.** Assigning `c.env_spec = …` calls
20
+ the new `EnvSpec#validate!`, which raises `EnvSpec::UnknownModeAlias` naming
21
+ every `in:` alias that has no `mode_alias`. Before, an undeclared alias raised
22
+ only on the first audit, which was a production `/diagnostics/env` request
23
+ (it 500'd one app's doctor tier once). All five estate apps declare every
24
+ alias they use, so none of them is affected.
25
+
26
+ ## [0.7.1] - 2026-09-25
27
+
28
+ ### Fixed
29
+
30
+ - **Skipped checks no longer degrade the aggregate.** A check that returns
31
+ `status: :skipped` itself means "not applicable here" (the feature it
32
+ covers is not configured or not enforced). It is now **neutral** in the
33
+ roll-up on every tier (`/ready` and the aggregate `/health`): it neither
34
+ degrades nor fails the status. Before, it counted like a failing
35
+ non-critical check, which held sidekick-web's `/health` at `degraded`
36
+ permanently while attestation was unconfigured. The row still renders in
37
+ `checks[]` with `"status": "skipped"`, and `check.completed` still fires,
38
+ so it stays visible. It is no longer listed in the `failed[]` / `failures`
39
+ of `ready.evaluated` / `aggregate.evaluated`, so the Logger and Sentry
40
+ notifiers don't name it as failing when something else degrades.
41
+ - **A critical check that reports `:skipped` is neutral too**, on purpose.
42
+ "Not applicable" says nothing about whether the instance can serve, so it
43
+ must not pull it out of rotation. Return `:fail` for a state that should
44
+ page.
45
+ - Real failures roll up exactly as before: a failing critical check is
46
+ `unavailable` (503), a failing non-critical one `degraded`, including
47
+ alongside a skipped check.
48
+ - **Budget skips are unchanged.** A check the `total_check_budget` never
49
+ reached was not performed, which is not the same as healthy. It still
50
+ floors the roll-up at `degraded` (never `unavailable`) and is still listed
51
+ in `failed[]`. Its row now carries `budget_exhausted: true`, which is how
52
+ the gem tells the two kinds of skip apart.
53
+ - `/diagnostics/env` is unaffected. Its roll-up is over env-audit rows and
54
+ assertions (`:ok` / `:warn` / `:error`), which have no skipped state.
55
+
56
+ ### Changed
57
+
58
+ - **Requires Rails 8.1** (`rails >= 8.1`, was `>= 8.0`). Every consumer app
59
+ runs 8.1; 8.0 was never exercised in CI. Ruby 3.4 remains supported.
60
+
61
+ ### Upgrade notes (0.7.0 → 0.7.1)
62
+
63
+ - **No required host change.** An app whose aggregate was `degraded` only
64
+ because of a self-reported skip now reports `ok`. Monitors or smoke tests
65
+ that expect `degraded` in that state need updating (sidekick-web).
66
+
10
67
  ## [0.7.0] - 2026-09-24
11
68
 
12
69
  The Phase 4 release. 0.6 deprecated nothing, so nothing is removed. It adds
data/README.md CHANGED
@@ -106,7 +106,7 @@ end
106
106
  | Status | HTTP | When |
107
107
  |---|---|---|
108
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` |
109
+ | `degraded` | 200 | a non-critical check failed, a check was skipped by the total budget, or the circuit roll-up is `:degraded` |
110
110
  | `ok` | 200 | otherwise |
111
111
 
112
112
  **The aggregate tier re-runs your readiness checks by default.** With
@@ -362,7 +362,7 @@ end
362
362
 
363
363
  - `nil` (omitted) — entry always applies
364
364
  - `Array<String>` — literal mode list (existing behaviour)
365
- - `Symbol` — resolved against `mode_alias` at audit time. An undeclared Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
365
+ - `Symbol` — resolved against `mode_alias`. Assigning the spec (`c.env_spec = …`) checks every alias, so an undeclared Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias` at boot, naming each unknown alias, rather than on the first `/diagnostics/env` request.
366
366
 
367
367
  ### `description:` and `consumed_by:`
368
368
 
@@ -522,6 +522,10 @@ end
522
522
 
523
523
  `with_timing` captures `latency_ms` on success and converts any `StandardError` into `{ status: :fail, error: <message> }`.
524
524
 
525
+ Registering a name that is already registered replaces that check in place (the
526
+ same for `register_aggregate_check`), so a `configure` block that runs twice,
527
+ e.g. from `to_prepare` on reload, doesn't run any check twice.
528
+
525
529
  **Per-registration options (0.6.0).** Keywords other than `critical:` and
526
530
  `timeout:` are forwarded to the check's constructor, and validated against its
527
531
  signature at registration — a typo fails at boot, not on every probe:
@@ -806,9 +810,25 @@ go in `extra:`.
806
810
 
807
811
  The orchestrator should pull the instance out of rotation only on 503; degraded means "still serving, page someone."
808
812
 
809
- `skipped` also exists, for a check the total budget never reached (see
810
- Timeouts). A skip floors the roll-up at `degraded` and **never** produces
811
- `unavailable`, even for a critical check.
813
+ `skipped` also exists, in two flavours that roll up differently:
814
+
815
+ - **A check that returns `status: :skipped` itself** — "not applicable here"
816
+ (the feature it covers isn't configured or enforced). This is **neutral**
817
+ (since 0.7.1): it neither degrades nor fails the roll-up, whether the check
818
+ is critical or not, and it is left out of the evaluation events' `failed[]`.
819
+ It still renders in `checks[]` with `"status": "skipped"` and still emits
820
+ `check.completed`, so it stays visible. A critical check is neutral too on
821
+ purpose: "not applicable" says nothing about whether the instance can serve,
822
+ so it must not pull it out of rotation — and it must not mark it healthy
823
+ *because of* that check either; it simply doesn't count. If the state should
824
+ page, return `:fail`.
825
+ - **A check the total budget never reached** (see Timeouts) — rendered with
826
+ `"budget_exhausted": true`. That check was not performed, which is not the
827
+ same as healthy, so it floors the roll-up at `degraded` and **never**
828
+ produces `unavailable`, even for a critical check.
829
+
830
+ A real failure still rolls up exactly as before alongside any skip: a failing
831
+ critical check is `unavailable`, a failing non-critical one `degraded`.
812
832
 
813
833
  ## Failure detail is redacted
814
834
 
@@ -934,8 +954,8 @@ slow-but-fine starts reporting `:fail`, and for a critical check that pulls
934
954
  the instance out of rotation. Pick values from observed `latency_ms` rather
935
955
  than intuition; that is what the `check.completed` events are for.
936
956
 
937
- Checks the total budget never reaches report `:skipped`, never silently `:ok`.
938
- A skip alone floors the roll-up at `degraded` — otherwise a slow *non-critical*
957
+ Checks the total budget never reaches report `:skipped` (with
958
+ `budget_exhausted: true`), never silently `:ok`. A budget skip alone floors the roll-up at `degraded` — otherwise a slow *non-critical*
939
959
  check could exhaust the budget, leave the database check unrun, and pull a
940
960
  healthy instance out of rotation.
941
961
 
@@ -18,8 +18,10 @@ module StandardHealth
18
18
  #
19
19
  # Status roll-up, in the estate's readiness vocabulary:
20
20
  # :unavailable (503) — a critical check failed, or a :critical circuit is RED
21
- # :degraded (200) — a non-critical check failed / was skipped, or the
22
- # circuit roll-up is :degraded
21
+ # :degraded (200) — a non-critical check failed, a check was skipped
22
+ # by the total budget, or the circuit roll-up is
23
+ # :degraded. A check that reports :skipped itself
24
+ # (not applicable) is neutral — see Aggregator.
23
25
  # :ok (200) — otherwise
24
26
  #
25
27
  # StandardCircuit's own word for its worst state is `:critical`; it is
@@ -92,7 +94,7 @@ module StandardHealth
92
94
  # notifier logs this one (so redacted messages still reach logs); Sentry
93
95
  # and Metrics ignore it.
94
96
  def emit_evaluation(report, rows, circuits, started)
95
- failing = rows.reject { |r| r[:status] == :ok }
97
+ failing = Aggregator.failing_rows(rows)
96
98
  payload = {
97
99
  status: report[:status],
98
100
  duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
@@ -14,11 +14,23 @@ module StandardHealth
14
14
  # Runs all registered checks and rolls them up into a single status.
15
15
  #
16
16
  # Status semantics:
17
- # :ok — every check returned :ok
17
+ # :ok — every check returned :ok, or reported :skipped itself
18
18
  # :degraded — at least one non-critical check failed, OR a check was
19
19
  # skipped because the total budget ran out
20
20
  # :unavailable — at least one critical check failed
21
21
  #
22
+ # Two kinds of :skipped row exist, and they roll up differently (0.7.1):
23
+ #
24
+ # * A check that RETURNS `status: :skipped` is saying "not applicable
25
+ # here" (the feature it covers is not configured or not enforced). That
26
+ # is NEUTRAL — it neither degrades nor fails the tier, whatever its
27
+ # `critical:` flag, and it is not listed in an evaluation event's
28
+ # `failed[]`. The row still renders with status "skipped", and
29
+ # `check.completed` still fires, so it stays visible.
30
+ # * A check the total budget never REACHED (`budget_exhausted: true` on
31
+ # the row) was not performed at all, and an unperformed check is not a
32
+ # healthy one. It floors the tier at :degraded — never :unavailable.
33
+ #
22
34
  # The aggregator never raises. Each check is invoked through `safe_run`
23
35
  # which catches `StandardError` so a buggy custom check cannot take down
24
36
  # /ready. Instrumentation is held to the same bar — every emit is wrapped
@@ -55,7 +67,7 @@ module StandardHealth
55
67
  duration_ms = ((monotonic - started) * 1000).round
56
68
  status = overall_status(check_rows)
57
69
 
58
- failing = check_rows.reject { |r| r[:status] == :ok }
70
+ failing = failing_rows(check_rows)
59
71
 
60
72
  return { status: status, checks: check_rows, generated_at: now.iso8601 } unless tier == :ready
61
73
 
@@ -81,6 +93,21 @@ module StandardHealth
81
93
  }
82
94
  end
83
95
 
96
+ # The rows an evaluation event reports as failing: everything that is not
97
+ # :ok, except a check that reported :skipped itself (not applicable, so
98
+ # not a failure). A budget skip IS listed — it is the only place a skip
99
+ # shows up in the transition-gated event. @api private (also used by
100
+ # AggregateReport).
101
+ def self.failing_rows(rows)
102
+ rows.reject { |r| r[:status] == :ok || neutral_skip?(r) }
103
+ end
104
+
105
+ # A row the check itself reported as :skipped — "not applicable here".
106
+ # Neutral in the roll-up. A budget skip is not neutral. @api private
107
+ def self.neutral_skip?(row)
108
+ row[:status] == :skipped && !row[:budget_exhausted]
109
+ end
110
+
84
111
  # Per-failure detail for an evaluation event. Public (but @api private) so
85
112
  # the aggregate tier's evaluation event carries the same shape.
86
113
  # @api private
@@ -177,12 +204,15 @@ module StandardHealth
177
204
  private_class_method :budget_exhausted?
178
205
 
179
206
  # A check the total budget never reached. `:skipped`, never silently
180
- # `:ok` — an unperformed check is not a healthy one.
207
+ # `:ok` — an unperformed check is not a healthy one. `budget_exhausted`
208
+ # is what separates it from a check that reported :skipped itself (which
209
+ # is neutral); see overall_status.
181
210
  def self.skipped_row(reg, budget)
182
211
  {
183
212
  name: reg.name,
184
213
  critical: reg.critical,
185
214
  status: :skipped,
215
+ budget_exhausted: true,
186
216
  error: "skipped — total check budget of #{budget}s exhausted"
187
217
  }
188
218
  end
@@ -191,10 +221,16 @@ module StandardHealth
191
221
  def self.overall_status(rows)
192
222
  return :ok if rows.empty?
193
223
 
194
- failures = rows.reject { |r| r[:status] == :ok }
224
+ # A check that reported :skipped itself is not applicable here, so it is
225
+ # NEUTRAL: it must not degrade the tier (sidekick-web's non-critical
226
+ # attestation check held /health at "degraded" forever while attestation
227
+ # was simply not configured), and a critical one must not fail it
228
+ # either — "not applicable" says nothing about whether the app can
229
+ # serve. It stays in checks[] as "skipped" for visibility.
230
+ failures = failing_rows(rows)
195
231
  return :ok if failures.empty?
196
232
 
197
- # A SKIP MUST NEVER PRODUCE :unavailable, even for a critical check.
233
+ # A BUDGET SKIP MUST NEVER PRODUCE :unavailable, even for a critical check.
198
234
  # Otherwise a slow *non-critical* check could exhaust the budget, leave
199
235
  # the database check unrun, and pull a perfectly healthy instance out of
200
236
  # rotation — a self-inflicted outage caused by the safety mechanism.
@@ -11,6 +11,16 @@ module StandardHealth
11
11
  #
12
12
  # { status: :fail, error: "connection refused" }
13
13
  #
14
+ # or, when the check does not apply to this deployment (the feature it
15
+ # covers is not configured or not enforced):
16
+ #
17
+ # { status: :skipped }
18
+ #
19
+ # A self-reported :skipped is NEUTRAL in the roll-up (0.7.1): it never
20
+ # degrades or fails the tier, even for a critical check, but it still
21
+ # renders in `checks[]` as "skipped". Return :fail, not :skipped, for
22
+ # anything that should page.
23
+ #
14
24
  # The `with_timing` helper wraps a block, captures latency, and converts
15
25
  # any unhandled `StandardError` into a `:fail` row so subclasses don't
16
26
  # have to repeat the pattern.
@@ -86,7 +86,16 @@ module StandardHealth
86
86
  # An optional `StandardHealth::EnvSpec` instance describing required and
87
87
  # recommended environment variables for the host app. Audited via the
88
88
  # /diagnostics/env endpoint.
89
- attr_accessor :env_spec
89
+ attr_reader :env_spec
90
+
91
+ # Validates the spec as it is assigned (`EnvSpec#validate!`), so an
92
+ # `in:` alias with no `mode_alias` fails the initializer at boot rather
93
+ # than raising on the first audit, which is a /diagnostics/env request
94
+ # in production.
95
+ def env_spec=(spec)
96
+ spec.validate! if spec.respond_to?(:validate!)
97
+ @env_spec = spec
98
+ end
90
99
 
91
100
  # --- Instrumentation -----------------------------------------------
92
101
  #
@@ -192,7 +201,9 @@ module StandardHealth
192
201
  attr_accessor :default_check_timeout
193
202
 
194
203
  # Budget across all checks, evaluated BEFORE each check starts. Checks not
195
- # reached are reported :skipped, and a skip alone floors the roll-up at
204
+ # reached are reported :skipped with `budget_exhausted: true` (unlike a
205
+ # check that reports :skipped itself, which is neutral), and such a skip
206
+ # alone floors the roll-up at
196
207
  # :degraded — never :unavailable. Otherwise a slow NON-critical check could
197
208
  # exhaust the budget, leave a critical check unrun, and pull a healthy app
198
209
  # out of rotation. nil = no budget.
@@ -268,13 +279,16 @@ module StandardHealth
268
279
  # Validated against the constructor's signature HERE, so a typo fails
269
280
  # at boot instead of turning into an ArgumentError on every probe (which
270
281
  # the aggregator would dutifully report as a failing check forever).
282
+ #
283
+ # Re-registering a name replaces that registration in place (last writer
284
+ # wins), so `configure` can safely re-run, e.g. from `to_prepare` on
285
+ # reload, without running the check twice.
271
286
  def register_check(name, klass, critical: false, timeout: nil, **options)
272
287
  validate_check_options!(klass, options)
273
288
  registration = Registration.new(
274
289
  name: name.to_sym, klass: klass, critical: critical, timeout: timeout, options: options
275
290
  )
276
- @checks << registration
277
- registration
291
+ upsert_registration(@checks, registration)
278
292
  end
279
293
 
280
294
  # The check set every consumer app registers by hand. Each entry: default
@@ -360,13 +374,14 @@ module StandardHealth
360
374
  #
361
375
  # c.register_aggregate_check :solid_cable, StandardHealth::Checks::SolidCable
362
376
  # c.register_aggregate_check :attestation_roots, "AttestationRootsCheck"
377
+ #
378
+ # Re-registering a name replaces it in place, as for #register_check.
363
379
  def register_aggregate_check(name, klass, critical: false, timeout: nil, **options)
364
380
  validate_check_options!(klass, options)
365
381
  registration = Registration.new(
366
382
  name: name.to_sym, klass: klass, critical: critical, timeout: timeout, options: options
367
383
  )
368
- @aggregate_checks << registration
369
- registration
384
+ upsert_registration(@aggregate_checks, registration)
370
385
  end
371
386
 
372
387
  DiagnosticsAssertion = Struct.new(:name, :callable)
@@ -438,6 +453,13 @@ module StandardHealth
438
453
 
439
454
  private
440
455
 
456
+ # Replaces the registration with the same name in place, or appends.
457
+ def upsert_registration(registry, registration)
458
+ index = registry.index { |existing| existing.name == registration.name }
459
+ index ? registry[index] = registration : registry << registration
460
+ registration
461
+ end
462
+
441
463
  # Rejects option keys the check's constructor cannot accept. Only
442
464
  # inspects the signature — never instantiates — so a check with side
443
465
  # effects in `initialize` is not run at boot. A constructor taking
@@ -115,6 +115,21 @@ module StandardHealth
115
115
  @mode_aliases[name.to_sym] = Array(modes).map(&:to_s)
116
116
  end
117
117
 
118
+ # Resolves every `in:` alias now. `Configuration#env_spec=` calls this,
119
+ # so an undeclared or misspelt alias fails at boot instead of raising on
120
+ # the first audit (a /diagnostics/env request, or an EnvSpecAudit row).
121
+ #
122
+ # @raise [UnknownModeAlias] naming every unknown alias
123
+ # @return [self]
124
+ def validate!
125
+ unknown = entries.filter_map { |entry| entry.modes if alias_unknown?(entry.modes) }.uniq
126
+ return self if unknown.empty?
127
+
128
+ raise UnknownModeAlias,
129
+ "Unknown mode alias#{"es" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")} " \
130
+ "(declare it with mode_alias)"
131
+ end
132
+
118
133
  # Group subsequent declarations under a label. Pure metadata —
119
134
  # propagated to audit rows as `group:` and otherwise inert. Nested
120
135
  # `group` blocks are supported; the innermost label is the one that
@@ -253,6 +268,10 @@ module StandardHealth
253
268
  modes.include?(mode_str)
254
269
  end
255
270
 
271
+ def alias_unknown?(modes)
272
+ modes.is_a?(Symbol) && !@mode_aliases.key?(modes)
273
+ end
274
+
256
275
  def resolve_modes(modes)
257
276
  return nil if modes.nil?
258
277
  if modes.is_a?(Symbol)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StandardHealth
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  description: StandardHealth is a mountable Rails engine providing /alive, /ready,
27
27
  and /diagnostics/env endpoints, with a configuration block for registering custom
28
28
  checks and a DSL for declaring required and recommended environment variables.
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 4.0.3
91
+ rubygems_version: 4.0.10
92
92
  specification_version: 4
93
93
  summary: A drop-in health check and environment-spec engine for Rails 8 host apps.
94
94
  test_files: []