@checkstack/healthcheck-backend 1.16.0 → 1.18.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/package.json +30 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +87 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +103 -19
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +348 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/status-page/widgets.ts +11 -1
  36. package/src/suspect-lane.test.ts +50 -0
  37. package/src/suspect-lane.ts +61 -0
  38. package/src/system-health-override.test.ts +94 -0
  39. package/src/system-health-override.ts +93 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,404 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8aae4e2: Count fanned-out environment slices in the dashboard's "X of Y checks failing".
8
+
9
+ The dashboard problem card counted CHECKS, so a system with a single check that
10
+ fans out to three environments showed "Unhealthy 1 of 1 checks failing" even
11
+ when only one of the three environments was failing. It now counts (check ×
12
+ environment) slices: that system reads "1 of 3 checks failing", and a system
13
+ with a three-environment check plus a single-environment check with one
14
+ environment failing reads "1 of 4 checks failing". An env-less check counts as a
15
+ single slice, so a system with no environments reads exactly as before.
16
+
17
+ The per-check status DTO (`SystemCheckStatus`, returned by
18
+ `getSystemHealthStatus` / `getBulkSystemHealthStatus` /
19
+ `getBulkSystemHealthMatrix`) gains two fields: `sliceCount` (environment slices
20
+ this check currently fans out to, always >= 1) and `failingSliceCount` (how many
21
+ of those slices are non-healthy). `deriveHealthcheckSignals` sums them across
22
+ checks for the honest numerator/denominator.
23
+
24
+ - d0eddc9: Rework health-check scheduling to one recurring job per
25
+ `(configuration, system, environment)` slice and add a slow-check bulkhead so a
26
+ slow or unreachable check can no longer starve the healthy ones.
27
+
28
+ Previously a single recurring job per `(configuration, system)` fanned out over
29
+ every environment sequentially inside one tick, so the job held a concurrency
30
+ slot for the sum of all its environments, and a slow environment stalled its
31
+ siblings. Now each environment slice is its own recurring job that holds a slot
32
+ only for its own probe. A convergence reconciler (k8s-controller style) derives
33
+ the desired per-env job set from Postgres + catalog membership and converges the
34
+ queue toward it (schedule missing, cancel orphans, reschedule interval changes),
35
+ so it is self-healing across pods and stays correct as catalog membership
36
+ changes. It runs at boot, and system-scoped after an assignment or GitOps
37
+ change. `run_now` enqueues one one-off job per effective environment.
38
+
39
+ The system rollup (the bare `<systemId>` health entity every badge, SLO rule and
40
+ dependency map reads) is recomputed by an event-driven, debounced consumer that
41
+ subscribes to per-environment health changes and recomputes once per system per
42
+ window, instead of inline on every tick. Notifications stay owned by the
43
+ per-environment runs, so the rollup notification is structurally deduplicated.
44
+
45
+ The bulkhead classifies each slice's recent runs: a slice whose last K runs were
46
+ slow transport failures (held its slot ~the full timeout) is admitted to a
47
+ capped, pod-local lane (single-flight per slice) and probed with a timeout shrunk
48
+ toward its own healthy-latency baseline, or DEFERRED (recording nothing, freeing
49
+ the slot) when the lane is full or a prior run is still in flight. The adaptive
50
+ timeout has four deadlock guardrails: no baseline means no shrink, the baseline
51
+ uses only healthy runs, every Nth suspect run re-probes at the full timeout, and
52
+ an absolute floor. A healthy slice is never gated and always runs at the full
53
+ timeout. A new `checkstack.healthcheck.deferred{reason}` counter records
54
+ bulkhead deferrals.
55
+
56
+ Measured with the scale harness (240 checks, 20% unreachable, concurrency 10, 5s
57
+ timeout, 35s): with the bulkhead off the queue backlog climbs unbounded to 774
58
+ while 60 slow checks pin slots; with it on the backlog stays bounded (drains to
59
+ 0), completions roughly triple (288 → 862), and slot-pinning timeouts drop
60
+ (60 → 12) as 207 suspect runs are deferred.
61
+
62
+ `@checkstack/test-utils-backend` gains a `withTransactionMock` helper that adds a
63
+ `.transaction(cb)` passthrough to a mock database, so tests can exercise code
64
+ that batches reads/writes through `withScopedTransaction`.
65
+
66
+ BREAKING CHANGE: the internal `HealthCheckJobPayload` now requires an
67
+ `environmentId` field and recurring health-check job IDs are per-environment
68
+ (`healthcheck:<config>:<system>[:<env>]`). This is an internal queue contract
69
+ with no external package API surface; on upgrade the reconciler cancels the
70
+ old-format jobs and schedules the per-environment set at boot.
71
+
72
+ - 8aae4e2: Show the last successful run per check (or per check+environment when fanned
73
+ out) in the system overview.
74
+
75
+ Each overview row that is currently degraded or unhealthy now shows when it was
76
+ last healthy (for example "Healthy until 2h ago", or "Never healthy" when it has
77
+ never succeeded), so operators can see at a glance since when a system has been
78
+ degraded or unhealthy without opening the drawer.
79
+
80
+ `getSystemHealthOverview` gains a `lastSuccessfulRunAt` field at both the check
81
+ level (most recent healthy run across all of the check's environments) and per
82
+ environment (`perEnvironment[].lastSuccessfulRunAt`). It is computed with a
83
+ dedicated max-per-environment aggregate query OUTSIDE the bounded sparkline
84
+ window, so it stays accurate even when a check has been failing for far longer
85
+ than the last runs shown in the sparkline.
86
+
87
+ ### Patch Changes
88
+
89
+ - 8aae4e2: Stop sending a duplicate notification when a fanned-out system goes unhealthy.
90
+
91
+ A health check that fans out across environments notified once per environment
92
+ ("... is unhealthy in environment X") AND once more for the system rollup
93
+ ("... is unhealthy") in the same tick, so operators received two notifications
94
+ describing the same outage. The rollup transition is always driven by the very
95
+ environment(s) that already notified, so the rollup notification is now
96
+ suppressed whenever any environment notified this tick. It is still sent as a
97
+ fallback when no environment notified (e.g. every per-env delivery was
98
+ suppressed by policy/maintenance or threw), so a real status change is never
99
+ left entirely unannounced, and a system with no environments is unaffected.
100
+
101
+ Only the redundant user-facing notification is dropped: the rollup state
102
+ transition is still recorded and the `SYSTEM_STATUS_CHANGED` signal is still
103
+ broadcast, so SLO downtime, the dependency graph, the frontend, and automations
104
+ (which subscribe to the per-env and rollup entity changes) are unchanged.
105
+
106
+ - d0eddc9: Cut health-check connection churn and de-cluster the scheduling "thundering
107
+ herd" so per-run durations stop varying wildly for the same check against the
108
+ same target. Grounded in live OpenTelemetry phase histograms: per-run wall time
109
+ was dominated by TCP/TLS connection setup under a self-inflicted burst, not by
110
+ slow targets, CPU, or the database.
111
+
112
+ - **In-memory queue now honors `startDelay` in `scheduleRecurring`.** It was
113
+ silently dropped, so every recurring job (health checks included) fired
114
+ immediately on boot and then on a boot-anchored interval grid - keeping all
115
+ equal-interval checks phase-aligned forever. `scheduleRecurring` now defers the
116
+ first execution by `startDelay` and anchors the recurrence to that first fire,
117
+ matching the queue contract and the BullMQ backend's intent. Jobs scheduled
118
+ without `startDelay` are unchanged (first run is immediate).
119
+ - **The BullMQ queue now honors `startDelay` in `scheduleRecurring` too.** It also
120
+ dropped `startDelay`, and its `every` scheduler captures the grid phase from
121
+ whenever `upsertJobScheduler` first runs - so a bootstrap loop scheduling many
122
+ equal-interval jobs at ~the same instant handed them all the same phase.
123
+ `scheduleRecurring` now pins the first fire to `now + startDelay` via the
124
+ scheduler's `startDate`, which shifts the whole recurrence, so the same jittered
125
+ `startDelay` de-clusters checks on the Redis backend identically to the
126
+ in-memory one. Cron schedules (absolute times) are unaffected.
127
+ - **The health-check scheduler jitters each check's first fire** by a small,
128
+ deterministic fraction of its interval (stable across restarts, keyed on the
129
+ check). A synchronized set of checks now spreads across the interval instead of
130
+ hammering their targets at the same instant. Because the queue anchors the
131
+ recurrence to the first fire, this offset persists for every subsequent run.
132
+ - **The HTTP collector refreshes its TCP/TLS connect-timing probe in the
133
+ background, per origin, and never awaits it.** Bun's `fetch` already pools and
134
+ reuses connections across runs (verified: warm reuse survives 20s+ idle gaps),
135
+ but the timing probe opened a fresh handshake on EVERY run - mis-reporting the
136
+ reused request's real latency and doubling the connection count under a burst.
137
+ The probe now refreshes a per-origin sample at most once per TTL (60s) and runs
138
+ fully in the background: it is NEVER on a request's critical path. Pinned to one
139
+ resolved IP, the probe can be far slower than the reused fetch (e.g. an
140
+ intermittent IPv6 SYN retry the real request never pays), and per the collector
141
+ contract best-effort timing must never delay the check - the previous code
142
+ `await`ed it, so a slow probe's refresh run showed up as a latency outlier. The
143
+ `connect`/`tls` phases are now explicitly a cached, per-host estimate.
144
+ - **The run detail UI now labels the estimate.** The timing-breakdown caption
145
+ clarifies that DNS, wait, and transfer are measured on the request, while
146
+ connection and TLS setup are an estimate sampled from a periodic per-host probe
147
+ and cached briefly (about a minute), so an operator does not read the cached
148
+ connect/TLS value as a per-run measurement.
149
+
150
+ Behaviour is otherwise unchanged: health status and assertions are the same;
151
+ there are simply far fewer connections, the herd is spread out, and the timing
152
+ breakdown can no longer be inflated by a slow best-effort probe. No configuration
153
+ or API changes.
154
+
155
+ - d0eddc9: Cut the per-tick database work of the health-check executor by batching
156
+ scoped-database queries, and fix a dashboard "Recent activity" rendering bug.
157
+
158
+ The scoped-database proxy has to wrap every standalone query in its own
159
+ transaction so `SET LOCAL search_path` applies to it, which means a hot path
160
+ issuing many sequential queries pays the `BEGIN` / `SET LOCAL` / `COMMIT`
161
+ round-trips once per query and checks a connection out that many times. Two
162
+ changes remove most of that overhead on the health-check path:
163
+
164
+ - **New `withScopedTransaction` helper (`@checkstack/backend-api`).** A reusable
165
+ primitive for running several scoped queries under a SINGLE `SET LOCAL
166
+ search_path` transaction, plus `ScopedTransaction` / `ScopedQueryRunner`
167
+ types so a helper can accept either the scoped db or a transaction handle.
168
+ Use it on any scoped-db hot path that issues 2+ queries in sequence.
169
+ - **`getSystemHealthStatus` is now batched.** It was a `1 + N` read fan-out (one
170
+ associations query, then one run-window query per enabled check) run as `1 +
171
+ N` separate proxy transactions. It now runs as ONE transaction. This is the
172
+ hottest read on the platform - each check tick reads it several times, and the
173
+ dashboard, RPC router, and AI system-signals all call it - so the reduction in
174
+ transaction volume and connection churn is broad. The reads are also now a
175
+ single consistent snapshot.
176
+ - **The executor's run + aggregate writes are batched.** Each persisted run
177
+ previously issued the run `INSERT`, the aggregate `SELECT`, and the aggregate
178
+ `UPSERT` as three separate proxy transactions; they now run in one
179
+ transaction and commit atomically (the run and the aggregate it feeds can no
180
+ longer be persisted apart).
181
+
182
+ Behaviour is unchanged: the derived health status, transition detection, and
183
+ signals are identical; only the number of database transactions per tick drops.
184
+
185
+ Also fixes a dashboard bug where the "Recent activity" feed generated React keys
186
+ from `configurationName` plus a millisecond timestamp, so results from different
187
+ systems sharing a check name that completed in the same millisecond collided on
188
+ one key and React mis-reconciled the list (visually duplicated/omitted entries).
189
+ Keys are now derived from the system, configuration, and environment ids.
190
+
191
+ - d0eddc9: Add opt-in OpenTelemetry metrics with a Prometheus exporter so a performance
192
+ investigation can be grounded in real numbers from a running instance instead of
193
+ guesses.
194
+
195
+ The layer is **off by default and free when off**: the instruments are OTel
196
+ no-ops until a `MeterProvider` is registered, so the hot paths pay nothing until
197
+ you opt in.
198
+
199
+ - **`@checkstack/backend-api` gains an `instrumentation` module** exporting lazy,
200
+ memoized instrument accessors any plugin can record through:
201
+ `dbTransactionsCounter`, `dbQueriesCounter`, `healthcheckExecutionHistogram`,
202
+ `healthcheckPhaseHistogram`, `queueEnqueuedCounter`, `queueProcessedCounter`.
203
+ Each looks up its instrument once and is a no-op until the host registers a
204
+ provider, so callers can record unconditionally.
205
+ - **`@checkstack/backend` owns the SDK bootstrap.** `startMetrics()` registers a
206
+ global `MeterProvider` + Prometheus exporter when `CHECKSTACK_METRICS_ENABLED`
207
+ is set (host `127.0.0.1`, port `9464` by default, both overridable via
208
+ `CHECKSTACK_METRICS_HOST` / `CHECKSTACK_METRICS_PORT`). The exporter runs its
209
+ OWN HTTP server, NOT a route on the app, so it carries no app-auth surface. It
210
+ also registers host-owned observable instruments:
211
+ `checkstack.db.pool.connections` (admin/lock pool active/idle/waiting) and
212
+ `checkstack.runtime.event_loop_delay` (setInterval-drift histogram = JS-thread
213
+ block time).
214
+ - **The scoped-DB proxy records DB transactions/queries per plugin schema**, so
215
+ `db_transactions_total` minus `db_queries_total` per schema is exactly the
216
+ number of batched transactions - a live check that `withScopedTransaction`
217
+ batching is taking effect.
218
+ - **The health-check executor records execution + per-phase histograms**
219
+ (`connect`, `wait`, ...) so a high `connect` p95 with a low `wait` points at
220
+ connection establishment rather than a slow target or a CPU-bound platform.
221
+ - **The in-memory queue records enqueued/processed counters** per queue and
222
+ status.
223
+
224
+ No behaviour changes when disabled. Enable with `CHECKSTACK_METRICS_ENABLED=1`
225
+ and scrape `http://127.0.0.1:9464/metrics`. See the backend observability guide
226
+ for the full metric list and interpretation.
227
+
228
+ - Updated dependencies [8aae4e2]
229
+ - Updated dependencies [f93ee7a]
230
+ - Updated dependencies [f93ee7a]
231
+ - Updated dependencies [8aae4e2]
232
+ - Updated dependencies [d0eddc9]
233
+ - Updated dependencies [d0eddc9]
234
+ - Updated dependencies [f93ee7a]
235
+ - Updated dependencies [f93ee7a]
236
+ - Updated dependencies [d0eddc9]
237
+ - Updated dependencies [d0eddc9]
238
+ - Updated dependencies [8aae4e2]
239
+ - Updated dependencies [d0eddc9]
240
+ - Updated dependencies [f93ee7a]
241
+ - @checkstack/healthcheck-common@1.15.0
242
+ - @checkstack/common@0.22.0
243
+ - @checkstack/catalog-common@2.6.3
244
+ - @checkstack/ai-backend@0.10.9
245
+ - @checkstack/backend-api@0.31.0
246
+ - @checkstack/automation-backend@0.11.0
247
+ - @checkstack/incident-common@1.9.0
248
+ - @checkstack/incident-backend@1.11.0
249
+ - @checkstack/maintenance-common@1.9.0
250
+ - @checkstack/script-packages-backend@0.4.0
251
+ - @checkstack/satellite-backend@0.8.3
252
+ - @checkstack/sdk@0.126.1
253
+ - @checkstack/ai-common@0.6.6
254
+ - @checkstack/cache-api@0.3.19
255
+ - @checkstack/catalog-backend@1.6.9
256
+ - @checkstack/command-backend@0.2.21
257
+ - @checkstack/gitops-backend@0.5.21
258
+ - @checkstack/gitops-common@0.7.3
259
+ - @checkstack/notification-common@1.5.3
260
+ - @checkstack/queue-api@0.3.19
261
+ - @checkstack/secrets-backend@0.3.3
262
+ - @checkstack/secrets-common@0.3.2
263
+ - @checkstack/signal-common@0.2.17
264
+ - @checkstack/status-page-backend@0.4.8
265
+ - @checkstack/status-page-common@0.5.3
266
+ - @checkstack/cache-utils@0.2.24
267
+
268
+ ## 1.17.0
269
+
270
+ ### Minor Changes
271
+
272
+ - 390d9cf: Add a **Container** health-check strategy for monitoring Docker and Podman
273
+ containers that expose no external service of their own. It reports container
274
+ existence, running state, healthcheck status, exit code, restart count, and
275
+ OOM-killed via the **Container Status** collector, and CPU/memory usage via the
276
+ **Container Stats** collector. Both collectors issue only read (GET) requests
277
+ against the runtime REST API.
278
+
279
+ The check runs wherever the executor runs: locally on the core instance (the
280
+ default) to watch containers that share a host with Checkstack, or on a
281
+ satellite pinned to another host.
282
+
283
+ Critically, Checkstack never touches the raw container socket. The strategy
284
+ talks the Docker Engine / Podman libpod API over either a unix socket path or an
285
+ `http(s)` endpoint, so operators point it at a **read-only socket-proxy**
286
+ (`lscr.io/linuxserver/socket-proxy` with `POST=0`) running next to whichever
287
+ Checkstack instance runs the check - core or a satellite - or at a rootless
288
+ Podman socket. The raw socket is mounted only into the proxy; even a compromised
289
+ instance can only read container state, never control the host. A stopped or missing container is a successful collection whose metrics
290
+ feed assertions (following the transport-failure-vs-metric rule) - only an
291
+ unreachable runtime endpoint fails the check. Container `exec` probes are
292
+ intentionally not offered because they would require write access to the socket.
293
+
294
+ To support in-product setup guidance, the health-check strategy contract gains
295
+ an optional `setupInstructions` (Markdown) field, surfaced in the DTO and
296
+ rendered as a collapsible "Setup guide" callout above the strategy config fields
297
+ in the editor. The Container strategy populates it with the secure proxy setup.
298
+
299
+ The hardened socket-proxy compose is maintained as a single canonical file
300
+ (`deploy/socket-proxy/docker-compose.yml`) that operators `include:` from their
301
+ core or satellite compose, so the read-only / `POST=0` / internal-network
302
+ hardening is defined in exactly one place; the docs and the in-product setup
303
+ guide reference it rather than duplicating the YAML.
304
+
305
+ Also removes a stale hand-written `HealthCheckStrategyDto` interface in
306
+ `@checkstack/healthcheck-common` that shadowed (and lagged behind) the
307
+ Zod-inferred DTO; the inferred type from `schemas.ts` is now the single source
308
+ of truth and correctly carries `resultSchema`, `aggregatedResultSchema`, and the
309
+ new `setupInstructions`.
310
+
311
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
312
+ that shaped this release.
313
+
314
+ - fc64fad: Dependencies can now be scoped to a specific environment and/or health check of
315
+ the upstream system, each with its own severity - a "matrix" of scope cells.
316
+
317
+ Previously a dependency watched the upstream's overall health (any check, any
318
+ environment) at the edge's impact type, with optional per-check rules. That
319
+ default is unchanged: with no scope cells configured, the dependency behaves
320
+ exactly as before. Now each cell pins a check (a specific configuration, or
321
+ "any"), an environment (a specific environment, or "any"), and a severity
322
+ (informational / degraded / critical). When a dependency has any cells, only
323
+ those slices are watched (they replace the whole-system watch) and the worst
324
+ result across cells wins. This lets you express, e.g., "System A depends on
325
+ System B only in `prod`", or "only when B's TLS check in `prod` fails", and lets
326
+ different cells carry different severities.
327
+
328
+ Because each environment is evaluated on its own slice, a scoped dependency
329
+ catches an environment-specific outage that the upstream's overall status
330
+ (worst-wins across environments) would otherwise hide. The dependency evaluator
331
+ now reads per-(check, environment) health via a new
332
+ `@checkstack/healthcheck-common` bulk contract `getBulkSystemHealthMatrix` (and
333
+ its `@checkstack/healthcheck-backend` implementation), which returns each
334
+ system's cross-environment rollup plus a per-environment slice. Incident
335
+ overrides still fold into the overall rollup, so incident-forced statuses keep
336
+ propagating through dependencies.
337
+
338
+ The scope-cell store gains a nullable `environment_id` column and makes
339
+ `health_check_id` nullable (forward-only migration; existing rows keep working
340
+ as "any check, any environment"). The dependency editor's per-check panel
341
+ becomes a scope-matrix editor with check + environment + severity rows.
342
+
343
+ Transitive (multi-hop) dependencies still cascade using the upstream's overall
344
+ status; per-environment cascades across multiple hops are not yet propagated.
345
+
346
+ - 9d30324: Incidents can now optionally override the health status of their affected
347
+ systems. When creating or editing an incident you can pick "Override system
348
+ health" (Degraded or Unhealthy); while the incident is active (not resolved)
349
+ that status is folded into every affected system's derived health via
350
+ worst-wins, so it shows on every health surface (status pages, dashboards,
351
+ dependency map, catalog badges). A health check reporting a worse status still
352
+ wins, and the override lifts automatically when the incident resolves. This
353
+ covers components that no automated check can monitor (e.g. a running app whose
354
+ licenses were revoked so it won't open).
355
+
356
+ The override is a deliberate operator choice, independent of the incident's
357
+ severity. A new service-typed incident RPC `getActiveHealthOverrides` exposes
358
+ active overrides per system, which `@checkstack/healthcheck-backend` reads and
359
+ folds into `getSystemHealthStatus`. The system-health response gains an optional
360
+ `override` field naming the contributing incident so UIs can explain why a
361
+ system reads unhealthy when its checks look fine. The system health badge uses
362
+ it to show, on hover, when a status was forced by an incident.
363
+
364
+ The dashboard "problem system" signal attributes an override-forced status to
365
+ the incident ("Forced by incident: <title>") instead of misreporting
366
+ "0 of N checks failing", while a genuinely worse health check still drives the
367
+ signal and its detail. Public status pages reflect the forced status but never
368
+ carry the incident title (the widget DTOs project only the status), so an
369
+ override cannot leak the name of a hidden incident.
370
+
371
+ Behavior change: a system's derived health now reflects active incident
372
+ overrides in addition to its health checks. Adds a forward-only migration for
373
+ the new nullable `incidents.health_override` column.
374
+
375
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
376
+ that shaped this release.
377
+
378
+ ### Patch Changes
379
+
380
+ - Updated dependencies [390d9cf]
381
+ - Updated dependencies [390d9cf]
382
+ - Updated dependencies [fc64fad]
383
+ - Updated dependencies [fc64fad]
384
+ - Updated dependencies [9d30324]
385
+ - Updated dependencies [9d30324]
386
+ - Updated dependencies [b218e3e]
387
+ - @checkstack/ai-backend@0.10.8
388
+ - @checkstack/backend-api@0.30.0
389
+ - @checkstack/healthcheck-common@1.14.0
390
+ - @checkstack/incident-common@1.8.0
391
+ - @checkstack/incident-backend@1.10.0
392
+ - @checkstack/automation-backend@0.10.10
393
+ - @checkstack/catalog-backend@1.6.8
394
+ - @checkstack/command-backend@0.2.20
395
+ - @checkstack/gitops-backend@0.5.20
396
+ - @checkstack/satellite-backend@0.8.2
397
+ - @checkstack/script-packages-backend@0.3.24
398
+ - @checkstack/secrets-backend@0.3.2
399
+ - @checkstack/status-page-backend@0.4.7
400
+ - @checkstack/sdk@0.125.1
401
+
3
402
  ## 1.16.0
4
403
 
5
404
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.7",
18
- "@checkstack/ai-common": "0.6.5",
19
- "@checkstack/automation-backend": "0.10.9",
20
- "@checkstack/backend-api": "0.29.1",
21
- "@checkstack/cache-api": "0.3.18",
22
- "@checkstack/cache-utils": "0.2.23",
23
- "@checkstack/catalog-backend": "1.6.7",
24
- "@checkstack/catalog-common": "2.6.2",
25
- "@checkstack/command-backend": "0.2.19",
26
- "@checkstack/common": "0.21.0",
27
- "@checkstack/gitops-backend": "0.5.19",
28
- "@checkstack/gitops-common": "0.7.2",
29
- "@checkstack/healthcheck-common": "1.13.0",
30
- "@checkstack/incident-backend": "1.9.5",
31
- "@checkstack/incident-common": "1.7.2",
32
- "@checkstack/maintenance-common": "1.8.2",
33
- "@checkstack/notification-common": "1.5.2",
34
- "@checkstack/queue-api": "0.3.18",
35
- "@checkstack/satellite-backend": "0.8.1",
36
- "@checkstack/script-packages-backend": "0.3.23",
37
- "@checkstack/sdk": "0.123.1",
38
- "@checkstack/secrets-backend": "0.3.1",
39
- "@checkstack/secrets-common": "0.3.1",
40
- "@checkstack/signal-common": "0.2.16",
41
- "@checkstack/status-page-backend": "0.4.6",
42
- "@checkstack/status-page-common": "0.5.2",
17
+ "@checkstack/ai-backend": "0.10.9",
18
+ "@checkstack/ai-common": "0.6.6",
19
+ "@checkstack/automation-backend": "0.11.0",
20
+ "@checkstack/backend-api": "0.31.0",
21
+ "@checkstack/cache-api": "0.3.19",
22
+ "@checkstack/cache-utils": "0.2.24",
23
+ "@checkstack/catalog-backend": "1.6.9",
24
+ "@checkstack/catalog-common": "2.6.3",
25
+ "@checkstack/command-backend": "0.2.21",
26
+ "@checkstack/common": "0.22.0",
27
+ "@checkstack/gitops-backend": "0.5.21",
28
+ "@checkstack/gitops-common": "0.7.3",
29
+ "@checkstack/healthcheck-common": "1.15.0",
30
+ "@checkstack/incident-backend": "1.11.0",
31
+ "@checkstack/incident-common": "1.9.0",
32
+ "@checkstack/maintenance-common": "1.9.0",
33
+ "@checkstack/notification-common": "1.5.3",
34
+ "@checkstack/queue-api": "0.3.19",
35
+ "@checkstack/satellite-backend": "0.8.3",
36
+ "@checkstack/script-packages-backend": "0.4.0",
37
+ "@checkstack/sdk": "0.126.1",
38
+ "@checkstack/secrets-backend": "0.3.3",
39
+ "@checkstack/secrets-common": "0.3.2",
40
+ "@checkstack/signal-common": "0.2.17",
41
+ "@checkstack/status-page-backend": "0.4.8",
42
+ "@checkstack/status-page-common": "0.5.3",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.2",
56
- "@checkstack/test-utils-backend": "0.1.53",
55
+ "@checkstack/scripts": "0.7.3",
56
+ "@checkstack/test-utils-backend": "0.1.55",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
59
  "@types/tdigest": "^0.1.5",
60
+ "bullmq": "^5.66.4",
60
61
  "date-fns": "^4.4.0",
61
62
  "drizzle-kit": "^0.31.10",
62
63
  "typescript": "^5.0.0"
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ adaptiveTimeout,
4
+ DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
5
+ } from "./adaptive-timeout";
6
+
7
+ describe("adaptiveTimeout", () => {
8
+ const configuredMs = 30_000;
9
+
10
+ test("guardrail 1: no baseline => never shrink", () => {
11
+ expect(
12
+ adaptiveTimeout({
13
+ configuredMs,
14
+ healthyBaselineMs: undefined,
15
+ isSuspect: true,
16
+ isRecoveryProbe: false,
17
+ }),
18
+ ).toBe(configuredMs);
19
+ });
20
+
21
+ test("non-suspect check keeps the full configured timeout", () => {
22
+ expect(
23
+ adaptiveTimeout({
24
+ configuredMs,
25
+ healthyBaselineMs: 200,
26
+ isSuspect: false,
27
+ isRecoveryProbe: false,
28
+ }),
29
+ ).toBe(configuredMs);
30
+ });
31
+
32
+ test("guardrail 3: recovery probe always uses the full configured timeout", () => {
33
+ expect(
34
+ adaptiveTimeout({
35
+ configuredMs,
36
+ healthyBaselineMs: 200,
37
+ isSuspect: true,
38
+ isRecoveryProbe: true,
39
+ }),
40
+ ).toBe(configuredMs);
41
+ });
42
+
43
+ test("fast healthy check shrinks toward the floor (200ms => ~1s)", () => {
44
+ // 200 * 1.5 = 300 < floor => clamped to the absolute floor.
45
+ expect(
46
+ adaptiveTimeout({
47
+ configuredMs,
48
+ healthyBaselineMs: 200,
49
+ isSuspect: true,
50
+ isRecoveryProbe: false,
51
+ }),
52
+ ).toBe(DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS);
53
+ });
54
+
55
+ test("deadlock guard: a slow-but-healthy check (10s) never shrinks below its own latency", () => {
56
+ // 10_000 * 1.5 = 15_000: a recovering 10s run still passes at 15s.
57
+ const t = adaptiveTimeout({
58
+ configuredMs,
59
+ healthyBaselineMs: 10_000,
60
+ isSuspect: true,
61
+ isRecoveryProbe: false,
62
+ });
63
+ expect(t).toBe(15_000);
64
+ expect(t).toBeGreaterThan(10_000);
65
+ });
66
+
67
+ test("shrink never exceeds the configured timeout", () => {
68
+ // baseline*factor would exceed configured => clamp down to configured.
69
+ expect(
70
+ adaptiveTimeout({
71
+ configuredMs: 5_000,
72
+ healthyBaselineMs: 8_000,
73
+ isSuspect: true,
74
+ isRecoveryProbe: false,
75
+ }),
76
+ ).toBe(5_000);
77
+ });
78
+
79
+ test("honors custom safetyFactor and absoluteFloor", () => {
80
+ expect(
81
+ adaptiveTimeout({
82
+ configuredMs,
83
+ healthyBaselineMs: 1_000,
84
+ isSuspect: true,
85
+ isRecoveryProbe: false,
86
+ safetyFactor: 2,
87
+ absoluteFloorMs: 500,
88
+ }),
89
+ ).toBe(2_000);
90
+ });
91
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Adaptive execution timeout for slot-hogging health checks.
3
+ *
4
+ * A check that hangs holds its concurrency slot for the FULL configured timeout
5
+ * (see `queue-executor.ts` — the per-env `Promise.race` frees the slot only when
6
+ * the timeout fires). When a target is consistently timing out, we can free that
7
+ * slot sooner by probing it with a SHORTER timeout — but only down to a value at
8
+ * which a genuinely HEALTHY run of this same check would still succeed, or we
9
+ * would abort even a recovering target and never let it go healthy again.
10
+ *
11
+ * The floor is therefore derived from the check's OWN measured healthy latency,
12
+ * never a global constant. Four guardrails make a recovery deadlock impossible:
13
+ *
14
+ * 1. No baseline (no recent healthy run) => NO shrink. Without evidence of the
15
+ * target's healthy latency we cannot tell "hung" from "legitimately slow and
16
+ * about to succeed", so we keep the full configured timeout.
17
+ * 2. The baseline is computed from SUCCESSFUL runs only (caller's job), so a
18
+ * timed-out run (latency ~= timeout) can never ratchet the floor downward.
19
+ * 3. The periodic recovery probe passes `isRecoveryProbe: true` and always gets
20
+ * the FULL configured timeout — so a genuinely-slow-but-healthy target (e.g.
21
+ * a 10s computation) is always eventually re-measured at its real latency.
22
+ * This is the by-construction deadlock breaker.
23
+ * 4. A single healthy run clears the suspect classification upstream, so the
24
+ * full timeout is restored immediately (hysteresis).
25
+ */
26
+
27
+ /** Default multiplier applied to the healthy-latency baseline. */
28
+ export const DEFAULT_TIMEOUT_SAFETY_FACTOR = 1.5;
29
+ /** Default absolute lower bound; the timeout is never shrunk below this. */
30
+ export const DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS = 1000;
31
+
32
+ export interface AdaptiveTimeoutInput {
33
+ /** The user-configured execution timeout (ms). The shrink never exceeds it. */
34
+ configuredMs: number;
35
+ /**
36
+ * p95 (or max) latency of this check's recent SUCCESSFUL runs, in ms.
37
+ * `undefined` means "no healthy baseline" => guardrail 1 (never shrink).
38
+ */
39
+ healthyBaselineMs: number | undefined;
40
+ /** Whether this check is classified as a consistent slot-hogging failure. */
41
+ isSuspect: boolean;
42
+ /** Whether this run is the periodic full-timeout recovery probe (guardrail 3). */
43
+ isRecoveryProbe: boolean;
44
+ /** Multiplier on the baseline. Defaults to {@link DEFAULT_TIMEOUT_SAFETY_FACTOR}. */
45
+ safetyFactor?: number;
46
+ /** Hard lower bound. Defaults to {@link DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS}. */
47
+ absoluteFloorMs?: number;
48
+ }
49
+
50
+ /**
51
+ * Resolve the effective execution timeout for a single (env-scoped) run.
52
+ * Returns `configuredMs` unchanged unless the check is a suspect slot-hogger
53
+ * WITH a healthy baseline AND this is not a recovery probe.
54
+ */
55
+ export function adaptiveTimeout(input: AdaptiveTimeoutInput): number {
56
+ const {
57
+ configuredMs,
58
+ healthyBaselineMs,
59
+ isSuspect,
60
+ isRecoveryProbe,
61
+ safetyFactor = DEFAULT_TIMEOUT_SAFETY_FACTOR,
62
+ absoluteFloorMs = DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
63
+ } = input;
64
+
65
+ // Guardrails 1 & 3, and the non-suspect fast path: use the full timeout.
66
+ if (!isSuspect || isRecoveryProbe || healthyBaselineMs === undefined) {
67
+ return configuredMs;
68
+ }
69
+
70
+ // Shrink toward the target's own healthy latency, clamped to
71
+ // [absoluteFloor, configured]. For a 10s-healthy check this stays >= ~15s;
72
+ // for a 200ms-healthy check it drops to the floor (~1s).
73
+ const shrunk = Math.round(healthyBaselineMs * safetyFactor);
74
+ return Math.min(configuredMs, Math.max(absoluteFloorMs, shrunk));
75
+ }
@@ -20,6 +20,8 @@ const unhealthyStatuses: HealthcheckSignalStatuses = {
20
20
  configurationName: "Ping",
21
21
  status: "unhealthy",
22
22
  runsConsidered: 5,
23
+ sliceCount: 1,
24
+ failingSliceCount: 1,
23
25
  },
24
26
  ],
25
27
  },