@checkstack/healthcheck-backend 1.8.0 → 1.9.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/src/router.ts CHANGED
@@ -199,20 +199,20 @@ export const createHealthCheckRouter = (opts: {
199
199
  }),
200
200
 
201
201
  deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
202
- await enforceNotGitOpsLocked("Healthcheck", input);
203
- await service.deleteConfiguration(input);
202
+ await enforceNotGitOpsLocked("Healthcheck", input.id);
203
+ await service.deleteConfiguration(input.id);
204
204
  await cache.invalidateAllSystems();
205
205
  }),
206
206
 
207
207
  pauseConfiguration: os.pauseConfiguration.handler(async ({ input }) => {
208
- await enforceNotGitOpsLocked("Healthcheck", input);
209
- await service.pauseConfiguration(input);
208
+ await enforceNotGitOpsLocked("Healthcheck", input.id);
209
+ await service.pauseConfiguration(input.id);
210
210
  await cache.invalidateAllSystems();
211
211
  }),
212
212
 
213
213
  resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
214
- await enforceNotGitOpsLocked("Healthcheck", input);
215
- await service.resumeConfiguration(input);
214
+ await enforceNotGitOpsLocked("Healthcheck", input.id);
215
+ await service.resumeConfiguration(input.id);
216
216
  await cache.invalidateAllSystems();
217
217
  }),
218
218
 
@@ -313,6 +313,10 @@ export const createHealthCheckRouter = (opts: {
313
313
  return service.getHistory(input);
314
314
  }),
315
315
 
