@checkstack/healthcheck-backend 1.21.2 → 1.21.3

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,75 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.21.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 6c8b36b: Promote the health-check run-queue contract and the observability window
8
+ math into `@checkstack/healthcheck-common`: `HEALTH_CHECK_QUEUE`,
9
+ `HealthCheckJobPayload`, `fastPathJobId` (per-plugin prefix) and
10
+ `computeWindowBounds`/`computeSecondsSinceLast` now have ONE definition
11
+ that the queue owner (healthcheck-backend) and every observability
12
+ strategy plugin import, replacing the per-plugin mirror copies that had
13
+ to be kept in lock-step by convention. Enqueued job ids and window
14
+ semantics are byte-identical; this is a drift-proofing refactor, not a
15
+ behavior change.
16
+ - 6c8b36b: Run the config-secrets backfill in afterPluginsReady instead of init.
17
+ Health-check strategies contributed by other plugins register during THEIR
18
+ init, and plugin init order follows the service-ref graph, so running the
19
+ backfill during healthcheck's own init could scan configurations before a
20
+ contributor (e.g. logstream's health strategy) had registered - skipping
21
+ that strategy's config with a "strategy not registered" warning at boot.
22
+ Only afterPluginsReady guarantees a complete registry. The backfill is
23
+ idempotent, so any configuration skipped by an earlier boot is picked up
24
+ on the next one.
25
+ - 6c8b36b: Promote the t-digest percentile helpers from healthcheck-backend into
26
+ backend-api (`createTDigest`, `serializeTDigest`, `deserializeTDigest`,
27
+ `mergeTDigestStates`, `percentileFromState`, ...), so any plugin can maintain
28
+ mergeable percentile sketches; tracestream's per-operation p95 buckets are the
29
+ first new consumer. healthcheck-backend now imports the shared module (the
30
+ local copy is removed, no behavior change).
31
+ - Updated dependencies [6c8b36b]
32
+ - Updated dependencies [6c8b36b]
33
+ - Updated dependencies [6c8b36b]
34
+ - Updated dependencies [6c8b36b]
35
+ - Updated dependencies [6c8b36b]
36
+ - Updated dependencies [6c8b36b]
37
+ - Updated dependencies [6c8b36b]
38
+ - Updated dependencies [6c8b36b]
39
+ - Updated dependencies [6c8b36b]
40
+ - Updated dependencies [6c8b36b]
41
+ - Updated dependencies [6c8b36b]
42
+ - Updated dependencies [6c8b36b]
43
+ - Updated dependencies [6c8b36b]
44
+ - Updated dependencies [6c8b36b]
45
+ - Updated dependencies [6c8b36b]
46
+ - @checkstack/ai-backend@0.11.3
47
+ - @checkstack/backend-api@0.34.0
48
+ - @checkstack/healthcheck-common@1.18.0
49
+ - @checkstack/catalog-common@2.8.0
50
+ - @checkstack/queue-api@0.4.0
51
+ - @checkstack/common@0.23.0
52
+ - @checkstack/catalog-backend@1.10.0
53
+ - @checkstack/status-page-backend@0.6.5
54
+ - @checkstack/automation-backend@0.11.7
55
+ - @checkstack/incident-backend@1.13.5
56
+ - @checkstack/script-packages-backend@0.4.5
57
+ - @checkstack/sdk@0.133.1
58
+ - @checkstack/command-backend@0.2.26
59
+ - @checkstack/gitops-backend@0.5.26
60
+ - @checkstack/satellite-backend@0.9.3
61
+ - @checkstack/secrets-backend@0.3.8
62
+ - @checkstack/incident-common@1.10.4
63
+ - @checkstack/maintenance-common@1.10.4
64
+ - @checkstack/status-page-common@0.6.4
65
+ - @checkstack/ai-common@0.6.7
66
+ - @checkstack/cache-api@0.3.20
67
+ - @checkstack/gitops-common@0.7.4
68
+ - @checkstack/notification-common@1.7.2
69
+ - @checkstack/secrets-common@0.3.3
70
+ - @checkstack/signal-common@0.3.1
71
+ - @checkstack/cache-utils@0.3.1
72
+
3
73
  ## 1.21.2
4
74
 
5
75
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.21.2",
3
+ "version": "1.21.3",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,50 +14,48 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.11.2",
18
- "@checkstack/ai-common": "0.6.6",
19
- "@checkstack/automation-backend": "0.11.6",
20
- "@checkstack/backend-api": "0.33.0",
21
- "@checkstack/cache-api": "0.3.19",
22
- "@checkstack/cache-utils": "0.3.0",
23
- "@checkstack/catalog-backend": "1.9.2",
24
- "@checkstack/catalog-common": "2.7.3",
25
- "@checkstack/command-backend": "0.2.25",
26
- "@checkstack/common": "0.22.0",
27
- "@checkstack/gitops-backend": "0.5.25",
28
- "@checkstack/gitops-common": "0.7.3",
29
- "@checkstack/healthcheck-common": "1.17.0",
30
- "@checkstack/incident-backend": "1.13.4",
31
- "@checkstack/incident-common": "1.10.3",
32
- "@checkstack/maintenance-common": "1.10.3",
33
- "@checkstack/notification-common": "1.7.1",
34
- "@checkstack/queue-api": "0.3.19",
35
- "@checkstack/satellite-backend": "0.9.2",
36
- "@checkstack/script-packages-backend": "0.4.4",
37
- "@checkstack/sdk": "0.132.0",
38
- "@checkstack/secrets-backend": "0.3.7",
39
- "@checkstack/secrets-common": "0.3.2",
40
- "@checkstack/signal-common": "0.3.0",
41
- "@checkstack/status-page-backend": "0.6.4",
42
- "@checkstack/status-page-common": "0.6.3",
17
+ "@checkstack/ai-backend": "0.11.3",
18
+ "@checkstack/ai-common": "0.6.7",
19
+ "@checkstack/automation-backend": "0.11.7",
20
+ "@checkstack/backend-api": "0.34.0",
21
+ "@checkstack/cache-api": "0.3.20",
22
+ "@checkstack/cache-utils": "0.3.1",
23
+ "@checkstack/catalog-backend": "1.10.0",
24
+ "@checkstack/catalog-common": "2.8.0",
25
+ "@checkstack/command-backend": "0.2.26",
26
+ "@checkstack/common": "0.23.0",
27
+ "@checkstack/gitops-backend": "0.5.26",
28
+ "@checkstack/gitops-common": "0.7.4",
29
+ "@checkstack/healthcheck-common": "1.18.0",
30
+ "@checkstack/incident-backend": "1.13.5",
31
+ "@checkstack/incident-common": "1.10.4",
32
+ "@checkstack/maintenance-common": "1.10.4",
33
+ "@checkstack/notification-common": "1.7.2",
34
+ "@checkstack/queue-api": "0.4.0",
35
+ "@checkstack/satellite-backend": "0.9.3",
36
+ "@checkstack/script-packages-backend": "0.4.5",
37
+ "@checkstack/sdk": "0.133.1",
38
+ "@checkstack/secrets-backend": "0.3.8",
39
+ "@checkstack/secrets-common": "0.3.3",
40
+ "@checkstack/signal-common": "0.3.1",
41
+ "@checkstack/status-page-backend": "0.6.5",
42
+ "@checkstack/status-page-common": "0.6.4",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
46
46
  "drizzle-orm": "^0.45.0",
47
47
  "hono": "^4.12.25",
48
48
  "jsonpath-plus": "^10.4.0",
49
- "tdigest": "^0.1.2",
50
49
  "yaml": "^2.6.1",
51
50
  "zod": "^4.2.1"
52
51
  },
