@checkstack/healthcheck-script-backend 0.7.13 → 0.8.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,94 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8cad340: Fix collectors hard-failing on successful-but-non-OK application results.
8
+
9
+ A health-check collector must fail only when the TRANSPORT fails (the probe
10
+ could not complete: DNS/connect/TLS failure, timeout, aborted, unspawnable
11
+ process). A successfully-received result that is simply "not what you hoped" is
12
+ an assertable metric, not a collector failure - the user's assertions (or the
13
+ no-assertion default) decide health.
14
+
15
+ BREAKING CHANGE: checks that previously relied on a collector auto-failing on a
16
+ non-OK result will now report healthy unless an explicit assertion is added.
17
+ Affected collectors:
18
+
19
+ - HTTP request collector: a received response (including 4xx/5xx) is now a
20
+ successful collection. `statusCode` / `statusText` / `success` are exposed as
21
+ metrics; the collector no longer sets `error` on a non-2xx. Add a
22
+ `statusCode equals 200` assertion to fail on non-200 (or `statusCode equals
23
+ 404` for a check that wants a 404). Only a real transport failure fails the
24
+ collector.
25
+ - gRPC health collector: a completed health RPC returning `NOT_SERVING` /
26
+ `SERVICE_UNKNOWN` / `UNKNOWN` is now a successful collection. `serving` /
27
+ `status` are assertable metrics; only a real RPC transport error fails the
28
+ collector.
29
+ - Jenkins node-health collector: offline nodes are now an assertable metric
30
+ (`offlineNodes`); a successful all-nodes API call no longer fails the
31
+ collector when some nodes are offline.
32
+ - Script (shell) execute collector: a non-zero exit code is now an assertable
33
+ metric (`exitCode` / `success`); the collector no longer hard-fails on a
34
+ non-zero exit. A timeout or a script that could not be spawned still fails the
35
+ collector (those are transport failures). Add a `success is true` (or
36
+ `exitCode equals 0`) assertion to fail on a non-zero exit.
37
+
38
+ Other strategies (DNS, TCP, TLS, ping, ssh, mysql, postgres, redis, rcon,
39
+ hardware, and the inline-script collector) were audited and already failed only
40
+ on genuine transport failures.
41
+
42
+ ### Patch Changes
43
+
44
+ - 8cad340: Retune anomaly-detection defaults across every health-check strategy and the
45
+ hardware collector for a low-noise, problem-focused out-of-the-box experience.
46
+
47
+ The detection engine already learns a per-metric baseline, debounces with a
48
+ confirmation window, and applies practical-significance floors. This pass tunes
49
+ the per-metric **defaults** so a fresh install alerts only on genuine,
50
+ statistically-significant, problem-mapping deviations instead of flooding on
51
+ every metric that wiggles. 264 metrics were reviewed:
52
+
53
+ - **Default-disabled** the high-noise and un-baselineable classes that were
54
+ alerting for no good reason: raw identifiers and counts (status codes, error
55
+ and row counts, build counts, player and executor counts), config echoes and
56
+ near-constants (probe packet counts, CPU core count, total/swap memory),
57
+ payload-size and other run-to-run-volatile values, and deterministic values
58
+ like certificate days-remaining (governed by the check's own static-threshold
59
+ health logic, not statistics). These stay chartable and can be re-enabled per
60
+ field.
61
+ - **Hardened** the signals that should alert - latency/response/execution time
62
+ and availability/success/saturation percentages - with confirmation windows
63
+ and absolute + relative floors so brief spikes and sub-threshold jitter no
64
+ longer flap, and prefer percentage metrics over their absolute twins.
65
+
66
+ No detection-engine or schema changes; only per-metric `x-anomaly-*` defaults.
67
+ Users who had opted into any now-disabled metric keep their explicit override.
68
+
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
+ - Updated dependencies [8cad340]
76
+ - Updated dependencies [8cad340]
77
+ - Updated dependencies [8cad340]
78
+ - @checkstack/script-packages-backend@0.3.15
79
+ - @checkstack/backend-api@0.25.0
80
+ - @checkstack/healthcheck-common@1.8.0
81
+ - @checkstack/common@0.17.0
82
+ - @checkstack/secrets-common@0.2.5
83
+
84
+ ## 0.7.14
85
+
86
+ ### Patch Changes
87
+
88
+ - Updated dependencies [2ec8f64]
89
+ - @checkstack/backend-api@0.24.1
90
+ - @checkstack/script-packages-backend@0.3.14
91
+
3
92
  ## 0.7.13