316
+ getRunStats: os.getRunStats.handler(async ({ input }) => {
317
+ return service.getRunStats(input);
318
+ }),
319
+
316
320
  getDetailedHistory: os.getDetailedHistory.handler(async ({ input }) => {
317
321
  return service.getDetailedHistory(input);
318
322
  }),
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { summarizeRuns, type StatRun } from "./run-stats.logic";
3
+
4
+ const start = new Date("2026-06-10T00:00:00.000Z");
5
+ const end = new Date("2026-06-10T04:00:00.000Z"); // 4h window
6
+
7
+ function at(hoursFromStart: number, status: string, latencyMs?: number): StatRun {
8
+ return {
9
+ timestamp: new Date(start.getTime() + hoursFromStart * 3_600_000),
10
+ status,
11
+ latencyMs,
12
+ };
13
+ }
14
+
15
+ describe("summarizeRuns", () => {
16
+ test("computes window totals with exact uptime and latency stats", () => {
17
+ const runs: StatRun[] = [
18
+ at(0, "healthy", 100),
19
+ at(0.5, "unhealthy", 300),
20
+ at(1, "healthy", 200),
21
+ at(2, "degraded", 400),
22
+ ];
23
+ const stats = summarizeRuns({ runs, startDate: start, endDate: end, maxBuckets: 4 });
24
+ expect(stats.total.runCount).toBe(4);
25
+ expect(stats.total.healthy).toBe(2);
26
+ expect(stats.total.degraded).toBe(1);
27
+ expect(stats.total.unhealthy).toBe(1);
28
+ expect(stats.total.uptimePct).toBe(50);
29
+ expect(stats.total.minLatencyMs).toBe(100);
30
+ expect(stats.total.maxLatencyMs).toBe(400);
31
+ expect(stats.total.avgLatencyMs).toBe(250);
32
+ expect(stats.window.start).toBe("2026-06-10T00:00:00.000Z");
33
+ expect(stats.window.end).toBe("2026-06-10T04:00:00.000Z");
34
+ });
35
+
36
+ test("buckets runs over the window and caps the count", () => {
37
+ const runs: StatRun[] = [
38
+ at(0, "healthy"),
39
+ at(0.5, "unhealthy"),
40
+ at(1, "healthy"),
41
+ at(2, "healthy"),
42
+ ];
43
+ const stats = summarizeRuns({ runs, startDate: start, endDate: end, maxBuckets: 4 });
44
+ // 4h / 4 buckets = 1h interval.
45
+ expect(stats.bucketIntervalSeconds).toBe(3600);
46
+ // Buckets 0 (2 runs), 1 (1 run), 2 (1 run); bucket 3 empty -> omitted.
47
+ expect(stats.buckets).toHaveLength(3);
48
+ expect(stats.buckets[0].runCount).toBe(2);
49
+ expect(stats.buckets[0].healthy).toBe(1);
50
+ expect(stats.buckets[0].unhealthy).toBe(1);
51
+ expect(stats.buckets[0].uptimePct).toBe(50);
52
+ expect(stats.buckets[0].start).toBe("2026-06-10T00:00:00.000Z");
53
+ expect(stats.buckets[0].end).toBe("2026-06-10T01:00:00.000Z");
54
+ // Empty buckets are omitted, not padded.
55
+ expect(stats.buckets.map((b) => b.runCount)).toEqual([2, 1, 1]);
56
+ });
57
+
58
+ test("never emits more buckets than maxBuckets", () => {
59
+ const runs: StatRun[] = Array.from({ length: 200 }, (_, i) =>
60
+ at((i / 200) * 4, "healthy"),
61
+ );
62
+ const stats = summarizeRuns({ runs, startDate: start, endDate: end, maxBuckets: 6 });
63
+ expect(stats.buckets.length).toBeLessThanOrEqual(6);
64
+ });
65
+
66
+ test("empty window yields zeroed totals and no buckets", () => {
67
+ const stats = summarizeRuns({ runs: [], startDate: start, endDate: end, maxBuckets: 4 });
68
+ expect(stats.total.runCount).toBe(0);
69
+ expect(stats.total.uptimePct).toBe(0);
70
+ expect(stats.total.avgLatencyMs).toBeUndefined();
71
+ expect(stats.buckets).toHaveLength(0);
72
+ });
73
+ });
@@ -0,0 +1,148 @@
1
+ import {
2
+ countStatuses,
3
+ calculateLatencyStats,
4
+ extractLatencies,
5
+ } from "./aggregation-utils";
6
+
7
+ /**
8
+ * Pure summarizer behind the `healthcheck.runStats` AI tool. Turns raw runs in a
9
+ * window into a COMPACT report — window totals + a small, capped time series of
10
+ * buckets — so the assistant can answer "how often / how much downtime / uptime
11
+ * over the last N days" without pulling thousands of rows into its context.
12
+ *
13
+ * Bucketing is uniform over [startDate, endDate): the window is split into at
14
+ * most `maxBuckets` equal intervals; each run lands in `floor((t - start) /
15
+ * interval)`. Buckets with no runs are omitted (the model reads the bucket's
16
+ * own `start`/`end`, so gaps are unambiguous and we don't pad the context with
17
+ * empty rows). Deterministic and DB-free, so it is unit-tested directly.
18
+ */
19
+
20
+ /** One input run (only the fields stats need). */
21
+ export interface StatRun {
22
+ timestamp: Date;
23
+ status: string;
24
+ latencyMs?: number;
25
+ }
26
+
27
+ export interface RunStatsBucket {
28
+ start: string;
29
+ end: string;
30
+ runCount: number;
31
+ healthy: number;
32
+ degraded: number;
33
+ unhealthy: number;
34
+ /** Percent of runs that were healthy, 0-100, rounded to 1 decimal. */
35
+ uptimePct: number;
36
+ avgLatencyMs?: number;
37
+ p95LatencyMs?: number;
38
+ }
39
+
40
+ export interface RunStatsTotal {
41
+ runCount: number;
42
+ healthy: number;
43
+ degraded: number;
44
+ unhealthy: number;
45
+ uptimePct: number;
46
+ avgLatencyMs?: number;
47
+ minLatencyMs?: number;
48
+ maxLatencyMs?: number;
49
+ p95LatencyMs?: number;
50
+ }
51
+
52
+ export interface RunStats {
53
+ window: { start: string; end: string };
54
+ bucketIntervalSeconds: number;
55
+ total: RunStatsTotal;
56
+ buckets: RunStatsBucket[];
57
+ }
58
+
59
+ /** Healthy / (total) as a 0-100 percentage, 1 decimal. 0 runs -> 0. */
60
+ function uptimePct({
61
+ healthy,
62
+ runCount,
63
+ }: {
64
+ healthy: number;
65
+ runCount: number;
66
+ }): number {
67
+ if (runCount === 0) return 0;
68
+ return Math.round((healthy / runCount) * 1000) / 10;
69
+ }
70
+
71
+ export function summarizeRuns({
72
+ runs,
73
+ startDate,
74
+ endDate,
75
+ maxBuckets,
76
+ }: {
77
+ runs: StatRun[];
78
+ startDate: Date;
79
+ endDate: Date;
80
+ maxBuckets: number;
81
+ }): RunStats {
82
+ const startMs = startDate.getTime();
83
+ const endMs = endDate.getTime();
84
+ const rangeMs = Math.max(1, endMs - startMs);
85
+ const buckets = Math.max(1, Math.min(maxBuckets, Math.ceil(maxBuckets)));
86
+ // At least 1s per bucket; never more buckets than the range allows.
87
+ const intervalMs = Math.max(1000, Math.ceil(rangeMs / buckets));
88
+
89
+ // Window totals (over ALL runs, so p95 is exact, not derived from buckets).
90
+ const totalCounts = countStatuses(runs);
91
+ const totalLatency = calculateLatencyStats(extractLatencies(runs));
92
+ const total: RunStatsTotal = {
93
+ runCount: runs.length,
94
+ healthy: totalCounts.healthyCount,
95
+ degraded: totalCounts.degradedCount,
96
+ unhealthy: totalCounts.unhealthyCount,
97
+ uptimePct: uptimePct({ healthy: totalCounts.healthyCount, runCount: runs.length }),
98
+ avgLatencyMs: totalLatency.avgLatencyMs,
99
+ minLatencyMs: totalLatency.minLatencyMs,
100
+ maxLatencyMs: totalLatency.maxLatencyMs,
101
+ p95LatencyMs: totalLatency.p95LatencyMs,
102
+ };
103
+
104
+ // Group runs into bucket indices.
105
+ const byBucket = new Map<number, StatRun[]>();
106
+ for (const run of runs) {
107
+ const offset = run.timestamp.getTime() - startMs;
108
+ if (offset < 0 || offset > rangeMs) continue; // outside the window
109
+ const idx = Math.min(buckets - 1, Math.floor(offset / intervalMs));
110
+ const list = byBucket.get(idx) ?? [];
111
+ list.push(run);
112
+ byBucket.set(idx, list);
113
+ }
114
+
115
+ const bucketRows: RunStatsBucket[] = [...byBucket.entries()]
116
+ .toSorted(([a], [b]) => a - b)
117
+ .map(([idx, bucketRuns]) => {
118
+ const counts = countStatuses(bucketRuns);
119
+ const latency = calculateLatencyStats(extractLatencies(bucketRuns));
120
+ const bStartMs = startMs + idx * intervalMs;
121
+ const bEndMs = Math.min(endMs, bStartMs + intervalMs);
122
+ return {
123
+ start: new Date(bStartMs).toISOString(),
124
+ end: new Date(bEndMs).toISOString(),
125
+ runCount: bucketRuns.length,
126
+ healthy: counts.healthyCount,
127
+ degraded: counts.degradedCount,
128
+ unhealthy: counts.unhealthyCount,
129
+ uptimePct: uptimePct({
130
+ healthy: counts.healthyCount,
131
+ runCount: bucketRuns.length,
132
+ }),
133
+ ...(latency.avgLatencyMs === undefined
134
+ ? {}
135
+ : { avgLatencyMs: latency.avgLatencyMs }),
136
+ ...(latency.p95LatencyMs === undefined
137
+ ? {}
138
+ : { p95LatencyMs: latency.p95LatencyMs }),
139
+ };
140
+ });
141
+
142
+ return {
143
+ window: { start: startDate.toISOString(), end: endDate.toISOString() },
144
+ bucketIntervalSeconds: Math.round(intervalMs / 1000),
145
+ total,
146
+ buckets: bucketRows,
147
+ };
148
+ }
package/src/service.ts CHANGED
@@ -11,7 +11,9 @@ import {
11
11
  DEFAULT_NOTIFICATION_POLICY,
12
12
  type CollectorConfigEntry,
13
13
  type HealthcheckSignalStatuses,
14
+ type RunStats,
14
15
  } from "@checkstack/healthcheck-common";
