@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.
- package/CHANGELOG.md +399 -0
- package/package.json +30 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +87 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +395 -294
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +103 -19
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +98 -0
- package/src/service-ordering.test.ts +4 -0
- package/src/service-paused-filter.test.ts +14 -7
- package/src/service-rollup-worst-wins.test.ts +37 -4
- package/src/service.ts +348 -145
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/widgets.ts +11 -1
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
- package/src/system-health-override.test.ts +94 -0
- 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.
|
|
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.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/automation-backend": "0.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.6.
|
|
24
|
-
"@checkstack/catalog-common": "2.6.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.7.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/incident-backend": "1.
|
|
31
|
-
"@checkstack/incident-common": "1.
|
|
32
|
-
"@checkstack/maintenance-common": "1.
|
|
33
|
-
"@checkstack/notification-common": "1.5.
|
|
34
|
-
"@checkstack/queue-api": "0.3.
|
|
35
|
-
"@checkstack/satellite-backend": "0.8.
|
|
36
|
-
"@checkstack/script-packages-backend": "0.
|
|
37
|
-
"@checkstack/sdk": "0.
|
|
38
|
-
"@checkstack/secrets-backend": "0.3.
|
|
39
|
-
"@checkstack/secrets-common": "0.3.
|
|
40
|
-
"@checkstack/signal-common": "0.2.
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
42
|
-
"@checkstack/status-page-common": "0.5.
|
|
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.
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
+
}
|