4
93
 
5
94
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.7.13",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,17 +14,17 @@
14
14
  "pack": "bunx @checkstack/scripts plugin-pack"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.24.0",
18
- "@checkstack/common": "0.16.0",
19
- "@checkstack/healthcheck-common": "1.7.1",
20
- "@checkstack/script-packages-backend": "0.3.13",
21
- "@checkstack/secrets-common": "0.2.4"
17
+ "@checkstack/backend-api": "0.25.0",
18
+ "@checkstack/common": "0.17.0",
19
+ "@checkstack/healthcheck-common": "1.8.0",
20
+ "@checkstack/script-packages-backend": "0.3.15",
21
+ "@checkstack/secrets-common": "0.2.5"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/bun": "^1.0.0",
25
25
  "typescript": "^5.0.0",
26
26
  "@checkstack/tsconfig": "0.0.7",
27
- "@checkstack/scripts": "0.6.2"
27
+ "@checkstack/scripts": "0.6.3"
28
28
  },
29
29
  "description": "Checkstack healthcheck-script-backend plugin",
30
30
  "author": {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
+ import { evaluateAssertions } from "@checkstack/backend-api";
2
3
  import { ExecuteCollector, type ExecuteConfig } from "./execute-collector";
3
4
  import type { ScriptTransportClient } from "./transport-client";
4
5
 
@@ -47,7 +48,13 @@ describe("ExecuteCollector", () => {
47
48
  expect(result.error).toBeUndefined();
48
49
  });
49
50
 
50
- it("should return error for failed script", async () => {
51
+ // Regression: a script that ran to completion and exited non-zero is a
52
+ // SUCCESSFUL collection - the command executed and reported a result. The
53
+ // collector must NOT set `error` (the executor treats that as a transport
54
+ // failure and hard-fails the run). `exitCode` / `success` are assertable
55
+ // metrics. Previously this asserted the wrong behavior (`error` contains
56
+ // "Exit code: 1").
57
+ it("does not hard-fail the collector on a non-zero exit code", async () => {
51
58
  const collector = new ExecuteCollector();
52
59
  const client = createMockClient({
53
60
  exitCode: 1,
@@ -62,7 +69,40 @@ describe("ExecuteCollector", () => {
62
69
 
63
70
  expect(result.result.exitCode).toBe(1);
64
71
  expect(result.result.success).toBe(false);
65
- expect(result.error).toContain("Exit code: 1");
72
+ // Transport succeeded: no error field is set.
73
+ expect(result.error).toBeUndefined();
74
+
75
+ // A "success is true" assertion fails - the user decides this is
76
+ // unhealthy, not the collector.
77
+ const failed = evaluateAssertions(
78
+ [{ field: "success", operator: "isTrue" }],
79
+ result.result as Record<string, unknown>,
80
+ );
81
+ expect(failed).not.toBeNull();
82
+
83
+ // A check that WANTS exit code 1 (e.g. "this file should be absent") can
84
+ // be green by asserting on the exit code.
85
+ const failedWanted = evaluateAssertions(
86
+ [{ field: "exitCode", operator: "equals", value: 1 }],
87
+ result.result as Record<string, unknown>,
88
+ );
89
+ expect(failedWanted).toBeNull();
90
+ });
91
+
92
+ // A timeout IS a transport failure: the script could not complete. The
93
+ // collector must surface it as an `error`.
94
+ it("hard-fails the collector on a timeout (transport failure)", async () => {
95
+ const collector = new ExecuteCollector();
96
+ const client = createMockClient({ timedOut: true, exitCode: -1 });
97
+
98
+ const result = await collector.execute({
99
+ config: { script: "sleep 999", timeout: 100 },
100
+ client,
101
+ pluginId: "test",
102
+ });
103
+
104
+ expect(result.result.timedOut).toBe(true);
105
+ expect(result.error).toContain("timed out");
66
106
  });
67
107
 
68
108
  it("should handle timeout", async () => {
@@ -174,8 +174,11 @@ const executeResultSchema = healthResultSchema({
174
174
  exitCode: healthResultNumber({
175
175
  "x-chart-type": "counter",
176
176
  "x-chart-label": "Exit Code",
177
- "x-anomaly-enabled": true,
178
- "x-anomaly-direction": "dominance",
177
+ // Exit codes are arbitrary integers (0, 1, 2, 127, -1, ...) with no
178
+ // stable distribution; the pass/fail signal they carry is already
179
+ // covered by `success`. Charting it stays useful; alerting on it just
180
+ // double-fires (and flaps for scripts that vary their nonzero codes).
181
+ "x-anomaly-enabled": false,
179
182
  }),
180
183
  stdout: healthResultString({
181
184
  "x-chart-type": "text",
@@ -193,7 +196,9 @@ const executeResultSchema = healthResultSchema({
193
196
  "x-chart-unit": "ms",
194
197
  "x-anomaly-enabled": true,
195
198
  "x-anomaly-direction": "lower-is-better",
196
- "x-anomaly-sensitivity": 2,
199
+ // Err wider than the default band: only sustained, materially-larger
200
+ // run times should alert, not normal jitter.
201
+ "x-anomaly-sensitivity": 2.5,
197
202
  "x-anomaly-confirmation-window": 3,
198
203
  "x-anomaly-min-absolute-delta": 50,
199
204
  "x-anomaly-min-relative-delta": 0.5,
@@ -207,8 +212,10 @@ const executeResultSchema = healthResultSchema({
207
212
  timedOut: healthResultBoolean({
208
213
  "x-chart-type": "boolean",
209
214
  "x-chart-label": "Timed Out",
210
- "x-anomaly-enabled": true,
211
- "x-anomaly-direction": "dominance",
215
+ // A timeout always implies `success: false`, so alerting here on top of
216
+ // `success` double-fires on the same incident. Keep it chartable, let
217
+ // `success` carry the alert.
218
+ "x-anomaly-enabled": false,
212
219
  }),
213
220
  });
214
221
 
@@ -222,6 +229,10 @@ const executeAggregatedFields = {
222
229
  "x-chart-unit": "ms",
223
230
  "x-anomaly-enabled": true,
224
231
  "x-anomaly-direction": "lower-is-better",
232
+ "x-anomaly-sensitivity": 2.5,
233
+ "x-anomaly-confirmation-window": 3,
234
+ "x-anomaly-min-absolute-delta": 50,
235
+ "x-anomaly-min-relative-delta": 0.5,
225
236
  }),
226
237
  successRate: aggregatedRate({
227
238
  "x-chart-type": "gauge",
@@ -229,6 +240,10 @@ const executeAggregatedFields = {
229
240
  "x-chart-unit": "%",
230
241
  "x-anomaly-enabled": true,
231
242
  "x-anomaly-direction": "higher-is-better",
243
+ // Only a real drop in availability should alert, not single-sample
244
+ // jitter in the rate.
245
+ "x-anomaly-confirmation-window": 3,
246
+ "x-anomaly-min-absolute-delta": 5,
232
247
  }),
233
248
  };
234
249
 
@@ -253,8 +268,12 @@ export type ExecuteAggregatedResult = InferAggregatedResult<
253
268
  * awk -v l="$load" 'BEGIN { exit (l+0 > 0.60) ? 1 : 0 }'
254
269
  * ```
255
270
  *
256
- * Exit code 0 = healthy, anything else = unhealthy. stdout / stderr are
257
- * captured and reported with the run.
271
+ * The exit code is exposed as the assertable `exitCode` / `success` metric
272
+ * (`success` is true when the exit code is 0). It does NOT hard-fail the
273
+ * collector: add an assertion (e.g. "success is true", or "exitCode equals 0")
274
+ * to turn a non-zero exit into an unhealthy result. Only a genuine transport
275
+ * failure (the script could not be spawned, or it timed out) fails the
276
+ * collector itself. stdout / stderr are captured and reported with the run.
258
277
  */
259
278
  export class ExecuteCollector implements CollectorStrategy<
260
279
  ScriptTransportClient,
@@ -264,7 +283,8 @@ export class ExecuteCollector implements CollectorStrategy<
264
283
  > {
265
284
  id = "execute";
266
285
  displayName = "Shell Script";
267
- description = "Run a shell script and treat exit code 0 as healthy";
286
+ description =
287
+ "Run a shell script and expose its exit code as an assertable metric";
268
288
 
269
289
  supportedPlugins = [pluginMetadata];
270
290
 
@@ -354,7 +374,23 @@ export class ExecuteCollector implements CollectorStrategy<
354
374
  });
355
375
 
356
376
  const executionTimeMs = Date.now() - startTime;
377
+ // `success` (exit code 0) and the raw `exitCode` are ASSERTABLE METRICS,
378
+ // not a collector-failure signal. A script that ran to completion and
379
+ // exited non-zero is a SUCCESSFUL collection: the command executed and
380
+ // reported a result. Whether a non-zero exit makes the check unhealthy is
381
+ // the user's decision via assertions (e.g. "exitCode equals 0", or
382
+ // "exitCode equals 1" when a non-zero exit is the wanted state). We must
383
+ // NOT set the `error` field on a non-zero exit, otherwise the executor
384
+ // hard-fails the run before assertions get to decide.
385
+ //
386
+ // Only a genuine transport failure fails the collector: `masked.error`
387
+ // (the script could not be spawned / a runner-level error) or `timedOut`
388
+ // (the script could not complete within the timeout). Those propagate as
389
+ // the collector `error`.
357
390
  const success = response.exitCode === 0 && !response.timedOut;
391
+ const transportError = response.timedOut
392
+ ? (masked.error ?? "Script execution timed out")
393
+ : masked.error;
358
394
 
359
395
  return {
360
396
  result: {
@@ -365,9 +401,7 @@ export class ExecuteCollector implements CollectorStrategy<
365
401
  success,
366
402
  timedOut: response.timedOut,
367
403
  },
368
- error:
369
- masked.error ??
370
- (success ? undefined : `Exit code: ${response.exitCode}`),
404
+ error: transportError,
371
405
  };
372
406
  }
373
407
 
@@ -153,8 +153,12 @@ const inlineScriptResultSchema = healthResultSchema({
153
153
  value: healthResultNumber({
154
154
  "x-chart-type": "line",
155
155
  "x-chart-label": "Value",
156
- "x-anomaly-enabled": true,
157
- "x-anomaly-direction": "deviation",
156
+ // Free-form user-returned number with no stable baseline and no
157
+ // universal good/bad direction (it could be a load average, a queue
158
+ // depth, a row count, anything). A learned band over an arbitrary
159
+ // value is the core alert-fatigue case, so do not alert by default.
160
+ // It stays fully chartable and a user can opt in per check.
161
+ "x-anomaly-enabled": false,
158
162
  }).optional(),
159
163
  executionTimeMs: healthResultNumber({
160
164
  "x-chart-type": "line",
@@ -162,7 +166,7 @@ const inlineScriptResultSchema = healthResultSchema({
162
166
  "x-chart-unit": "ms",
163
167
  "x-anomaly-enabled": true,
164
168
  "x-anomaly-direction": "lower-is-better",
165
- "x-anomaly-sensitivity": 2,
169
+ "x-anomaly-sensitivity": 2.5,
166
170
  "x-anomaly-confirmation-window": 3,
167
171
  "x-anomaly-min-absolute-delta": 50,
168
172
  "x-anomaly-min-relative-delta": 0.5,
@@ -170,8 +174,9 @@ const inlineScriptResultSchema = healthResultSchema({
170
174
  timedOut: healthResultBoolean({
171
175
  "x-chart-type": "boolean",
172
176
  "x-chart-label": "Timed Out",
173
- "x-anomaly-enabled": true,
174
- "x-anomaly-direction": "dominance",
177
+ // Always implies `success: false`; alerting here as well double-fires
178
+ // on the same incident. Keep chartable, let `success` carry the alert.
179
+ "x-anomaly-enabled": false,
175
180
  }),
176
181
  });
177
182
 
@@ -185,6 +190,10 @@ const inlineScriptAggregatedFields = {
185
190
  "x-chart-unit": "ms",
186
191
  "x-anomaly-enabled": true,
187
192
  "x-anomaly-direction": "lower-is-better",
193
+ "x-anomaly-sensitivity": 2.5,
194
+ "x-anomaly-confirmation-window": 3,
195
+ "x-anomaly-min-absolute-delta": 50,
196
+ "x-anomaly-min-relative-delta": 0.5,
188
197
  }),
189
198
  successRate: aggregatedRate({
190
199
  "x-chart-type": "gauge",
@@ -192,6 +201,8 @@ const inlineScriptAggregatedFields = {
192
201
  "x-chart-unit": "%",
193
202
  "x-anomaly-enabled": true,
194
203
  "x-anomaly-direction": "higher-is-better",
204
+ "x-anomaly-confirmation-window": 3,
205
+ "x-anomaly-min-absolute-delta": 5,
195
206
  }),
196
207
  };
197
208
 
package/src/strategy.ts CHANGED
@@ -73,7 +73,7 @@ const scriptResultSchema = healthResultSchema({
73
73
  "x-chart-unit": "ms",
74
74
  "x-anomaly-enabled": true,
75
75
  "x-anomaly-direction": "lower-is-better",
76
- "x-anomaly-sensitivity": 2,
76
+ "x-anomaly-sensitivity": 2.5,
77
77
  "x-anomaly-confirmation-window": 3,
78
78
  "x-anomaly-min-absolute-delta": 50,
79
79
  "x-anomaly-min-relative-delta": 0.5,
@@ -81,8 +81,9 @@ const scriptResultSchema = healthResultSchema({
81
81
  exitCode: healthResultNumber({
82
82
  "x-chart-type": "counter",
83
83
  "x-chart-label": "Exit Code",
84
- "x-anomaly-enabled": true,
85
- "x-anomaly-direction": "dominance",
84
+ // Arbitrary integer with no stable distribution; the pass/fail signal
85
+ // it carries is already covered by `success`. Keep chartable only.
86
+ "x-anomaly-enabled": false,
86
87
  }).optional(),
87
88
  success: healthResultBoolean({
88
89
  "x-chart-type": "boolean",
@@ -93,8 +94,9 @@ const scriptResultSchema = healthResultSchema({
93
94
  timedOut: healthResultBoolean({
94
95
  "x-chart-type": "boolean",
95
96
  "x-chart-label": "Timed Out",
96
- "x-anomaly-enabled": true,
97
- "x-anomaly-direction": "dominance",
97
+ // Always implies `success: false`; alerting here as well double-fires
98
+ // on the same incident. Keep chartable, let `success` carry the alert.
99
+ "x-anomaly-enabled": false,
98
100
  }),
99
101
  error: healthResultString({
100
102
  "x-chart-type": "status",
@@ -113,6 +115,10 @@ const scriptAggregatedFields = {
113
115
  "x-chart-unit": "ms",
114
116
  "x-anomaly-enabled": true,
115
117
  "x-anomaly-direction": "lower-is-better",
118
+ "x-anomaly-sensitivity": 2.5,
119
+ "x-anomaly-confirmation-window": 3,
120
+ "x-anomaly-min-absolute-delta": 50,
121
+ "x-anomaly-min-relative-delta": 0.5,
116
122
  }),
117
123
  successRate: aggregatedRate({
118
124
  "x-chart-type": "gauge",
@@ -120,18 +126,23 @@ const scriptAggregatedFields = {
120
126
  "x-chart-unit": "%",
121
127
  "x-anomaly-enabled": true,
122
128
  "x-anomaly-direction": "higher-is-better",
129
+ "x-anomaly-confirmation-window": 3,
130
+ "x-anomaly-min-absolute-delta": 5,
123
131
  }),
124
132
  errorCount: aggregatedCounter({
125
133
  "x-chart-type": "counter",
126
134
  "x-chart-label": "Errors",
127
- "x-anomaly-enabled": true,
128
- "x-anomaly-direction": "lower-is-better",
135
+ // Raw per-bucket count is the absolute twin of `successRate`; it scales
136
+ // with bucket volume and has no stable baseline. Prefer the percent
137
+ // form (`successRate`) for alerting and keep this chartable only.
138
+ "x-anomaly-enabled": false,
129
139
  }),
130
140
  timeoutCount: aggregatedCounter({
131
141
  "x-chart-type": "counter",
132
142
  "x-chart-label": "Timeouts",
133
- "x-anomaly-enabled": true,
134
- "x-anomaly-direction": "lower-is-better",
143
+ // Same as errorCount: a volume-scaled absolute twin of the failure
144
+ // rate already expressed by `successRate`. Chartable only.
145
+ "x-anomaly-enabled": false,
135
146
  }),
136
147
  };
137
148