16
+ import { summarizeRuns, type StatRun } from "./run-stats.logic";
15
17
  import type { ConfigService } from "@checkstack/backend-api";
16
18
  import type { InferClient } from "@checkstack/common";
17
19
  import type { CatalogApi } from "@checkstack/catalog-common";
@@ -921,6 +923,67 @@ export class HealthCheckService {
921
923
  };
922
924
  }
923
925
 
926
+ /**
927
+ * Compact run statistics over a window: totals + a small, capped bucket series.
928
+ * Backs the `healthcheck.runStats` AI tool so the assistant can answer
929
+ * "how often / how much downtime / uptime over the last N days" WITHOUT pulling
930
+ * thousands of raw rows into its context. Selects only the three columns the
931
+ * stats need and aggregates with the pure `summarizeRuns` helper. Same public
932
+ * gate as `getHistory` (no `result` payload is read).
933
+ */
934
+ async getRunStats(props: {
935
+ systemId?: string;
936
+ configurationId?: string;
937
+ startDate: Date;
938
+ endDate: Date;
939
+ sourceFilter?: string;
940
+ statusFilter?: HealthCheckStatus[];
941
+ maxBuckets?: number;
942
+ }): Promise<RunStats> {
943
+ const {
944
+ systemId,
945
+ configurationId,
946
+ startDate,
947
+ endDate,
948
+ sourceFilter,
949
+ statusFilter,
950
+ maxBuckets = 24,
951
+ } = props;
952
+
953
+ const conditions = [
954
+ gte(healthCheckRuns.timestamp, startDate),
955
+ lte(healthCheckRuns.timestamp, endDate),
956
+ ];
957
+ if (systemId) conditions.push(eq(healthCheckRuns.systemId, systemId));
958
+ if (configurationId)
959
+ conditions.push(eq(healthCheckRuns.configurationId, configurationId));
960
+ if (sourceFilter === "local") {
961
+ conditions.push(isNull(healthCheckRuns.sourceId));
962
+ } else if (sourceFilter) {
963
+ conditions.push(eq(healthCheckRuns.sourceId, sourceFilter));
964
+ }
965
+ if (statusFilter && statusFilter.length > 0) {
966
+ conditions.push(inArray(healthCheckRuns.status, statusFilter));
967
+ }
968
+
969
+ const rows = await this.db
970
+ .select({
971
+ timestamp: healthCheckRuns.timestamp,
972
+ status: healthCheckRuns.status,
973
+ latencyMs: healthCheckRuns.latencyMs,
974
+ })
975
+ .from(healthCheckRuns)
976
+ .where(and(...conditions));
977
+
978
+ const runs: StatRun[] = rows.map((r) => ({
979
+ timestamp: r.timestamp,
980
+ status: r.status,
981
+ latencyMs: r.latencyMs ?? undefined,
982
+ }));
983
+
984
+ return summarizeRuns({ runs, startDate, endDate, maxBuckets });
985
+ }
986
+
924
987
  /**
925
988
  * Get detailed health check run history with full result data.
926
989
  * Restricted to users with manage access.
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ mapHealthStatus,
4
+ rollupStatus,
5
+ overallBannerStatus,
6
+ statusBannerTitle,
7
+ } from "./rollup";
8
+
9
+ describe("mapHealthStatus", () => {
10
+ test("maps the internal health enum to the public vocabulary", () => {
11
+ expect(mapHealthStatus("healthy")).toBe("operational");
12
+ expect(mapHealthStatus("degraded")).toBe("degraded");
13
+ expect(mapHealthStatus("unhealthy")).toBe("major_outage");
14
+ expect(mapHealthStatus("weird")).toBe("unknown");
15
+ });
16
+ });
17
+
18
+ describe("rollupStatus", () => {
19
+ test("empty -> unknown; all operational -> operational", () => {
20
+ expect(rollupStatus([])).toBe("unknown");
21
+ expect(rollupStatus(["operational", "operational"])).toBe("operational");
22
+ });
23
+ test("a major outage dominates; maintenance ranks above degraded", () => {
24
+ expect(rollupStatus(["operational", "major_outage"])).toBe("major_outage");
25
+ expect(rollupStatus(["degraded", "maintenance"])).toBe("maintenance");
26
+ });
27
+ });
28
+
29
+ describe("overallBannerStatus", () => {
30
+ test("SOME down -> partial_outage; ALL down -> major_outage", () => {
31
+ expect(overallBannerStatus(["operational", "major_outage"])).toBe(
32
+ "partial_outage",
33
+ );
34
+ expect(overallBannerStatus(["major_outage", "major_outage"])).toBe(
35
+ "major_outage",
36
+ );
37
+ expect(overallBannerStatus(["major_outage", "unknown"])).toBe(
38
+ "major_outage",
39
+ );
40
+ });
41
+ test("no hard outages -> worst of the rest; empty/unknown -> unknown", () => {
42
+ expect(overallBannerStatus(["degraded", "operational"])).toBe("degraded");
43
+ expect(overallBannerStatus([])).toBe("unknown");
44
+ expect(overallBannerStatus(["unknown"])).toBe("unknown");
45
+ });
46
+ });
47
+
48
+ describe("statusBannerTitle", () => {
49
+ test("renders a human title", () => {
50
+ expect(statusBannerTitle("operational")).toBe("All systems operational");
51
+ expect(statusBannerTitle("partial_outage")).toBe("Partial system outage");
52
+ });
53
+ });
@@ -0,0 +1,82 @@
1
+ import type { PublicStatus } from "@checkstack/status-page-common";
2
+
3
+ /**
4
+ * Pure status mapping + rollup for the status-page health widgets. The INTERNAL
5
+ * health enum is never exposed; it is mapped onto the public vocabulary here.
6
+ * (Moved here from status-page-backend: the rollup is health-domain logic, so it
7
+ * lives with the plugin that owns health.)
8
+ */
9
+
10
+ export function mapHealthStatus(
11
+ internal: "healthy" | "degraded" | "unhealthy" | string,
12
+ ): PublicStatus {
13
+ switch (internal) {
14
+ case "healthy": {
15
+ return "operational";
16
+ }
17
+ case "degraded": {
18
+ return "degraded";
19
+ }
20
+ case "unhealthy": {
21
+ return "major_outage";
22
+ }
23
+ default: {
24
+ return "unknown";
25
+ }
26
+ }
27
+ }
28
+
29
+ const PRECEDENCE: PublicStatus[] = [
30
+ "major_outage",
31
+ "partial_outage",
32
+ "maintenance",
33
+ "degraded",
34
+ "operational",
35
+ "unknown",
36
+ ];
37
+
38
+ export function rollupStatus(statuses: PublicStatus[]): PublicStatus {
39
+ if (statuses.length === 0) return "unknown";
40
+ for (const candidate of PRECEDENCE) {
41
+ if (statuses.includes(candidate)) return candidate;
42
+ }
43
+ return "unknown";
44
+ }
45
+
46
+ /**
47
+ * Overall BANNER status: distinguishes a PARTIAL outage (some, not all, known
48
+ * systems down) from a MAJOR one (all known systems down). `unknown` is ignored
49
+ * unless everything is unknown.
50
+ */
51
+ export function overallBannerStatus(statuses: PublicStatus[]): PublicStatus {
52
+ const known = statuses.filter((s) => s !== "unknown");
53
+ if (known.length === 0) return "unknown";
54
+ const majors = known.filter((s) => s === "major_outage").length;
55
+ if (majors > 0) {
56
+ return majors === known.length ? "major_outage" : "partial_outage";
57
+ }
58
+ return rollupStatus(known);
59
+ }
60
+
61
+ export function statusBannerTitle(status: PublicStatus): string {
62
+ switch (status) {
63
+ case "operational": {
64
+ return "All systems operational";
65
+ }
66
+ case "degraded": {
67
+ return "Degraded performance";
68
+ }
69
+ case "partial_outage": {
70
+ return "Partial system outage";
71
+ }
72
+ case "major_outage": {
73
+ return "Major system outage";
74
+ }
75
+ case "maintenance": {
76
+ return "Under maintenance";
77
+ }
78
+ case "unknown": {
79
+ return "Status unknown";
80
+ }
81
+ }
82
+ }