@checkstack/healthcheck-backend 1.9.2 → 1.10.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 CHANGED
@@ -1,5 +1,104 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8cad340: Add an AI assistant tool that lists the health checks assigned to a system.
8
+
9
+ The assistant previously had `healthcheck.status` (every check globally) but no
10
+ way to map a check to a system, so it had to guess which check monitored a given
11
+ system. It now projects `getSystemConfigurations` as the read-only tool
12
+ `healthcheck.listSystemChecks`: given a `systemId` (resolved from a name via
13
+ `catalog.listSystems`), it returns the checks assigned to that system - id, name,
14
+ strategy, interval, collectors/assertions, and paused state. The tool inherits
15
+ the source procedure's system-scoped `configuration.read` gate, so it stays
16
+ team-scoped and needs no new permission.
17
+
18
+ - 8cad340: Add a finer per-run transport timing breakdown to health checks.
19
+
20
+ Each run now records an optional structured `metadata.timings` (DNS, connect,
21
+ TLS, wait/time-to-first-byte, transfer, and a `processing` catch-all for
22
+ non-HTTP operation time). The run-detail view renders the phases it has, in
23
+ transport order, and falls back to the previous Connection + Processing split
24
+ for older runs that lack the finer data.
25
+
26
+ For HTTP the request is issued verbatim through `fetch` (original URL, headers,
27
+ and body), so request behavior is identical to a plain `fetch`. The timing is
28
+ measured around it: `fetch` resolves at the response headers, so wait
29
+ (time-to-first-byte) and transfer (body) are measured exactly on the request,
30
+ DNS is timed at the resolve step, and connect/TLS come from a short-lived,
31
+ best-effort raw `net`/`tls` probe to the same already-validated IP (the request
32
+ socket exposes no connect/handshake events on the Bun runtime). The probe is
33
+ timing-only and never fails the check. The probe validates the TLS certificate
34
+ (against the original hostname via SNI) like the real request does - it does not
35
+ disable certificate validation; an unverifiable cert simply yields no TLS-phase
36
+ timing rather than aborting. Other transports surface the connect and operation
37
+ times they already measure.
38
+
39
+ The SSRF guard now validates the resolved host (rejecting cloud-metadata /
40
+ link-local and operator-denied ranges) as a pre-flight check and no longer pins
41
+ the request to the resolved IP. Pinning rewrote the URL to the IP literal and
42
+ moved the host to the `Host` header, which breaks HTTP/2 origins (their
43
+ authority comes from the URL's `:authority`, not `Host`) - that is why real
44
+ hosts such as `google.com` started answering 404/429 instead of 200. The
45
+ pre-flight validation keeps blocking static metadata/link-local targets and
46
+ direct denied IP literals; the only thing dropped is DNS-rebind TOCTOU
47
+ protection (a narrow window that pinning closed at the cost of breaking
48
+ legitimate HTTP/2 requests).
49
+
50
+ The run-detail "slowest" badge no longer collides with the timing bar, and a
51
+ genuinely sub-millisecond phase reads as "<1 ms" instead of a bare "0 ms".
52
+
53
+ ### Patch Changes
54
+
55
+ - Updated dependencies [8cad340]
56
+ - Updated dependencies [8cad340]
57
+ - Updated dependencies [8cad340]
58
+ - Updated dependencies [8cad340]
59
+ - Updated dependencies [8cad340]
60
+ - Updated dependencies [8cad340]
61
+ - Updated dependencies [8cad340]
62
+ - Updated dependencies [8cad340]
63
+ - Updated dependencies [8cad340]
64
+ - Updated dependencies [8cad340]
65
+ - Updated dependencies [8cad340]
66
+ - Updated dependencies [8cad340]
67
+ - Updated dependencies [8cad340]
68
+ - Updated dependencies [8cad340]
69
+ - Updated dependencies [8cad340]
70
+ - Updated dependencies [8cad340]
71
+ - Updated dependencies [8cad340]
72
+ - Updated dependencies [8cad340]
73
+ - Updated dependencies [8cad340]
74
+ - Updated dependencies [8cad340]
75
+ - @checkstack/ai-backend@0.8.0
76
+ - @checkstack/ai-common@0.6.0
77
+ - @checkstack/automation-backend@0.9.3
78
+ - @checkstack/status-page-backend@0.3.0
79
+ - @checkstack/satellite-backend@0.7.0
80
+ - @checkstack/gitops-backend@0.5.12
81
+ - @checkstack/secrets-backend@0.2.12
82
+ - @checkstack/script-packages-backend@0.3.15
83
+ - @checkstack/backend-api@0.25.0
84
+ - @checkstack/notification-common@1.4.0
85
+ - @checkstack/healthcheck-common@1.8.0
86
+ - @checkstack/common@0.17.0
87
+ - @checkstack/incident-backend@1.8.3
88
+ - @checkstack/command-backend@0.2.12
89
+ - @checkstack/catalog-backend@1.5.3
90
+ - @checkstack/status-page-common@0.3.0
91
+ - @checkstack/catalog-common@2.4.2
92
+ - @checkstack/incident-common@1.6.2
93
+ - @checkstack/maintenance-common@1.7.2
94
+ - @checkstack/sdk@0.112.1
95
+ - @checkstack/cache-api@0.3.14
96
+ - @checkstack/gitops-common@0.6.5
97
+ - @checkstack/queue-api@0.3.14
98
+ - @checkstack/secrets-common@0.2.5
99
+ - @checkstack/signal-common@0.2.11
100
+ - @checkstack/cache-utils@0.2.19
101
+
3
102
  ## 1.9.2
4
103
 
5
104
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.9.2",
3
+ "version": "1.10.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/backend-api": "0.24.1",
18
- "@checkstack/ai-backend": "0.7.2",
19
- "@checkstack/ai-common": "0.5.0",
20
- "@checkstack/script-packages-backend": "0.3.14",
21
- "@checkstack/cache-api": "0.3.13",
22
- "@checkstack/cache-utils": "0.2.18",
23
- "@checkstack/catalog-backend": "1.5.2",
24
- "@checkstack/catalog-common": "2.4.1",
25
- "@checkstack/command-backend": "0.2.11",
26
- "@checkstack/common": "0.16.0",
27
- "@checkstack/gitops-backend": "0.5.11",
28
- "@checkstack/gitops-common": "0.6.4",
29
- "@checkstack/healthcheck-common": "1.7.1",
30
- "@checkstack/secrets-common": "0.2.4",
31
- "@checkstack/secrets-backend": "0.2.11",
32
- "@checkstack/incident-backend": "1.8.2",
33
- "@checkstack/incident-common": "1.6.1",
34
- "@checkstack/automation-backend": "0.9.2",
35
- "@checkstack/maintenance-common": "1.7.1",
36
- "@checkstack/notification-common": "1.3.4",
37
- "@checkstack/queue-api": "0.3.13",
38
- "@checkstack/satellite-backend": "0.6.15",
39
- "@checkstack/sdk": "0.111.0",
40
- "@checkstack/signal-common": "0.2.10",
41
- "@checkstack/status-page-backend": "0.2.1",
42
- "@checkstack/status-page-common": "0.2.0",
17
+ "@checkstack/backend-api": "0.25.0",
18
+ "@checkstack/ai-backend": "0.8.0",
19
+ "@checkstack/ai-common": "0.6.0",
20
+ "@checkstack/script-packages-backend": "0.3.15",
21
+ "@checkstack/cache-api": "0.3.14",
22
+ "@checkstack/cache-utils": "0.2.19",
23
+ "@checkstack/catalog-backend": "1.5.3",
24
+ "@checkstack/catalog-common": "2.4.2",
25
+ "@checkstack/command-backend": "0.2.12",
26
+ "@checkstack/common": "0.17.0",
27
+ "@checkstack/gitops-backend": "0.5.12",
28
+ "@checkstack/gitops-common": "0.6.5",
29
+ "@checkstack/healthcheck-common": "1.8.0",
30
+ "@checkstack/secrets-common": "0.2.5",
31
+ "@checkstack/secrets-backend": "0.2.12",
32
+ "@checkstack/incident-backend": "1.8.3",
33
+ "@checkstack/incident-common": "1.6.2",
34
+ "@checkstack/automation-backend": "0.9.3",
35
+ "@checkstack/maintenance-common": "1.7.2",
36
+ "@checkstack/notification-common": "1.4.0",
37
+ "@checkstack/queue-api": "0.3.14",
38
+ "@checkstack/satellite-backend": "0.7.0",
39
+ "@checkstack/sdk": "0.112.1",
40
+ "@checkstack/signal-common": "0.2.11",
41
+ "@checkstack/status-page-backend": "0.3.0",
42
+ "@checkstack/status-page-common": "0.3.0",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "drizzle-orm": "^0.45.0",
45
45
  "hono": "^4.12.25",
@@ -50,9 +50,9 @@
50
50
  "@orpc/server": "^1.14.4"
51
51
  },
