@checkstack/healthcheck-backend 1.6.7 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/package.json +16 -16
- package/src/ai/system-signals-contributor.test.ts +86 -0
- package/src/ai/system-signals-contributor.ts +46 -0
- package/src/index.ts +24 -2
- package/src/service.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0b6f01b: feat(healthcheck): contribute health problems to the backend system.issues aggregator
|
|
8
|
+
|
|
9
|
+
The healthcheck plugin now registers a `system.issues` contributor (sourceId
|
|
10
|
+
`healthcheck`) from its backend `init`, so the AI assistant surfaces degraded
|
|
11
|
+
and unhealthy systems alongside incidents, SLOs, anomalies, and dependency
|
|
12
|
+
problems.
|
|
13
|
+
|
|
14
|
+
The contributor enforces its own `healthcheck.status` access gate (returning an
|
|
15
|
+
empty map - never throwing - when the principal lacks access; service users get
|
|
16
|
+
no signals), then reads the current problem rows for every system from the
|
|
17
|
+
shared, durable `health_check_runs` / `system_health_checks` tables via a new
|
|
18
|
+
global `getAllUnhealthySystemStatuses` service method (every system with an
|
|
19
|
+
enabled check association, evaluated with the same per-system evaluator the
|
|
20
|
+
dashboard uses, healthy systems omitted). The answer is therefore identical on
|
|
21
|
+
every pod, and only systems with a current problem appear in the result.
|
|
22
|
+
|
|
23
|
+
The row->signal mapping (source/tone/label/detail/href/accessRule/iconName) is
|
|
24
|
+
extracted into a new pure `deriveHealthcheckSignals` deriver in
|
|
25
|
+
`@checkstack/healthcheck-common`, shared by both the backend contributor and the
|
|
26
|
+
frontend `HealthSignalsFiller` so the two surfaces stay in lockstep. The
|
|
27
|
+
frontend filler now delegates to that deriver with unchanged behavior.
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- dbb76a2: fix(ai): guide the assistant to find all issues and fix the anomaly tool
|
|
32
|
+
|
|
33
|
+
Two assistant problems reported in production:
|
|
34
|
+
|
|
35
|
+
1. Asked "are there any issues?", the model answered from a single source (an
|
|
36
|
+
SLO breach) and missed a system with a failing health check. The chat
|
|
37
|
+
system prompt now instructs the model to check ALL issue sources before
|
|
38
|
+
answering - failing health checks (`healthcheck_status`), breaching/at-risk
|
|
39
|
+
SLOs (`slo_listObjectives`), active anomalies (`anomaly_list`), and open
|
|
40
|
+
incidents (`incident_list`) - and not to stop after the first source. It
|
|
41
|
+
also tells the model that `systemId` must be a real system UUID (resolve a
|
|
42
|
+
name via the catalog tool first) and to never invent ids or filter values.
|
|
43
|
+
|
|
44
|
+
2. The anomaly tool was named `anomaly.explain` but actually LISTS anomalies
|
|
45
|
+
with optional filters. The misleading name led the model to pass a
|
|
46
|
+
non-existent filter value ("Type validation failed") and a system
|
|
47
|
+
name/anomaly id as `systemId` ("a value was malformed"). Renamed to
|
|
48
|
+
`anomaly.list` with a description that spells out the optional filters and
|
|
49
|
+
their valid enum values (state: suspicious|anomaly|recovered, kind:
|
|
50
|
+
spike|drift, suppression: active|suppressed|all) and that `systemId` is a
|
|
51
|
+
system UUID.
|
|
52
|
+
|
|
53
|
+
Also sharpened the `healthcheck.status` and `slo.listObjectives` tool
|
|
54
|
+
descriptions to be use-case oriented ("use when asked what is failing /
|
|
55
|
+
breaching").
|
|
56
|
+
|
|
57
|
+
BREAKING: the anomaly read tool's name changes from `anomaly_explain` to
|
|
58
|
+
`anomaly_list` over the MCP `tools/list` surface. MCP clients referencing it by
|
|
59
|
+
the old name must update.
|
|
60
|
+
|
|
61
|
+
- Updated dependencies [dbb76a2]
|
|
62
|
+
- Updated dependencies [0b6f01b]
|
|
63
|
+
- Updated dependencies [0b6f01b]
|
|
64
|
+
- Updated dependencies [0b6f01b]
|
|
65
|
+
- @checkstack/ai-backend@0.3.0
|
|
66
|
+
- @checkstack/healthcheck-common@1.6.0
|
|
67
|
+
- @checkstack/incident-backend@1.7.0
|
|
68
|
+
- @checkstack/incident-common@1.5.0
|
|
69
|
+
- @checkstack/maintenance-common@1.5.0
|
|
70
|
+
- @checkstack/automation-backend@0.5.8
|
|
71
|
+
- @checkstack/catalog-backend@1.4.8
|
|
72
|
+
- @checkstack/satellite-backend@0.6.8
|
|
73
|
+
- @checkstack/sdk@0.103.1
|
|
74
|
+
- @checkstack/backend-api@0.21.6
|
|
75
|
+
- @checkstack/script-packages-backend@0.3.7
|
|
76
|
+
- @checkstack/command-backend@0.2.6
|
|
77
|
+
- @checkstack/gitops-backend@0.5.6
|
|
78
|
+
- @checkstack/secrets-backend@0.2.6
|
|
79
|
+
|
|
3
80
|
## 1.6.7
|
|
4
81
|
|
|
5
82
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,29 +14,29 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.21.
|
|
18
|
-
"@checkstack/ai-backend": "0.
|
|
17
|
+
"@checkstack/backend-api": "0.21.6",
|
|
18
|
+
"@checkstack/ai-backend": "0.3.0",
|
|
19
19
|
"@checkstack/ai-common": "0.1.3",
|
|
20
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
20
|
+
"@checkstack/script-packages-backend": "0.3.7",
|
|
21
21
|
"@checkstack/cache-api": "0.3.12",
|
|
22
22
|
"@checkstack/cache-utils": "0.2.17",
|
|
23
|
-
"@checkstack/catalog-backend": "1.4.
|
|
23
|
+
"@checkstack/catalog-backend": "1.4.8",
|
|
24
24
|
"@checkstack/catalog-common": "2.3.4",
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
25
|
+
"@checkstack/command-backend": "0.2.6",
|
|
26
26
|
"@checkstack/common": "0.15.0",
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.6",
|
|
28
28
|
"@checkstack/gitops-common": "0.6.3",
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
29
|
+
"@checkstack/healthcheck-common": "1.6.0",
|
|
30
30
|
"@checkstack/secrets-common": "0.2.3",
|
|
31
|
-
"@checkstack/secrets-backend": "0.2.
|
|
32
|
-
"@checkstack/incident-backend": "1.
|
|
33
|
-
"@checkstack/incident-common": "1.
|
|
34
|
-
"@checkstack/automation-backend": "0.5.
|
|
35
|
-
"@checkstack/maintenance-common": "1.
|
|
31
|
+
"@checkstack/secrets-backend": "0.2.6",
|
|
32
|
+
"@checkstack/incident-backend": "1.7.0",
|
|
33
|
+
"@checkstack/incident-common": "1.5.0",
|
|
34
|
+
"@checkstack/automation-backend": "0.5.8",
|
|
35
|
+
"@checkstack/maintenance-common": "1.5.0",
|
|
36
36
|
"@checkstack/notification-common": "1.3.3",
|
|
37
37
|
"@checkstack/queue-api": "0.3.12",
|
|
38
|
-
"@checkstack/satellite-backend": "0.6.
|
|
39
|
-
"@checkstack/sdk": "0.
|
|
38
|
+
"@checkstack/satellite-backend": "0.6.8",
|
|
39
|
+
"@checkstack/sdk": "0.103.1",
|
|
40
40
|
"@checkstack/signal-common": "0.2.9",
|
|
41
41
|
"@hono/zod-validator": "^0.7.6",
|
|
42
42
|
"drizzle-orm": "^0.45.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
52
52
|
"@checkstack/scripts": "0.6.1",
|
|
53
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
53
|
+
"@checkstack/test-utils-backend": "0.1.40",
|
|
54
54
|
"@checkstack/tsconfig": "0.0.7",
|
|
55
55
|
"@types/bun": "^1.0.0",
|
|
56
56
|
"@types/tdigest": "^0.1.5",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
HEALTHCHECK_SIGNAL_SOURCE_ID,
|
|
4
|
+
type HealthcheckSignalStatuses,
|
|
5
|
+
} from "@checkstack/healthcheck-common";
|
|
6
|
+
import type { AuthUser } from "@checkstack/backend-api";
|
|
7
|
+
import type { SystemAccessResolver } from "@checkstack/ai-backend";
|
|
8
|
+
import {
|
|
9
|
+
createHealthcheckSignalsContributor,
|
|
10
|
+
type HealthcheckSignalsSource,
|
|
11
|
+
} from "./system-signals-contributor";
|
|
12
|
+
|
|
13
|
+
const unhealthyStatuses: HealthcheckSignalStatuses = {
|
|
14
|
+
s1: {
|
|
15
|
+
status: "unhealthy",
|
|
16
|
+
evaluatedAt: new Date(),
|
|
17
|
+
checkStatuses: [
|
|
18
|
+
{
|
|
19
|
+
configurationId: "c1",
|
|
20
|
+
configurationName: "Ping",
|
|
21
|
+
status: "unhealthy",
|
|
22
|
+
runsConsidered: 5,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function sourceReturning(statuses: HealthcheckSignalStatuses): {
|
|
29
|
+
source: HealthcheckSignalsSource;
|
|
30
|
+
} {
|
|
31
|
+
return { source: { getAllUnhealthySystemStatuses: async () => statuses } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The per-source gate is owned/tested by createGatedSystemSignalsContributor;
|
|
35
|
+
// these resolver stubs let us focus on this plugin's wiring.
|
|
36
|
+
const allowAll: SystemAccessResolver = {
|
|
37
|
+
accessibleSystemIds: async ({ systemIds }) => systemIds,
|
|
38
|
+
};
|
|
39
|
+
const denyAll: SystemAccessResolver = { accessibleSystemIds: async () => [] };
|
|
40
|
+
const userWith = (accessRules: string[]): AuthUser => ({
|
|
41
|
+
type: "user",
|
|
42
|
+
id: "u1",
|
|
43
|
+
accessRules,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("createHealthcheckSignalsContributor", () => {
|
|
47
|
+
it("exposes the shared source id", () => {
|
|
48
|
+
const { source } = sourceReturning({});
|
|
49
|
+
const contributor = createHealthcheckSignalsContributor({
|
|
50
|
+
service: source,
|
|
51
|
+
resolver: allowAll,
|
|
52
|
+
});
|
|
53
|
+
expect(contributor.sourceId).toBe(HEALTHCHECK_SIGNAL_SOURCE_ID);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("wires the service + shared deriver for an authorized principal", async () => {
|
|
57
|
+
const { source } = sourceReturning(unhealthyStatuses);
|
|
58
|
+
const contributor = createHealthcheckSignalsContributor({
|
|
59
|
+
service: source,
|
|
60
|
+
resolver: allowAll,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await contributor.read({
|
|
64
|
+
principal: userWith(["healthcheck.healthcheck.status.read"]),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(Object.keys(result.signals)).toEqual(["s1"]);
|
|
68
|
+
expect(result.signals.s1[0]).toMatchObject({
|
|
69
|
+
source: HEALTHCHECK_SIGNAL_SOURCE_ID,
|
|
70
|
+
tone: "error",
|
|
71
|
+
label: "Unhealthy",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("routes a non-global user through the team gate (no grants -> nothing)", async () => {
|
|
76
|
+
const { source } = sourceReturning(unhealthyStatuses);
|
|
77
|
+
const contributor = createHealthcheckSignalsContributor({
|
|
78
|
+
service: source,
|
|
79
|
+
resolver: denyAll,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = await contributor.read({ principal: userWith([]) });
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({ accessible: false, signals: {} });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveHealthcheckSignals,
|
|
3
|
+
healthCheckAccess,
|
|
4
|
+
HEALTHCHECK_SIGNAL_SOURCE_ID,
|
|
5
|
+
type HealthcheckSignalStatuses,
|
|
6
|
+
} from "@checkstack/healthcheck-common";
|
|
7
|
+
import {
|
|
8
|
+
createGatedSystemSignalsContributor,
|
|
9
|
+
type SystemAccessResolver,
|
|
10
|
+
type SystemSignalsContributor,
|
|
11
|
+
} from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The slice of `HealthCheckService` this contributor needs: a global,
|
|
15
|
+
* pod-independent scan of every currently-degraded/unhealthy system. Narrowed
|
|
16
|
+
* to an interface so the contributor is unit-testable without constructing the
|
|
17
|
+
* full service (or a database).
|
|
18
|
+
*/
|
|
19
|
+
export interface HealthcheckSignalsSource {
|
|
20
|
+
getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the healthcheck contributor for the AI `system.issues` aggregator. Reads
|
|
25
|
+
* the global durable scan of unhealthy systems and runs the SAME deriver the
|
|
26
|
+
* dashboard filler uses. The per-source access gate (global `healthcheck.status`
|
|
27
|
+
* plus per-system team grants) is applied by
|
|
28
|
+
* {@link createGatedSystemSignalsContributor}.
|
|
29
|
+
*/
|
|
30
|
+
export function createHealthcheckSignalsContributor({
|
|
31
|
+
service,
|
|
32
|
+
resolver,
|
|
33
|
+
}: {
|
|
34
|
+
service: HealthcheckSignalsSource;
|
|
35
|
+
resolver: SystemAccessResolver;
|
|
36
|
+
}): SystemSignalsContributor {
|
|
37
|
+
return createGatedSystemSignalsContributor({
|
|
38
|
+
sourceId: HEALTHCHECK_SIGNAL_SOURCE_ID,
|
|
39
|
+
accessRule: healthCheckAccess.status,
|
|
40
|
+
resolver,
|
|
41
|
+
readSignals: async () =>
|
|
42
|
+
deriveHealthcheckSignals({
|
|
43
|
+
statuses: await service.getAllUnhealthySystemStatuses(),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,8 +21,11 @@ import {
|
|
|
21
21
|
aiToolExtensionPoint,
|
|
22
22
|
aiToolProjectionExtensionPoint,
|
|
23
23
|
deferredProjectionExecute,
|
|
24
|
+
systemSignalsExtensionPoint,
|
|
25
|
+
createSystemAccessResolver,
|
|
24
26
|
} from "@checkstack/ai-backend";
|
|
25
27
|
import { buildHealthcheckAiTools } from "./ai/register-ai-tools";
|
|
28
|
+
import { createHealthcheckSignalsContributor } from "./ai/system-signals-contributor";
|
|
26
29
|
import {
|
|
27
30
|
createBackendPlugin,
|
|
28
31
|
coreServices,
|
|
@@ -234,11 +237,12 @@ export default createBackendPlugin({
|
|
|
234
237
|
// computes each system's aggregate from durable `health_check_runs`,
|
|
235
238
|
// and the queue worker (set up just below — the only mutation site)
|
|
236
239
|
// drives writes through `handle.mutate`.
|
|
237
|
-
|
|
240
|
+
const service = new HealthCheckService(
|
|
238
241
|
database,
|
|
239
242
|
healthCheckRegistry,
|
|
240
243
|
collectorRegistry,
|
|
241
244
|
);
|
|
245
|
+
healthEntityService = service;
|
|
242
246
|
|
|
243
247
|
// Register this plugin's AI tools (propose/update/delete) into the AI
|
|
244
248
|
// registry via the extension point - owned here, not in ai-backend.
|
|
@@ -259,11 +263,29 @@ export default createBackendPlugin({
|
|
|
259
263
|
procedureKey: "getConfigurations",
|
|
260
264
|
name: "healthcheck.status",
|
|
261
265
|
description:
|
|
262
|
-
"List health
|
|
266
|
+
"List systems' health checks and their current status, including " +
|
|
267
|
+
"which are failing/unhealthy. Use this when asked what is down, " +
|
|
268
|
+
"failing, or unhealthy. Read-only.",
|
|
263
269
|
effect: "read",
|
|
264
270
|
execute: deferredProjectionExecute,
|
|
265
271
|
});
|
|
266
272
|
|
|
273
|
+
// Contribute this plugin's per-system health problems to the AI
|
|
274
|
+
// `system.issues` aggregator. PER-SOURCE access is OUR job: gate on the
|
|
275
|
+
// principal's `healthcheck.status` grant and return {} (never throw)
|
|
276
|
+
// when not satisfied. The read derives from the durable
|
|
277
|
+
// `health_check_runs` / `system_health_checks` tables (global, identical
|
|
278
|
+
// on every pod) and reuses the SAME pure deriver as the dashboard
|
|
279
|
+
// filler, so backend signals match the UI's source/tone/label/detail.
|
|
280
|
+
env
|
|
281
|
+
.getExtensionPoint(systemSignalsExtensionPoint)
|
|
282
|
+
.contribute(
|
|
283
|
+
createHealthcheckSignalsContributor({
|
|
284
|
+
service,
|
|
285
|
+
resolver: createSystemAccessResolver(rpcClient),
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
|
|
267
289
|
// Create catalog client for notification delegation
|
|
268
290
|
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
269
291
|
|
package/src/service.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
NotificationPolicySchema,
|
|
11
11
|
DEFAULT_NOTIFICATION_POLICY,
|
|
12
12
|
type CollectorConfigEntry,
|
|
13
|
+
type HealthcheckSignalStatuses,
|
|
13
14
|
} from "@checkstack/healthcheck-common";
|
|
14
15
|
import type { ConfigService } from "@checkstack/backend-api";
|
|
15
16
|
import type { InferClient } from "@checkstack/common";
|
|
@@ -624,6 +625,36 @@ export class HealthCheckService {
|
|
|
624
625
|
};
|
|
625
626
|
}
|
|
626
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Global problem scan across EVERY system that has at least one enabled
|
|
630
|
+
* health-check association. Returns the evaluated status keyed by systemId,
|
|
631
|
+
* containing ONLY systems that are currently degraded or unhealthy (healthy
|
|
632
|
+
* systems are omitted). This is the read source for the AI system-signals
|
|
633
|
+
* contributor: it must answer the same on every pod, so it derives entirely
|
|
634
|
+
* from the durable `health_check_runs` / `system_health_checks` tables via
|
|
635
|
+
* the same per-system evaluator the dashboard uses - no per-caller systemId
|
|
636
|
+
* list and no process-local state.
|
|
637
|
+
*/
|
|
638
|
+
async getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses> {
|
|
639
|
+
// Distinct systemIds that have at least one ENABLED check association.
|
|
640
|
+
// `getSystemHealthStatus` already short-circuits to healthy for systems
|
|
641
|
+
// with no enabled associations, so this is the complete candidate set.
|
|
642
|
+
const rows = await this.db
|
|
643
|
+
.selectDistinct({ systemId: systemHealthChecks.systemId })
|
|
644
|
+
.from(systemHealthChecks)
|
|
645
|
+
.where(eq(systemHealthChecks.enabled, true));
|
|
646
|
+
|
|
647
|
+
const result: HealthcheckSignalStatuses = {};
|
|
648
|
+
await Promise.all(
|
|
649
|
+
rows.map(async ({ systemId }) => {
|
|
650
|
+
const status = await this.getSystemHealthStatus(systemId);
|
|
651
|
+
if (status.status === "healthy") return; // problems only
|
|
652
|
+
result[systemId] = status;
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
627
658
|
/**
|
|
628
659
|
* Live health-state snapshot for a single system (Wave-2 sensing
|
|
629
660
|
* contract). When `configurationId` is given, status reflects that
|