@checkstack/healthcheck-backend 1.17.0 → 1.19.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 (44) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/package.json +32 -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/health-notification-content.test.ts +89 -0
  9. package/src/health-notification-content.ts +138 -0
  10. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  11. package/src/healthcheck-gitops-kinds.ts +17 -13
  12. package/src/index.ts +58 -6
  13. package/src/migration-chain-contract.test.ts +7 -1
  14. package/src/notification-policy.test.ts +19 -0
  15. package/src/notification-policy.ts +26 -0
  16. package/src/queue-executor.test.ts +391 -338
  17. package/src/queue-executor.ts +426 -362
  18. package/src/realtime-aggregation.ts +9 -2
  19. package/src/rollup-consumer.test.ts +191 -0
  20. package/src/rollup-consumer.ts +160 -0
  21. package/src/router.ts +46 -13
  22. package/src/schedule-jitter.test.ts +69 -0
  23. package/src/schedule-jitter.ts +50 -0
  24. package/src/schedule-reconciler.it.test.ts +453 -0
  25. package/src/schedule-reconciler.test.ts +418 -0
  26. package/src/schedule-reconciler.ts +304 -0
  27. package/src/service-batching.test.ts +106 -0
  28. package/src/service-bulk-counts.it.test.ts +144 -0
  29. package/src/service-bulk-run-stats.it.test.ts +197 -0
  30. package/src/service-ordering.test.ts +10 -2
  31. package/src/service-paused-filter.test.ts +27 -7
  32. package/src/service-rollup-worst-wins.test.ts +221 -124
  33. package/src/service.ts +557 -266
  34. package/src/slow-check-admission.test.ts +184 -0
  35. package/src/slow-check-admission.ts +101 -0
  36. package/src/slow-check-classifier.test.ts +155 -0
  37. package/src/slow-check-classifier.ts +137 -0
  38. package/src/slow-check-config.ts +102 -0
  39. package/src/status-page/rollup.test.ts +40 -0
  40. package/src/status-page/rollup.ts +27 -0
  41. package/src/status-page/widgets.test.ts +303 -0
  42. package/src/status-page/widgets.ts +155 -39
  43. package/src/suspect-lane.test.ts +50 -0
  44. package/src/suspect-lane.ts +61 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,564 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43e4484: Fix an N+1 in the catalog manager: the per-system "Health Checks" count badge