52
52
  "devDependencies": {
53
- "@checkstack/drizzle-helper": "0.0.5",
54
- "@checkstack/scripts": "0.6.2",
55
- "@checkstack/test-utils-backend": "0.1.45",
53
+ "@checkstack/drizzle-helper": "0.0.6",
54
+ "@checkstack/scripts": "0.6.3",
55
+ "@checkstack/test-utils-backend": "0.1.46",
56
56
  "@checkstack/tsconfig": "0.0.7",
57
57
  "@types/bun": "^1.0.0",
58
58
  "@types/tdigest": "^0.1.5",
@@ -35,6 +35,42 @@ describe("healthcheck.status projection", () => {
35
35
  });
36
36
  });
37
37
 
38
+ // The per-system projection is how the assistant maps a check to a system:
39
+ // `healthcheck.status` lists every check globally with no system attribution, so
40
+ // it must call this with a `systemId` (resolved via catalog.listSystems) to know
41
+ // which checks are assigned to that system. Gated by the system-scoped
42
+ // `configuration.read` rule, NOT the chat transport gate.
43
+ describe("healthcheck.listSystemChecks projection", () => {
44
+ const tool = buildProjectedTool({
45
+ procedure: healthCheckContract.getSystemConfigurations,
46
+ sourcePluginMetadata: pluginMetadata,
47
+ procedureKey: "getSystemConfigurations",
48
+ name: "healthcheck.listSystemChecks",
49
+ description: "List the health checks assigned to a system. Read-only.",
50
+ effect: "read",
51
+ execute: deferredProjectionExecute,
52
+ });
53
+
54
+ test("uses the overridden tool name", () => {
55
+ expect(tool.name).toBe("healthcheck.listSystemChecks");
56
+ });
57
+
58
+ test("is classified as a read-only effect", () => {
59
+ expect(tool.effect).toBe("read");
60
+ });
61
+
62
+ test("exposes the systemId input to the model", () => {
63
+ const inputShape = tool.input as { shape?: Record<string, unknown> };
64
+ expect(inputShape.shape).toBeDefined();
65
+ expect(inputShape.shape?.["systemId"]).toBeDefined();
66
+ });
67
+
68
+ test("inherits the source procedure's access rules, not the chat gate", () => {
69
+ expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
70
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
71
+ });
72
+ });
73
+
38
74
  // The run-history projection lets the assistant answer timeline / root-cause