53
52
  "devDependencies": {
54
53
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.5",
56
- "@checkstack/test-utils-backend": "0.1.59",
54
+ "@checkstack/scripts": "0.7.6",
55
+ "@checkstack/test-utils-backend": "0.1.60",
57
56
  "@checkstack/tsconfig": "0.0.7",
58
57
  "@types/bun": "^1.0.0",
59
58
  "@types/pg": "^8.20.0",
60
- "@types/tdigest": "^0.1.5",
61
59
  "bullmq": "^5.66.4",
62
60
  "date-fns": "^4.4.0",
63
61
  "drizzle-kit": "^0.31.10",
package/src/index.ts CHANGED
@@ -287,20 +287,12 @@ export default createBackendPlugin({
287
287
  };
288
288
  gitopsConfigSecrets = configSecrets;
289
289
 
290
- // Move any pre-channel inline secrets out of stored rows (idempotent,
291
- // advisory-locked, fail-open: a backfill failure must not block boot).
292
- try {
293
- await backfillConfigSecrets({
294
- db: typedDb,
295
- registry: healthCheckRegistry,
296
- collectorRegistry,
297
- internalSecrets,
298
- advisoryLock,
299
- logger,
300
- });
301
- } catch (error) {
302
- logger.warn("Config-secrets backfill failed; continuing boot", error);
303
- }
290
+ // NOTE: the config-secrets backfill runs in afterPluginsReady, NOT
291
+ // here. Strategies contributed by OTHER plugins (e.g. logstream's
292
+ // LogStreamHealthStrategy) register during THEIR init(), and init
293
+ // order follows the service-ref graph - it is NOT guaranteed that
294
+ // every contributor initialized before this plugin. Backfilling here
295
+ // would skip configs of not-yet-registered strategies with a warn.
304
296
 
305
297
  // Resolve/search health-check configurations by name for the Teams admin
306
298
  // UI (team grants are stored as opaque `<type>:<configId>` rows, where
@@ -644,6 +636,30 @@ export default createBackendPlugin({
644
636
  // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
645
637
  storedEmitHook = emitHook;
646
638
 
639
+ // Move any pre-channel inline secrets out of stored rows (idempotent,
640
+ // advisory-locked, fail-open: a backfill failure must not block boot).
641
+ // Runs HERE - after every plugin's init() - because strategies are
642
+ // contributed by other plugins during their init (e.g. logstream's
643
+ // health strategy), and only afterPluginsReady guarantees the registry
644
+ // is complete; running in init raced contributor init order.
645
+ if (gitopsConfigSecrets && resolvedAdvisoryLock) {
646
+ try {
647
+ await backfillConfigSecrets({
648
+ db: database,
649
+ registry: healthCheckRegistry,
650
+ collectorRegistry,
651
+ internalSecrets: gitopsConfigSecrets.internalSecrets,
652
+ advisoryLock: resolvedAdvisoryLock,
653
+ logger,
654
+ });
655
+ } catch (error) {
656
+ logger.warn(
657
+ "Config-secrets backfill failed; continuing boot",
658
+ error,
659
+ );
660
+ }
661
+ }
662
+
647
663
  // No cross-pod status-cache broadcast: the status cache runs on the
648
664
  // platform CacheManager, so a distributed backend (Redis) makes every
649
665
  // eviction visible to all pods through the shared store. The prior
@@ -32,6 +32,15 @@ import {
32
32
  ENVIRONMENT_RESOLUTION_FAILED,
33
33
  type HealthCheckStatus,
34
34
  stripEphemeralFields,
35
+ HEALTH_CHECK_QUEUE,
36
+ type HealthCheckJobPayload,
37
+ } from "@checkstack/healthcheck-common";
38
+ // The run-queue contract (queue name + payload) now lives in the common leaf so
39
+ // every fast-path enqueuer shares one shape; re-exported from the owner here
40
+ // because this file's many internal importers reference these names.
41
+ export {
42
+ HEALTH_CHECK_QUEUE,
43
+ type HealthCheckJobPayload,
35
44
  } from "@checkstack/healthcheck-common";
36
45
  import {
37
46
  CatalogApi,
@@ -244,12 +253,6 @@ async function emitCheckCompletedHook({
244
253
  * The scheduling reconciler owns which (config, system, env) jobs exist; the
245
254
  * `run_now` action enqueues one job per effective environment.
246
255
  */
247
- export interface HealthCheckJobPayload {
248
- configId: string;
249
- systemId: string;
250
- environmentId: string | null;
251
- }
252
-
253
256
  /** Prefix every health-check recurring jobId shares (used for orphan scans). */
254
257
  export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
255
258
 
@@ -268,13 +271,6 @@ export function encodeHealthCheckJobId(props: {
268
271
  return environmentId === null ? base : `${base}:${environmentId}`;
269
272
  }
270
273
 
271
- /**
272
- * Queue name for health check execution. Exported so consumers like
273
- * the `healthcheck.run_now` automation action can enqueue a one-off
274
- * job without re-importing the recurring-job factory.
275
- */
276
- export const HEALTH_CHECK_QUEUE = "health-checks";
277
-
278
274
  /**
279
275
  * Worker group for health check execution (work-queue mode)
280
276
  */
@@ -1,12 +1,11 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { getHourBucketStart, incrementHourlyAggregate } from "./realtime-aggregation";
2
3
  import {
3
- getHourBucketStart,
4
4
  serializeTDigest,
5
5
  deserializeTDigest,
6
- incrementHourlyAggregate,
7
- } from "./realtime-aggregation";
6
+ createTDigest,
7
+ } from "@checkstack/backend-api";
8
8
  import { computeAssertionKey } from "@checkstack/healthcheck-common";
9
- import { TDigest } from "tdigest";
10
9
 
11
10
  describe("getHourBucketStart", () => {
12
11
  it("floors timestamp to the hour", () => {
@@ -42,7 +41,7 @@ describe("getHourBucketStart", () => {
42
41
 
43
42
  describe("t-digest serialization", () => {
44
43
  it("serializes and deserializes empty t-digest", () => {
45
- const original = new TDigest();
44
+ const original = createTDigest();
46
45
  const serialized = serializeTDigest(original);
47
46
  const restored = deserializeTDigest(serialized);
48
47
 
@@ -52,7 +51,7 @@ describe("t-digest serialization", () => {
52
51
  });
53
52
 
54
53
  it("serializes and deserializes t-digest with values", () => {
55
- const original = new TDigest();
54
+ const original = createTDigest();
56
55
  original.push(100);
57
56
  original.push(200);
58
57
  original.push(300);
@@ -67,7 +66,7 @@ describe("t-digest serialization", () => {
67
66
  });
68
67
 
69
68
  it("preserves p95 accuracy after serialization", () => {
70
- const original = new TDigest();
69
+ const original = createTDigest();
71
70
  // Add 100 values from 1 to 100
72
71
  for (let i = 1; i <= 100; i++) {
73
72
  original.push(i);
@@ -85,7 +84,7 @@ describe("t-digest serialization", () => {
85
84
 
86
85
  it("handles incremental updates correctly", () => {
87
86
  // First batch
88
- const digest1 = new TDigest();
87
+ const digest1 = createTDigest();
89
88
  digest1.push(100);
90
89
  digest1.push(110);
91
90
 
@@ -254,7 +253,7 @@ describe("incrementHourlyAggregate", () => {
254
253
  it("updates min/max when existing aggregate has values", async () => {
255
254
  // Set up existing aggregate
256
255
  existingAggregate = {
257
- tdigestState: serializeTDigest(new TDigest()),
256
+ tdigestState: serializeTDigest(createTDigest()),
258
257
  minLatencyMs: 100,
259
258
  maxLatencyMs: 200,
260
259
  };
@@ -309,7 +308,7 @@ describe("incrementHourlyAggregate", () => {
309
308
 
310
309
  it("updates max when new latency is higher", async () => {
311
310
  existingAggregate = {
312
- tdigestState: serializeTDigest(new TDigest()),
311
+ tdigestState: serializeTDigest(createTDigest()),
313
312
  minLatencyMs: 100,
314
313
  maxLatencyMs: 200,
315
314
  };
@@ -361,7 +360,7 @@ describe("incrementHourlyAggregate", () => {
361
360
 
362
361
  it("accumulates t-digest state across multiple runs", async () => {
363
362
  // First run
364
- const digest1 = new TDigest();
363
+ const digest1 = createTDigest();
365
364
  digest1.push(100);
366
365
  digest1.push(200);
367
366
  digest1.push(300);
@@ -2,6 +2,10 @@ import type {
2
2
  ScopedQueryRunner,
3
3
  CollectorRegistry,
4
4
  } from "@checkstack/backend-api";
5
+ import {
6
+ pushValuesIntoState,
7
+ percentileFromState,
8
+ } from "@checkstack/backend-api";
5
9
  import {
6
10
  ASSERTIONS_AGG_KEY,
7
11
  AssertionOutcomeSchema,
@@ -9,7 +13,6 @@ import {
9
13
  readAssertionStats,
10
14
  type AssertionOutcome,
11
15
  } from "@checkstack/healthcheck-common";
12
- import { TDigest } from "tdigest";
13
16
  import * as schema from "./schema";
14
17
  import { healthCheckAggregates } from "./schema";
15
18
  import { eq, and, sql } from "drizzle-orm";
@@ -30,46 +33,6 @@ export function getHourBucketStart(timestamp: Date): Date {
30
33
  return bucketStart;
31
34
  }
32
35
 
33
- /**
34
- * Serialize a t-digest to an array of numbers for storage.
35
- * Format: [centroid1_mean, centroid1_n, centroid2_mean, centroid2_n, ...]
36
- */
37
- export function serializeTDigest(digest: TDigest): number[] {
38
- const centroids = digest.toArray();
39
- const result: number[] = [];
40
- for (const c of centroids) {
41
- result.push(c.mean, c.n);
42
- }
43
- return result;
44
- }
45
-
46
- /**
47
- * Deserialize a t-digest from storage format.
48
- */
49
- export function deserializeTDigest(state: number[]): TDigest {
50
- const digest = new TDigest();
51
-
52
- if (state.length === 0) {
53
- return digest;
54
- }
55
-
56
- // Reconstruct centroids from pairs of [mean, n]
57
- const centroids: Array<{ mean: number; n: number }> = [];
58
- for (let i = 0; i < state.length; i += 2) {
59
- const mean = state[i];
60
- const n = state[i + 1];
61
- if (mean !== undefined && n !== undefined && n > 0) {
62
- centroids.push({ mean, n });
63
- }
64
- }
65
-
66
- if (centroids.length > 0) {
67
- digest.push_centroid(centroids);
68
- }
69
-
70
- return digest;
71
- }
72
-
73
36
  interface IncrementHourlyAggregateParams {
74
37
  db: Db;
75
38
  systemId: string;
@@ -177,15 +140,14 @@ export async function incrementHourlyAggregate(
177
140
  max = latencyMs;
178
141
  }
179
142
 
180
- // Update t-digest for p95 calculation
181
- const digest =
182
- existing?.tdigestState && existing.tdigestState.length > 0
183
- ? deserializeTDigest(existing.tdigestState)
184
- : new TDigest();
185
-
186
- digest.push(latencyMs);
187
- const tdigestState = serializeTDigest(digest);
188
- const p95 = Math.round(digest.percentile(0.95));
143
+ // Update t-digest for p95 calculation (shared backend-api helper).
144
+ const tdigestState = pushValuesIntoState({
145
+ state: existing?.tdigestState ?? null,
146
+ values: [latencyMs],
147
+ });
148
+ const p95 = Math.round(
149
+ percentileFromState({ state: tdigestState, q: 0.95 }) ?? latencyMs,
150
+ );
189
151
 
190
152
  return { latencySumIncr, min, max, tdigestState, p95 };
191
153
  })();