8
+ fired one `getSystemAssociations` request per system row, each holding a pooled
9
+ Postgres connection that contended with the background health-check run
10
+ executor and could exhaust the pool on large catalogs.
11
+
12
+ - Add `getBulkAssignedHealthCheckCounts({ systemIds })` to healthcheck, which
13
+ returns per-system assignment counts (0 for systems with no assignments) from
14
+ ONE grouped `COUNT(*) ... GROUP BY system_id` query. Read authorization
15
+ matches the per-system endpoint it replaces (`configuration.read` +
16
+ `catalog.system` read via `recordKey`), so a team-scoped user only sees counts
17
+ for systems they may read.
18
+ - `CatalogSystemActionsSlot` now passes `visibleSystemIds` (every system id in
19
+ the row's list) so a per-row filler can bulk-fetch for the whole visible set
20
+ in a single deduped request instead of one request per row. This mirrors how
21
+ `CatalogBrowseHealthSlot` / `SystemSignalsSlot` already pass `systemIds`.
22
+ - The health-check count badge now reads its count from that one deduped bulk
23
+ query. N visible rows cause 1 request instead of N.
24
+
25
+ State & scale: the counts are derived on read from the shared
26
+ `system_health_checks` table, so every pod returns the same answer; no
27
+ process-local or duplicated state is introduced.
28
+
29
+ - 43e4484: Name the failing health check in system-health notifications. The notification
30
+ body now names the check that drove the transition (in addition to the system
31
+ and environment), and a `healthcheck.healthcheck` subject is pushed alongside
32
+ the `catalog.system` subject, deep-linked to the check's run history. Recovery
33
+ notifications stay system-level. Adds a `createHealthcheckSubject` builder to
34
+ `healthcheck-common`.
35
+
36
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
37
+
38
+ - 43e4484: Status pages can now publish only a subset of catalog environments. The page
39
+ builder gains a "Published environments" picker (empty = all environments, the
40
+ backward-compatible default). When a non-empty set is selected, the page omits
41
+ status, incidents, maintenances and uptime for systems that belong to none of
42
+ the selected environments.
43
+
44
+ - Status pages store an optional `publishedEnvironmentIds` set (new nullable
45
+ `published_environment_ids` column; NULL = all environments, so existing pages
46
+ are unchanged) exposed on `StatusPage`, `createStatusPage`, and
47
+ `updateStatusPage`.
48
+ - The scope is threaded onto `WidgetResolveContext.publishedEnvironmentIds` as
49
+ opaque strings and passed identically to `resolvePublic`,
50
+ `resolveScopedSystems`, and `resolveScopedSystemsDetailed` (and the email
51
+ subscribe clamp + fan-out), so what a page shows, offers for subscription, and
52
+ emails about all agree.
53
+ - Health widgets recompute per environment: they read the per-environment health
54
+ matrix and roll up only the selected environments. `getBulkRunStats` and
55
+ `getRunStats` gain an optional `environmentIds` filter so uptime counts only
56
+ runs recorded in the selected environments.
57
+ - Incident and maintenance widgets filter their feed and scope by intersecting
58
+ each item's affected systems with the environment-visible systems. Incidents
59
+ and maintenance windows carry no environment of their own, so a system in
60
+ several environments makes its items visible on a page publishing ANY of them
61
+ (the multi-environment caveat).
62
+
63
+ ### Patch Changes
64
+
65
+ - 43e4484: fix(healthcheck): disabling an environment for an assignment now clears its stale slice from the rollup and overview immediately
66
+
67
+ Disabling an environment for a health-check assignment (removing it from the
68
+ assignment's `environmentIds`) stopped that environment from fanning out, but a
69
+ check that was FAILING there kept dragging the system health rollup/badge to
70
+ unhealthy and kept showing as a live failing row in the system overview. Because
71
+ the rollup is recomputed by an event-driven consumer subscribed to per-env health
72
+ CHANGES, and a disabled env produces no further runs (so no change event fires),
73
+ the stale unhealthy status was never recomputed away - it only cleared
74
+ incidentally, once the disabled env's runs aged out of the bounded run window
75
+ (which needs the assignment's OTHER active environments to produce enough newer
76
+ runs first). With a single active/failing env, it could persist until retention.
77
+
78
+ Scope: this reconciles environments DISABLED/removed ON THE ASSIGNMENT (its
79
+ `systemHealthChecks.environmentIds` selector - switching to Specific and
80
+ deselecting, or None).
81
+
82
+ Fixes:
83
+
84
+ - The rollup aggregation (`getSystemHealthStatus`) and the per-check status in
85
+ `getSystemHealthOverview` now consider only CURRENTLY-EFFECTIVE environment
86
+ slices, derived from the durable `systemHealthChecks.environmentIds` selector
87
+ (catalog-free, identical on every pod). A slice whose environment was disabled
88
+ for the assignment, or the stale env-less slice of a check that now fans out,
89
+ no longer contributes.
90
+
91
+ Known limitation: under an "all-environments" assignment (`environmentIds` is
92
+ `null`), an environment removed only from the system's CATALOG MEMBERSHIP (rather
93
+ than disabled on the assignment) can still contribute to the backend rollup/badge
94
+ until the assignment is re-evaluated, because the rollup read path is
95
+ intentionally catalog-free for horizontal-scale correctness (it must return the
96
+ same answer on every pod without a per-read catalog lookup). This is pre-existing;
97
+ the frontend overview, which can see membership, still orphans such a slice.
98
+
99
+ - Each environment is now windowed by its OWN query in the rollup, instead of a
100
+ single shared `LIMIT` across the mixed-env pool. The old shared window
101
+ truncated per-env evaluation for checks that fan out to many environments (or
102
+ with large threshold windows); every environment now gets its full evaluation
103
+ depth.
104
+ - Changing an assignment's environment set now triggers an immediate rollup
105
+ recompute for that system, so the persisted `health` entity (badge + SLO
106
+ downtime) converges at once rather than waiting for stale runs to age out.
107
+ - The system-overview frontend tucks a slice whose environment was disabled for
108
+ the assignment under "Old checks" (system membership alone could not detect it,
109
+ since the environment is still part of the system). `getSystemHealthOverview`
110
+ now returns each check's `environmentIds` selector to drive this.
111
+
112
+ Shared pure helpers `selectorIncludesEnvironment` / `isEnvSliceEffective` /
113
+ `selectEffectiveEnvKeys` are added to `@checkstack/healthcheck-common` so the
114
+ backend and frontend agree on effective-slice detection.
115
+
116
+ - 43e4484: Batch hot-path scoped-db reads/writes into single transactions to cut per-query round-trips.
117
+
118
+ The scoped-db proxy wraps every standalone query in its own `BEGIN → SET LOCAL search_path → query → COMMIT`, so a path issuing N sequential queries paid N round-trips and checked out a connection N times. These reads/writes now run under one `withScopedTransaction`, collapsing the batch to a single `SET LOCAL` on one connection. Behavior is unchanged:
119
+
120
+ - healthcheck: `getSystemHealthOverview`'s `1 + N·(2+E)` read fan-out.
121
+ - incident/maintenance: `getIncident`/`getMaintenance` (4 reads), `getManyEntityStates`, `listOpenIncidentsBySystem` / `getActiveMaintenancesBySystem`, `getMaintenanceWindowsForRange`; the `list*` / `*ForSystem` per-row `N+1` system lookups collapsed to a single set-based `inArray` read; maintenance `transitionStatus` update+insert made atomic; `addUpdate`/`editUpdate`/`addLink` use `.returning()` instead of a follow-up re-select.
122
+ - ai: `appendMessage`, memory `saveOrUpdate`.
123
+ - notification: `resolveInheritedGroups`.
124
+ - status-page: subscriber `verify` (4 reads) and `unsubscribe` (3 reads).
125
+ - announcement: `getActiveAnnouncements` / `dismissAnnouncement` / `createAnnouncement`.
126
+ - gitops: `upsertProvenance`.
127
+
128
+ - 43e4484: Eliminate N+1 RPC fan-outs in the public status-page widget resolvers.
129
+
130
+ Each of these widgets renders a PUBLIC page, so every per-item RPC was real
131
+ external DB load. Three bulk-by-id endpoints replace the per-item fetches:
132
+
133
+ - `healthcheck-common`: new `getBulkRunStats({ systemIds, startDate, endDate,
134
+ maxBuckets })` -> `{ stats: Record<systemId, RunStats> }`. The `systemHealth`
135
+ widget's uptime column now issues ONE request for all systems instead of one
136
+ `getRunStats` per system. Systems with no runs in the window are omitted, so
137
+ the resolver's output is unchanged.
138
+ - `incident-common`: new `getBulkIncidentUpdates({ incidentIds })` ->
139
+ `{ updates: Record<incidentId, IncidentUpdate[]> }`. The incidents widget now
140
+ fetches every selected incident's update timeline in ONE request instead of
141
+ one `getIncident` per incident.
142
+ - `maintenance-common`: new `getBulkMaintenanceUpdates({ maintenanceIds })` ->
143
+ `{ updates: Record<maintenanceId, MaintenanceUpdate[]> }` (symmetric with the
144
+ incident endpoint) for the maintenance widget.
145
+
146
+ The new update endpoints apply the same per-item audience filter as
147
+ `getIncident` / `getMaintenance`, so internal/logged-in updates and author
148
+ identity never leak to a non-manager caller. Each endpoint is keyed by the
149
+ resource id and gated with the record post-filter (`recordKey`) matching the
150
+ single endpoint's read scope, mirroring `getBulkSystemHealthStatus` /
151
+ `getBulkIncidentsForSystems`. Widget DTO output is unchanged - this is a pure
152
+ request-count optimization.
153
+
154
+ - 43e4484: Status page enhancements:
155
+
156
+ - Group-status widget can collapse its member rows while every member is
157
+ operational (auto-expanding on any issue or maintenance).
158
+ - New "Announcements" status-page widget, contributed fully externally by the
159
+ announcement plugin: it surfaces active `visibility: "all"` announcements
160
+ through a public-safe DTO (title/message/severity/timestamps only) and never
161
+ affects the page status rollup.
162
+ - Incident and maintenance widgets can scope by catalog GROUPS with per-system
163
+ exceptions. Scope is resolved at read time (`(systemIds ∪ members(groupIds)) −
164
+ excludedSystemIds`), so members added to a group later are reflected
165
+ automatically. The builder gets a nested group/system picker.
166
+ - Incident and maintenance items on a public page link to dedicated public
167
+ detail pages, gated server-side to items the page's published widgets actually
168
+ surface (no enumeration, no internal-field leak). The custom-domain public
169
+ bundle gains a minimal in-memory router for the two detail pages.
170
+ - Fix the custom-domain "Cannot connect to Checkstack backend" screen: a
171
+ configured-but-not-servable custom domain now serves the lean public
172
+ "not available" page instead of the admin shell; the public bundle skips the
173
+ cross-origin `/api/config` probe; CORS admits resolved custom domains; the
174
+ request origin is normalized for proxy scheme/port variance; and re-saving an
175
+ unchanged custom domain no longer clears its verification.
176
+ - Anonymous email subscriptions (double opt-in) for incident updates, opt-in per
177
+ status page (`emailSubscriptionsEnabled`, default off): a new
178
+ `status_page_subscribers` table, public subscribe/verify/unsubscribe
179
+ procedures with constant-time responses that fail closed when the page has not
180
+ enabled subscriptions, and team-scoped admin list/remove + an enable toggle in
181
+ the builder. Emails are delivered through a new `sendRawEmail` primitive in
182
+ notification-backend that sends to an arbitrary external address (no auth
183
+ account) via every enabled email strategy (SMTP), with a mandatory unsubscribe
184
+ link.
185
+ - Incident/maintenance update fan-out to subscribers via a new
186
+ `notificationAudienceExtensionPoint` in notification-backend. Every
187
+ notification funnelled through `notifyForSubscription` (incident, maintenance,
188
+ health - all unchanged) now also invokes each registered audience sink exactly
189
+ once, enriched with the affected systems and their catalog groups (resolved
190
+ from notification-backend's own resource-parent graph, never a domain import).
191
+ status-page-backend contributes a sink that, AT SEND TIME, matches each
192
+ notification's affected systems against the systems each published + public +
193
+ email-enabled page currently surfaces in its incident/maintenance widgets
194
+ (honoring group membership and per-system exclusions) and emails that page's
195
+ verified subscribers. Send-time scoping against the live layout is the privacy
196
+ boundary: a page only ever emails about systems its widgets surface right now.
197
+ Because `notifyForSubscription` is a single-pod point RPC, each notification
198
+ fans out exactly once cluster-wide.
199
+ - Subscriber reconcile on page deletion: the subscriber FK is `ON DELETE
200
+ CASCADE` and page deletion also explicitly purges subscribers (invalidating
201
+ pending verify/unsubscribe tokens) - no orphan rows, no post-deletion send.
202
+ Removing all systems from a page or disabling email is intentionally NOT a
203
+ prune: send-time scoping plus the email-enabled gate make those subscribers
204
+ dormant with no data loss, and re-enabling restores the audience without a
205
+ re-subscribe.
206
+ - Send-time scoping is single-source: the fan-out asks each event-feed widget for
207
+ its CURRENT effective system scope (the same live catalog group expansion the
208
+ widget renders from) instead of a parallel copy of group membership, so it can
209
+ never over- or under-deliver relative to what the page shows.
210
+ - `sendRawEmail` in notification-backend is now `userType: "service"` (was an
211
+ authenticated procedure gated on `notification.send`). Sending to an arbitrary
212
+ address is an open-relay / email-bomb primitive, so it is callable only by a
213
+ trusted backend-to-backend caller (the status-page subscriber mailer), never by
214
+ an end user.
215
+ - Incident/maintenance widgets gain an optional per-system PUBLIC label override
216
+ (`systemLabels`), the same override path the system-health widget uses, so the
217
+ public incident/maintenance detail pages present clean labels instead of raw
218
+ catalog names.
219
+ - The anonymous subscribe endpoint adds a coarse per-page quota (max new
220
+ subscribers per rolling hour, counted over durable rows so it holds across
221
+ pods) on top of the per-(page,email) cooldown, capping verification-email
222
+ amplification. The quota is CONFIGURABLE per status page (new nullable
223
+ `email_subscribers_hourly_quota` column; null uses the default of 50, so
224
+ existing pages are unchanged), validated as a positive integer up to 5000,
225
+ editable in the builder next to the email opt-in toggle and gated by the same
226
+ page-manage capability.
227
+ - Email verification is now per-page configurable and backed by a platform-global
228
+ once-per-address registry:
229
+ - New `email_verification_required` column (boolean, default true) on
230
+ `status_pages`, exposed on the admin StatusPage DTO + `updateStatusPage`
231
+ input (same page-manage gate) with a builder toggle. When OFF, a new
232
+ subscriber is created active immediately - no verification email, and the
233
+ address is NOT written to the global registry (the operator's trust choice
234
+ for e.g. an internal page).
235
+ - New `status_page_verified_emails` table: one row per normalized address that
236
+ has completed verification on ANY page. When a verification-required page is
237
+ subscribed by an already-globally-verified address, the row is created active
238
+ immediately and a COURTESY email (with one-click unsubscribe) is sent instead
239
+ of a verification email, so a malicious add is always caught. `verify` upserts
240
+ the address into this registry and activates every other pending row for the
241
+ same address in one update (confirm once, all pages).
242
+ - Fan-out is unchanged: it still gates on the per-row `verified` flag; the
243
+ registry only governs whether a NEW subscribe short-circuits to active.
244
+
245
+ BREAKING CHANGE: `sendRawEmail` is now service-only. Any (non-existent in-tree)
246
+ authenticated caller must invoke it through a trusted service client instead.
247
+
248
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
249
+
250
+ - Updated dependencies [43e4484]
251
+ - Updated dependencies [43e4484]
252
+ - Updated dependencies [43e4484]
253
+ - Updated dependencies [43e4484]
254
+ - Updated dependencies [43e4484]
255
+ - Updated dependencies [43e4484]
256
+ - Updated dependencies [43e4484]
257
+ - Updated dependencies [43e4484]
258
+ - Updated dependencies [43e4484]
259
+ - Updated dependencies [43e4484]
260
+ - Updated dependencies [43e4484]
261
+ - Updated dependencies [43e4484]
262
+ - Updated dependencies [43e4484]
263
+ - Updated dependencies [43e4484]
264
+ - Updated dependencies [43e4484]
265
+ - Updated dependencies [43e4484]
266
+ - Updated dependencies [43e4484]
267
+ - Updated dependencies [43e4484]
268
+ - Updated dependencies [43e4484]
269
+ - Updated dependencies [43e4484]
270
+ - Updated dependencies [43e4484]
271
+ - Updated dependencies [43e4484]
272
+ - Updated dependencies [43e4484]
273
+ - Updated dependencies [43e4484]
274
+ - Updated dependencies [43e4484]
275
+ - Updated dependencies [43e4484]
276
+ - Updated dependencies [43e4484]
277
+ - Updated dependencies [43e4484]
278
+ - @checkstack/ai-backend@0.10.10
279
+ - @checkstack/automation-backend@0.11.1
280
+ - @checkstack/catalog-common@2.7.0
281
+ - @checkstack/catalog-backend@1.7.0
282
+ - @checkstack/healthcheck-common@1.16.0
283
+ - @checkstack/backend-api@0.31.1
284
+ - @checkstack/incident-common@1.10.0
285
+ - @checkstack/incident-backend@1.12.0
286
+ - @checkstack/maintenance-common@1.10.0
287
+ - @checkstack/notification-common@1.6.0
288
+ - @checkstack/status-page-backend@0.5.0
289
+ - @checkstack/gitops-backend@0.5.22
290
+ - @checkstack/secrets-backend@0.3.4
291
+ - @checkstack/status-page-common@0.6.0
292
+ - @checkstack/satellite-backend@0.8.4
293
+ - @checkstack/sdk@0.127.1
294
+ - @checkstack/command-backend@0.2.22
295
+ - @checkstack/script-packages-backend@0.4.1
296
+
297
+ ## 1.18.0
298
+
299
+ ### Minor Changes
300
+
301
+ - 8aae4e2: Count fanned-out environment slices in the dashboard's "X of Y checks failing".
302
+
303
+ The dashboard problem card counted CHECKS, so a system with a single check that
304
+ fans out to three environments showed "Unhealthy 1 of 1 checks failing" even
305
+ when only one of the three environments was failing. It now counts (check ×
306
+ environment) slices: that system reads "1 of 3 checks failing", and a system
307
+ with a three-environment check plus a single-environment check with one
308
+ environment failing reads "1 of 4 checks failing". An env-less check counts as a
309
+ single slice, so a system with no environments reads exactly as before.
310
+
311
+ The per-check status DTO (`SystemCheckStatus`, returned by
312
+ `getSystemHealthStatus` / `getBulkSystemHealthStatus` /
313
+ `getBulkSystemHealthMatrix`) gains two fields: `sliceCount` (environment slices
314
+ this check currently fans out to, always >= 1) and `failingSliceCount` (how many
315
+ of those slices are non-healthy). `deriveHealthcheckSignals` sums them across
316
+ checks for the honest numerator/denominator.
317
+
318
+ - d0eddc9: Rework health-check scheduling to one recurring job per
319
+ `(configuration, system, environment)` slice and add a slow-check bulkhead so a
320
+ slow or unreachable check can no longer starve the healthy ones.
321
+
322
+ Previously a single recurring job per `(configuration, system)` fanned out over
323
+ every environment sequentially inside one tick, so the job held a concurrency
324
+ slot for the sum of all its environments, and a slow environment stalled its
325
+ siblings. Now each environment slice is its own recurring job that holds a slot
326
+ only for its own probe. A convergence reconciler (k8s-controller style) derives
327
+ the desired per-env job set from Postgres + catalog membership and converges the
328
+ queue toward it (schedule missing, cancel orphans, reschedule interval changes),
329
+ so it is self-healing across pods and stays correct as catalog membership
330
+ changes. It runs at boot, and system-scoped after an assignment or GitOps
331
+ change. `run_now` enqueues one one-off job per effective environment.
332
+
333
+ The system rollup (the bare `<systemId>` health entity every badge, SLO rule and
334
+ dependency map reads) is recomputed by an event-driven, debounced consumer that
335
+ subscribes to per-environment health changes and recomputes once per system per
336
+ window, instead of inline on every tick. Notifications stay owned by the
337
+ per-environment runs, so the rollup notification is structurally deduplicated.
338
+
339
+ The bulkhead classifies each slice's recent runs: a slice whose last K runs were
340
+ slow transport failures (held its slot ~the full timeout) is admitted to a
341
+ capped, pod-local lane (single-flight per slice) and probed with a timeout shrunk
342
+ toward its own healthy-latency baseline, or DEFERRED (recording nothing, freeing
343
+ the slot) when the lane is full or a prior run is still in flight. The adaptive
344
+ timeout has four deadlock guardrails: no baseline means no shrink, the baseline
345
+ uses only healthy runs, every Nth suspect run re-probes at the full timeout, and
346
+ an absolute floor. A healthy slice is never gated and always runs at the full
347
+ timeout. A new `checkstack.healthcheck.deferred{reason}` counter records
348
+ bulkhead deferrals.
349
+
350
+ Measured with the scale harness (240 checks, 20% unreachable, concurrency 10, 5s
351
+ timeout, 35s): with the bulkhead off the queue backlog climbs unbounded to 774
352
+ while 60 slow checks pin slots; with it on the backlog stays bounded (drains to
353
+ 0), completions roughly triple (288 → 862), and slot-pinning timeouts drop
354
+ (60 → 12) as 207 suspect runs are deferred.
355
+
356
+ `@checkstack/test-utils-backend` gains a `withTransactionMock` helper that adds a
357
+ `.transaction(cb)` passthrough to a mock database, so tests can exercise code
358
+ that batches reads/writes through `withScopedTransaction`.
359
+
360
+ BREAKING CHANGE: the internal `HealthCheckJobPayload` now requires an
361
+ `environmentId` field and recurring health-check job IDs are per-environment
362
+ (`healthcheck:<config>:<system>[:<env>]`). This is an internal queue contract
363
+ with no external package API surface; on upgrade the reconciler cancels the
364
+ old-format jobs and schedules the per-environment set at boot.
365
+
366
+ - 8aae4e2: Show the last successful run per check (or per check+environment when fanned
367
+ out) in the system overview.
368
+
369
+ Each overview row that is currently degraded or unhealthy now shows when it was
370
+ last healthy (for example "Healthy until 2h ago", or "Never healthy" when it has
371
+ never succeeded), so operators can see at a glance since when a system has been
372
+ degraded or unhealthy without opening the drawer.
373
+
374
+ `getSystemHealthOverview` gains a `lastSuccessfulRunAt` field at both the check
375
+ level (most recent healthy run across all of the check's environments) and per
376
+ environment (`perEnvironment[].lastSuccessfulRunAt`). It is computed with a
377
+ dedicated max-per-environment aggregate query OUTSIDE the bounded sparkline
378
+ window, so it stays accurate even when a check has been failing for far longer
379
+ than the last runs shown in the sparkline.
380
+
381
+ ### Patch Changes
382
+
383
+ - 8aae4e2: Stop sending a duplicate notification when a fanned-out system goes unhealthy.
384
+
385
+ A health check that fans out across environments notified once per environment
386
+ ("... is unhealthy in environment X") AND once more for the system rollup
387
+ ("... is unhealthy") in the same tick, so operators received two notifications
388
+ describing the same outage. The rollup transition is always driven by the very
389
+ environment(s) that already notified, so the rollup notification is now
390
+ suppressed whenever any environment notified this tick. It is still sent as a
391
+ fallback when no environment notified (e.g. every per-env delivery was
392
+ suppressed by policy/maintenance or threw), so a real status change is never
393
+ left entirely unannounced, and a system with no environments is unaffected.
394
+
395
+ Only the redundant user-facing notification is dropped: the rollup state
396
+ transition is still recorded and the `SYSTEM_STATUS_CHANGED` signal is still
397
+ broadcast, so SLO downtime, the dependency graph, the frontend, and automations
398
+ (which subscribe to the per-env and rollup entity changes) are unchanged.
399
+
400
+ - d0eddc9: Cut health-check connection churn and de-cluster the scheduling "thundering
401
+ herd" so per-run durations stop varying wildly for the same check against the
402
+ same target. Grounded in live OpenTelemetry phase histograms: per-run wall time
403
+ was dominated by TCP/TLS connection setup under a self-inflicted burst, not by
404
+ slow targets, CPU, or the database.
405
+
406
+ - **In-memory queue now honors `startDelay` in `scheduleRecurring`.** It was
407
+ silently dropped, so every recurring job (health checks included) fired
408
+ immediately on boot and then on a boot-anchored interval grid - keeping all
409
+ equal-interval checks phase-aligned forever. `scheduleRecurring` now defers the
410
+ first execution by `startDelay` and anchors the recurrence to that first fire,
411
+ matching the queue contract and the BullMQ backend's intent. Jobs scheduled
412
+ without `startDelay` are unchanged (first run is immediate).
413
+ - **The BullMQ queue now honors `startDelay` in `scheduleRecurring` too.** It also
414
+ dropped `startDelay`, and its `every` scheduler captures the grid phase from
415
+ whenever `upsertJobScheduler` first runs - so a bootstrap loop scheduling many
416
+ equal-interval jobs at ~the same instant handed them all the same phase.
417
+ `scheduleRecurring` now pins the first fire to `now + startDelay` via the
418
+ scheduler's `startDate`, which shifts the whole recurrence, so the same jittered
419
+ `startDelay` de-clusters checks on the Redis backend identically to the
420
+ in-memory one. Cron schedules (absolute times) are unaffected.
421
+ - **The health-check scheduler jitters each check's first fire** by a small,
422
+ deterministic fraction of its interval (stable across restarts, keyed on the
423
+ check). A synchronized set of checks now spreads across the interval instead of
424
+ hammering their targets at the same instant. Because the queue anchors the
425
+ recurrence to the first fire, this offset persists for every subsequent run.
426
+ - **The HTTP collector refreshes its TCP/TLS connect-timing probe in the
427
+ background, per origin, and never awaits it.** Bun's `fetch` already pools and
428
+ reuses connections across runs (verified: warm reuse survives 20s+ idle gaps),
429
+ but the timing probe opened a fresh handshake on EVERY run - mis-reporting the
430
+ reused request's real latency and doubling the connection count under a burst.
431
+ The probe now refreshes a per-origin sample at most once per TTL (60s) and runs
432
+ fully in the background: it is NEVER on a request's critical path. Pinned to one
433
+ resolved IP, the probe can be far slower than the reused fetch (e.g. an
434
+ intermittent IPv6 SYN retry the real request never pays), and per the collector
435
+ contract best-effort timing must never delay the check - the previous code
436
+ `await`ed it, so a slow probe's refresh run showed up as a latency outlier. The
437
+ `connect`/`tls` phases are now explicitly a cached, per-host estimate.
438
+ - **The run detail UI now labels the estimate.** The timing-breakdown caption
439
+ clarifies that DNS, wait, and transfer are measured on the request, while
440
+ connection and TLS setup are an estimate sampled from a periodic per-host probe
441
+ and cached briefly (about a minute), so an operator does not read the cached
442
+ connect/TLS value as a per-run measurement.
443
+
444
+ Behaviour is otherwise unchanged: health status and assertions are the same;
445
+ there are simply far fewer connections, the herd is spread out, and the timing
446
+ breakdown can no longer be inflated by a slow best-effort probe. No configuration
447
+ or API changes.
448
+
449
+ - d0eddc9: Cut the per-tick database work of the health-check executor by batching
450
+ scoped-database queries, and fix a dashboard "Recent activity" rendering bug.
451
+
452
+ The scoped-database proxy has to wrap every standalone query in its own
453
+ transaction so `SET LOCAL search_path` applies to it, which means a hot path
454
+ issuing many sequential queries pays the `BEGIN` / `SET LOCAL` / `COMMIT`
455
+ round-trips once per query and checks a connection out that many times. Two
456
+ changes remove most of that overhead on the health-check path:
457
+
458
+ - **New `withScopedTransaction` helper (`@checkstack/backend-api`).** A reusable
459
+ primitive for running several scoped queries under a SINGLE `SET LOCAL
460
+ search_path` transaction, plus `ScopedTransaction` / `ScopedQueryRunner`
461
+ types so a helper can accept either the scoped db or a transaction handle.
462
+ Use it on any scoped-db hot path that issues 2+ queries in sequence.
463
+ - **`getSystemHealthStatus` is now batched.** It was a `1 + N` read fan-out (one
464
+ associations query, then one run-window query per enabled check) run as `1 +
465
+ N` separate proxy transactions. It now runs as ONE transaction. This is the
466
+ hottest read on the platform - each check tick reads it several times, and the
467
+ dashboard, RPC router, and AI system-signals all call it - so the reduction in
468
+ transaction volume and connection churn is broad. The reads are also now a
469
+ single consistent snapshot.
470
+ - **The executor's run + aggregate writes are batched.** Each persisted run
471
+ previously issued the run `INSERT`, the aggregate `SELECT`, and the aggregate
472
+ `UPSERT` as three separate proxy transactions; they now run in one
473
+ transaction and commit atomically (the run and the aggregate it feeds can no
474
+ longer be persisted apart).
475
+
476
+ Behaviour is unchanged: the derived health status, transition detection, and
477
+ signals are identical; only the number of database transactions per tick drops.
478
+
479
+ Also fixes a dashboard bug where the "Recent activity" feed generated React keys
480
+ from `configurationName` plus a millisecond timestamp, so results from different
481
+ systems sharing a check name that completed in the same millisecond collided on
482
+ one key and React mis-reconciled the list (visually duplicated/omitted entries).
483
+ Keys are now derived from the system, configuration, and environment ids.
484
+
485
+ - d0eddc9: Add opt-in OpenTelemetry metrics with a Prometheus exporter so a performance
486
+ investigation can be grounded in real numbers from a running instance instead of
487
+ guesses.
488
+
489
+ The layer is **off by default and free when off**: the instruments are OTel
490
+ no-ops until a `MeterProvider` is registered, so the hot paths pay nothing until
491
+ you opt in.
492
+
493
+ - **`@checkstack/backend-api` gains an `instrumentation` module** exporting lazy,
494
+ memoized instrument accessors any plugin can record through:
495
+ `dbTransactionsCounter`, `dbQueriesCounter`, `healthcheckExecutionHistogram`,
496
+ `healthcheckPhaseHistogram`, `queueEnqueuedCounter`, `queueProcessedCounter`.
497
+ Each looks up its instrument once and is a no-op until the host registers a
498
+ provider, so callers can record unconditionally.
499
+ - **`@checkstack/backend` owns the SDK bootstrap.** `startMetrics()` registers a
500
+ global `MeterProvider` + Prometheus exporter when `CHECKSTACK_METRICS_ENABLED`
501
+ is set (host `127.0.0.1`, port `9464` by default, both overridable via
502
+ `CHECKSTACK_METRICS_HOST` / `CHECKSTACK_METRICS_PORT`). The exporter runs its
503
+ OWN HTTP server, NOT a route on the app, so it carries no app-auth surface. It
504
+ also registers host-owned observable instruments:
505
+ `checkstack.db.pool.connections` (admin/lock pool active/idle/waiting) and
506
+ `checkstack.runtime.event_loop_delay` (setInterval-drift histogram = JS-thread
507
+ block time).
508
+ - **The scoped-DB proxy records DB transactions/queries per plugin schema**, so
509
+ `db_transactions_total` minus `db_queries_total` per schema is exactly the
510
+ number of batched transactions - a live check that `withScopedTransaction`
511
+ batching is taking effect.
512
+ - **The health-check executor records execution + per-phase histograms**
513
+ (`connect`, `wait`, ...) so a high `connect` p95 with a low `wait` points at
514
+ connection establishment rather than a slow target or a CPU-bound platform.
515
+ - **The in-memory queue records enqueued/processed counters** per queue and
516
+ status.
517
+
518
+ No behaviour changes when disabled. Enable with `CHECKSTACK_METRICS_ENABLED=1`
519
+ and scrape `http://127.0.0.1:9464/metrics`. See the backend observability guide
520
+ for the full metric list and interpretation.
521
+
522
+ - Updated dependencies [8aae4e2]
523
+ - Updated dependencies [f93ee7a]
524
+ - Updated dependencies [f93ee7a]
525
+ - Updated dependencies [8aae4e2]
526
+ - Updated dependencies [d0eddc9]
527
+ - Updated dependencies [d0eddc9]
528
+ - Updated dependencies [f93ee7a]
529
+ - Updated dependencies [f93ee7a]
530
+ - Updated dependencies [d0eddc9]
531
+ - Updated dependencies [d0eddc9]
532
+ - Updated dependencies [8aae4e2]
533
+ - Updated dependencies [d0eddc9]
534
+ - Updated dependencies [f93ee7a]
535
+ - @checkstack/healthcheck-common@1.15.0
536
+ - @checkstack/common@0.22.0
537
+ - @checkstack/catalog-common@2.6.3
538
+ - @checkstack/ai-backend@0.10.9
539
+ - @checkstack/backend-api@0.31.0
540
+ - @checkstack/automation-backend@0.11.0
541
+ - @checkstack/incident-common@1.9.0
542
+ - @checkstack/incident-backend@1.11.0
543
+ - @checkstack/maintenance-common@1.9.0
544
+ - @checkstack/script-packages-backend@0.4.0
545
+ - @checkstack/satellite-backend@0.8.3
546
+ - @checkstack/sdk@0.126.1
547
+ - @checkstack/ai-common@0.6.6
548
+ - @checkstack/cache-api@0.3.19
549
+ - @checkstack/catalog-backend@1.6.9
550
+ - @checkstack/command-backend@0.2.21
551
+ - @checkstack/gitops-backend@0.5.21
552
+ - @checkstack/gitops-common@0.7.3
553
+ - @checkstack/notification-common@1.5.3
554
+ - @checkstack/queue-api@0.3.19
555
+ - @checkstack/secrets-backend@0.3.3
556
+ - @checkstack/secrets-common@0.3.2
557
+ - @checkstack/signal-common@0.2.17
558
+ - @checkstack/status-page-backend@0.4.8
559
+ - @checkstack/status-page-common@0.5.3
560
+ - @checkstack/cache-utils@0.2.24
561
+
3
562
  ## 1.17.0
4
563
 
5
564
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.17.0",
3
+ "version": "1.19.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.8",
18
- "@checkstack/ai-common": "0.6.5",
19
- "@checkstack/automation-backend": "0.10.10",
20
- "@checkstack/backend-api": "0.30.0",
21
- "@checkstack/cache-api": "0.3.18",
22
- "@checkstack/cache-utils": "0.2.23",
23
- "@checkstack/catalog-backend": "1.6.8",
24
- "@checkstack/catalog-common": "2.6.2",
25
- "@checkstack/command-backend": "0.2.20",
26
- "@checkstack/common": "0.21.0",
27
- "@checkstack/gitops-backend": "0.5.20",
28
- "@checkstack/gitops-common": "0.7.2",
29
- "@checkstack/healthcheck-common": "1.14.0",
30
- "@checkstack/incident-backend": "1.10.0",
31
- "@checkstack/incident-common": "1.8.0",
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.2",
36
- "@checkstack/script-packages-backend": "0.3.24",
37
- "@checkstack/sdk": "0.125.1",
38
- "@checkstack/secrets-backend": "0.3.2",
39
- "@checkstack/secrets-common": "0.3.1",
40
- "@checkstack/signal-common": "0.2.16",
41
- "@checkstack/status-page-backend": "0.4.7",
42
- "@checkstack/status-page-common": "0.5.2",
17
+ "@checkstack/ai-backend": "0.10.10",
18
+ "@checkstack/ai-common": "0.6.6",
19
+ "@checkstack/automation-backend": "0.11.1",
20
+ "@checkstack/backend-api": "0.31.1",
21
+ "@checkstack/cache-api": "0.3.19",
22
+ "@checkstack/cache-utils": "0.2.24",
23
+ "@checkstack/catalog-backend": "1.7.0",
24
+ "@checkstack/catalog-common": "2.7.0",
25
+ "@checkstack/command-backend": "0.2.22",
26
+ "@checkstack/common": "0.22.0",
27
+ "@checkstack/gitops-backend": "0.5.22",
28
+ "@checkstack/gitops-common": "0.7.3",
29
+ "@checkstack/healthcheck-common": "1.16.0",
30
+ "@checkstack/incident-backend": "1.12.0",
31
+ "@checkstack/incident-common": "1.10.0",
32
+ "@checkstack/maintenance-common": "1.10.0",
33
+ "@checkstack/notification-common": "1.6.0",
34
+ "@checkstack/queue-api": "0.3.19",
35
+ "@checkstack/satellite-backend": "0.8.4",
36
+ "@checkstack/script-packages-backend": "0.4.1",
37
+ "@checkstack/sdk": "0.127.1",
38
+ "@checkstack/secrets-backend": "0.3.4",
39
+ "@checkstack/secrets-common": "0.3.2",
40
+ "@checkstack/signal-common": "0.2.17",
41
+ "@checkstack/status-page-backend": "0.5.0",
42
+ "@checkstack/status-page-common": "0.6.0",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -52,13 +52,16 @@
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.54",
55
+ "@checkstack/scripts": "0.7.3",
56
+ "@checkstack/test-utils-backend": "0.1.56",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
+ "@types/pg": "^8.20.0",
59
60
  "@types/tdigest": "^0.1.5",
61
+ "bullmq": "^5.66.4",
60
62
  "date-fns": "^4.4.0",
61
63
  "drizzle-kit": "^0.31.10",
64
+ "pg": "^8.21.0",
62
65
  "typescript": "^5.0.0"
63
66
  }
64
67
  }