39
75
  // questions ("what issues did system X have between T1 and T2"). It must expose
40
76
  // the filter inputs (systemId, date window, statusFilter) and be gated by the
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ HEALTH_ENTITY_ID_SEPARATOR,
4
+ encodeHealthEntityId,
5
+ parseHealthEntityId,
6
+ } from "./health-entity-id";
7
+
8
+ describe("encodeHealthEntityId", () => {
9
+ it("yields the bare system id when environmentId is undefined", () => {
10
+ expect(encodeHealthEntityId({ systemId: "sys-1" })).toBe("sys-1");
11
+ });
12
+
13
+ it("yields the bare system id when environmentId is null", () => {
14
+ expect(encodeHealthEntityId({ systemId: "sys-1", environmentId: null })).toBe(
15
+ "sys-1",
16
+ );
17
+ });
18
+
19
+ it("joins system and environment with the double-colon separator", () => {
20
+ expect(
21
+ encodeHealthEntityId({ systemId: "sys-1", environmentId: "prod" }),
22
+ ).toBe(`sys-1${HEALTH_ENTITY_ID_SEPARATOR}prod`);
23
+ expect(HEALTH_ENTITY_ID_SEPARATOR).toBe("::");
24
+ });
25
+
26
+ it("treats an empty-string environmentId as concrete (not a rollup)", () => {
27
+ // "" is neither null nor undefined, so it encodes as a per-env id.
28
+ expect(encodeHealthEntityId({ systemId: "sys-1", environmentId: "" })).toBe(
29
+ "sys-1::",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("parseHealthEntityId", () => {
35
+ it("decodes a bare id as the system rollup (environmentId null)", () => {
36
+ expect(parseHealthEntityId("sys-1")).toEqual({
37
+ systemId: "sys-1",
38
+ environmentId: null,
39
+ });
40
+ });
41
+
42
+ it("decodes a per-env id into system and environment", () => {
43
+ expect(parseHealthEntityId("sys-1::prod")).toEqual({
44
+ systemId: "sys-1",
45
+ environmentId: "prod",
46
+ });
47
+ });
48
+
49
+ it("splits on the FIRST separator so a later '::' stays in the env id", () => {
50
+ expect(parseHealthEntityId("sys-1::a::b")).toEqual({
51
+ systemId: "sys-1",
52
+ environmentId: "a::b",
53
+ });
54
+ });
55
+
56
+ it("yields an empty environmentId for a trailing separator", () => {
57
+ expect(parseHealthEntityId("sys-1::")).toEqual({
58
+ systemId: "sys-1",
59
+ environmentId: "",
60
+ });
61
+ });
62
+
63
+ it("round-trips an encoded per-env id", () => {
64
+ const encoded = encodeHealthEntityId({
65
+ systemId: "sys-9",
66
+ environmentId: "staging",
67
+ });
68
+ expect(parseHealthEntityId(encoded)).toEqual({
69
+ systemId: "sys-9",
70
+ environmentId: "staging",
71
+ });
72
+ });
73
+ });
package/src/index.ts CHANGED
@@ -314,6 +314,34 @@ export default createBackendPlugin({
314
314
  execute: deferredProjectionExecute,
315
315
  });
316
316
 
317
+ // The per-system ASSIGNMENT of checks: given a `systemId`, list the
318
+ // health checks assigned to that system (id, name, strategy, interval,
319
+ // collectors/assertions, paused state). This is the mapping
320
+ // `healthcheck.status` lacks - `getConfigurations` lists every check
321
+ // GLOBALLY with no system attribution, so from it alone the assistant
322
+ // cannot tell WHICH check monitors a given system (it would have to
323
+ // guess). The assistant resolves a system NAME to its id via
324
+ // catalog.listSystems, then calls this to attribute a named/failing
325
+ // check to the right system. Same system-scoped `configuration.read`
326
+ // gate as the dashboard's per-system configuration view.
327
+ env.getExtensionPoint(aiToolProjectionExtensionPoint).expose({
328
+ procedure: healthCheckContract.getSystemConfigurations,
329
+ sourcePluginMetadata: pluginMetadata,
330
+ procedureKey: "getSystemConfigurations",
331
+ name: "healthcheck.listSystemChecks",
332
+ description:
333
+ "List the health checks ASSIGNED to a specific system, given its " +
334
+ "`systemId` (resolve a system NAME to its id with " +
335
+ "catalog.listSystems first). Returns each assigned check's id, " +
336
+ "name, strategy, interval, collectors/assertions, and paused " +
337
+ "state. Use this to answer 'which checks monitor system X' or to " +
338
+ "attribute a specific or failing check to a system - " +
339
+ "healthcheck.status lists ALL checks globally WITHOUT the " +
340
+ "system mapping. Read-only.",
341
+ effect: "read",
342
+ execute: deferredProjectionExecute,
343
+ });
344
+
317
345
  // Historical run-by-run check results, filterable by system, time
318
346
  // window, and status. Complements `healthcheck.status` (which is only
319
347
  // CURRENT state): this is how the assistant answers "what issues did
@@ -8,10 +8,12 @@ import {
8
8
  type BaseStrategyConfig,
9
9
  type ConnectedClient,
10
10
  type TransportClient,
11
+ type TransportTimings,
11
12
  type CollectorRunContext,
12
13
  type AdvisoryLockService,
13
14
  renderTemplatableConfig,
14
15
  } from "@checkstack/backend-api";
16
+ import type { RunTimings } from "@checkstack/healthcheck-common";
15
17
  import { QueueManager } from "@checkstack/queue-api";
16
18
  import {
17
19
  healthCheckConfigurations,
@@ -91,6 +93,39 @@ function toHealthEntityView(state: AggregatedHealth): HealthEntityState {
91
93
  };
92
94
  }
93
95
 
96
+ /** The known transport timing phase keys, in transport order. */
97
+ const RUN_TIMING_KEYS = [
98
+ "dnsMs",
99
+ "connectMs",
100
+ "tlsMs",
101
+ "waitMs",
102
+ "transferMs",
103
+ "processingMs",
104
+ ] as const;
105
+
106
+ /**
107
+ * Build the run's `metadata.timings` from a connected client's surfaced
108
+ * transport timings, keeping only present, finite, non-negative phases. Returns
109
+ * `undefined` when no usable phase was measured so the field is omitted (old
110
+ * runs and single-phase strategies stay on the coarse fallback in the UI).
111
+ */
112
+ function extractRunTimings(
113
+ connectedClient: ConnectedClient<TransportClient<never, unknown>> | undefined,
114
+ ): RunTimings | undefined {
115
+ const raw: TransportTimings | undefined = connectedClient?.timings;
116
+ if (!raw) return undefined;
117
+ const result: RunTimings = {};
118
+ let any = false;
119
+ for (const key of RUN_TIMING_KEYS) {
120
+ const value = raw[key];
121
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
122
+ result[key] = value;
123
+ any = true;
124
+ }
125
+ }
126
+ return any ? result : undefined;
127
+ }
128
+
94
129
  /**
95
130
  * Emit the checkCompleted hook if available, plus the narrower
96
131
  * `checkFailed` hook when the result wasn't `healthy` (so operators
@@ -1003,6 +1038,12 @@ async function executeHealthCheckJob(props: {
1003
1038
  const status = hasCollectorError ? "unhealthy" : "healthy";
1004
1039
  const totalLatencyMs = Math.round(performance.now() - start);
1005
1040
 
1041
+ // Lift the strategy's structured transport timings (DNS / connect / TLS /
1042
+ // wait / transfer / processing) into the run metadata when the connected
1043
+ // client surfaced any. Strategies that cannot measure sub-phases leave this
1044
+ // undefined and the frontend falls back to the coarse connection split.
1045
+ const timings = extractRunTimings(connectedClient);
1046
+
1006
1047
  const result = {
1007
1048
  status: status as "healthy" | "unhealthy",
1008
1049
  latencyMs: totalLatencyMs,
@@ -1012,6 +1053,7 @@ async function executeHealthCheckJob(props: {
1012
1053
  metadata: {
1013
1054
  connected: true,
1014
1055
  connectionTimeMs,
1056
+ ...(timings ? { timings } : {}),
1015
1057
  collectors: collectorResults,
1016
1058
  },
1017
1059
  };