@checkstack/healthcheck-backend 0.7.0 → 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,22 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d6f7449: Add availability statistics display to HealthCheckSystemOverview
8
+
9
+ - New `getAvailabilityStats` RPC endpoint that calculates availability percentages for 31-day and 365-day periods
10
+ - Availability is calculated as `(healthyRuns / totalRuns) * 100`
11
+ - Data is sourced from both daily aggregates and recent raw runs to include the most up-to-date information
12
+ - Frontend displays availability stats with color-coded badges (green ≥99.9%, yellow ≥99%, red <99%)
13
+ - Shows total run counts for each period
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [d6f7449]
18
+ - @checkstack/healthcheck-common@0.8.0
19
+
3
20
  ## 0.7.0
4
21
 
5
22
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -33,6 +33,7 @@
33
33
  "@checkstack/tsconfig": "workspace:*",
34
34
  "@orpc/server": "^1.13.2",
35
35
  "@types/bun": "^1.0.0",
36
+ "date-fns": "^4.1.0",
36
37
  "drizzle-kit": "^0.31.8",
37
38
  "typescript": "^5.0.0"
38
39
  }
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { HealthCheckService } from "./service";
3
+ import { subDays } from "date-fns";
4
+
5
+ describe("HealthCheckService.getAvailabilityStats", () => {
6
+ // Mock database
7
+ let mockDb: ReturnType<typeof createMockDb>;
8
+ let service: HealthCheckService;
9
+
10
+ // Store mock data for different queries
11
+ let mockDailyAggregates: Array<{
12
+ bucketStart: Date;
13
+ runCount: number;
14
+ healthyCount: number;
15
+ }> = [];
16
+ let mockRawRuns: Array<{
17
+ status: "healthy" | "unhealthy" | "degraded";
18
+ timestamp: Date;
19
+ }> = [];
20
+ let selectCallCount = 0;
21
+
22
+ function createMockDb() {
23
+ // Reset call counter on creation
24
+ selectCallCount = 0;
25
+
26
+ // Create a mock that handles:
27
+ // 1. First select: daily aggregates
28
+ // 2. Second select: raw runs
29
+ const createSelectChain = () => {
30
+ const currentCall = selectCallCount++;
31
+
32
+ return {
33
+ from: mock(() => ({
34
+ where: mock(() => {
35
+ // First call: daily aggregates, Second call: raw runs
36
+ if (currentCall === 0) return Promise.resolve(mockDailyAggregates);
37
+ return Promise.resolve(mockRawRuns);
38
+ }),
39
+ })),
40
+ };
41
+ };
42
+
43
+ return {
44
+ select: mock(createSelectChain),
45
+ };
46
+ }
47
+
48
+ beforeEach(() => {
49
+ // Reset mock data
50
+ mockDailyAggregates = [];
51
+ mockRawRuns = [];
52
+ mockDb = createMockDb();
53
+ service = new HealthCheckService(mockDb as never);
54
+ });
55
+
56
+ describe("with no data", () => {
57
+ it("returns null availability when no aggregates or runs exist", async () => {
58
+ const result = await service.getAvailabilityStats({
59
+ systemId: "sys-1",
60
+ configurationId: "config-1",
61
+ });
62
+
63
+ expect(result.availability31Days).toBeNull();
64
+ expect(result.availability365Days).toBeNull();
65
+ expect(result.totalRuns31Days).toBe(0);
66
+ expect(result.totalRuns365Days).toBe(0);
67
+ });
68
+ });
69
+
70
+ describe("with only daily aggregates", () => {
71
+ it("calculates 100% availability when all runs are healthy", async () => {
72
+ const now = new Date();
73
+
74
+ mockDailyAggregates = [
75
+ { bucketStart: subDays(now, 10), runCount: 100, healthyCount: 100 },
76
+ { bucketStart: subDays(now, 20), runCount: 100, healthyCount: 100 },
77
+ ];
78
+
79
+ const result = await service.getAvailabilityStats({
80
+ systemId: "sys-1",
81
+ configurationId: "config-1",
82
+ });
83
+
84
+ expect(result.availability31Days).toBe(100);
85
+ expect(result.availability365Days).toBe(100);
86
+ expect(result.totalRuns31Days).toBe(200);
87
+ expect(result.totalRuns365Days).toBe(200);
88
+ });
89
+
90
+ it("calculates correct availability with mixed results", async () => {
91
+ const now = new Date();
92
+
93
+ mockDailyAggregates = [
94
+ { bucketStart: subDays(now, 10), runCount: 100, healthyCount: 90 }, // 90%
95
+ { bucketStart: subDays(now, 20), runCount: 100, healthyCount: 80 }, // 80%
96
+ ];
97
+
98
+ const result = await service.getAvailabilityStats({
99
+ systemId: "sys-1",
100
+ configurationId: "config-1",
101
+ });
102
+
103
+ // 170 healthy / 200 total = 85%
104
+ expect(result.availability31Days).toBe(85);
105
+ expect(result.availability365Days).toBe(85);
106
+ expect(result.totalRuns31Days).toBe(200);
107
+ expect(result.totalRuns365Days).toBe(200);
108
+ });
109
+
110
+ it("separates 31-day and 365-day data correctly", async () => {
111
+ const now = new Date();
112
+
113
+ mockDailyAggregates = [
114
+ // Within 31 days
115
+ { bucketStart: subDays(now, 10), runCount: 100, healthyCount: 99 },
116
+ // Outside 31 days, but within 365 days
117
+ { bucketStart: subDays(now, 60), runCount: 100, healthyCount: 50 },
118
+ ];
119
+
120
+ const result = await service.getAvailabilityStats({
121
+ systemId: "sys-1",
122
+ configurationId: "config-1",
123
+ });
124
+
125
+ // 31 days: 99/100 = 99%
126
+ expect(result.availability31Days).toBe(99);
127
+ expect(result.totalRuns31Days).toBe(100);
128
+
129
+ // 365 days: (99+50)/200 = 74.5%
130
+ expect(result.availability365Days).toBe(74.5);
131
+ expect(result.totalRuns365Days).toBe(200);
132
+ });
133
+ });
134
+
135
+ describe("with raw runs (recent data not yet aggregated)", () => {
136
+ it("includes raw runs that are not in any aggregate bucket", async () => {
137
+ const now = new Date();
138
+
139
+ // No daily aggregates
140
+ mockDailyAggregates = [];
141
+
142
+ // Recent raw runs
143
+ mockRawRuns = [
144
+ { status: "healthy", timestamp: subDays(now, 1) },
145
+ { status: "healthy", timestamp: subDays(now, 2) },
146
+ { status: "unhealthy", timestamp: subDays(now, 3) },
147
+ ];
148
+
149
+ const result = await service.getAvailabilityStats({
150
+ systemId: "sys-1",
151
+ configurationId: "config-1",
152
+ });
153
+
154
+ // 2 healthy / 3 total = 66.67%
155
+ expect(result.availability31Days).toBeCloseTo(66.67, 1);
156
+ expect(result.availability365Days).toBeCloseTo(66.67, 1);
157
+ expect(result.totalRuns31Days).toBe(3);
158
+ expect(result.totalRuns365Days).toBe(3);
159
+ });
160
+
161
+ it("deduplicates raw runs when aggregate bucket exists", async () => {
162
+ const now = new Date();
163
+ const bucketDate = subDays(now, 5);
164
+ bucketDate.setUTCHours(0, 0, 0, 0);
165
+
166
+ // Aggregate bucket for that day
167
+ mockDailyAggregates = [
168
+ { bucketStart: bucketDate, runCount: 10, healthyCount: 9 },
169
+ ];
170
+
171
+ // Raw runs on the same day (should be deduplicated)
172
+ const runTimestamp = new Date(bucketDate);
173
+ runTimestamp.setUTCHours(12, 0, 0, 0); // Same day, different time
174
+ mockRawRuns = [
175
+ { status: "healthy", timestamp: runTimestamp },
176
+ { status: "healthy", timestamp: runTimestamp },
177
+ ];
178
+
179
+ const result = await service.getAvailabilityStats({
180
+ systemId: "sys-1",
181
+ configurationId: "config-1",
182
+ });
183
+
184
+ // Should only count the aggregate bucket, not the raw runs
185
+ expect(result.totalRuns31Days).toBe(10);
186
+ expect(result.totalRuns365Days).toBe(10);
187
+ expect(result.availability31Days).toBe(90);
188
+ });
189
+ });
190
+
191
+ describe("99.9% availability calculation", () => {
192
+ it("calculates precise availability for SLA tracking", async () => {
193
+ const now = new Date();
194
+
195
+ // Simulate a month with 1 failure out of 1000 runs
196
+ mockDailyAggregates = [
197
+ { bucketStart: subDays(now, 15), runCount: 1000, healthyCount: 999 },
198
+ ];
199
+
200
+ const result = await service.getAvailabilityStats({
201
+ systemId: "sys-1",
202
+ configurationId: "config-1",
203
+ });
204
+
205
+ expect(result.availability31Days).toBe(99.9);
206
+ expect(result.availability365Days).toBe(99.9);
207
+ });
208
+
209
+ it("calculates very high availability correctly", async () => {
210
+ const now = new Date();
211
+
212
+ // 99.99% availability
213
+ mockDailyAggregates = [
214
+ { bucketStart: subDays(now, 15), runCount: 10_000, healthyCount: 9999 },
215
+ ];
216
+
217
+ const result = await service.getAvailabilityStats({
218
+ systemId: "sys-1",
219
+ configurationId: "config-1",
220
+ });
221
+
222
+ expect(result.availability31Days).toBe(99.99);
223
+ });
224
+ });
225
+ });
package/src/router.ts CHANGED
@@ -224,6 +224,10 @@ export const createHealthCheckRouter = (
224
224
  return service.getSystemHealthOverview(input.systemId);
225
225
  },
226
226
  ),
227
+
228
+ getAvailabilityStats: os.getAvailabilityStats.handler(async ({ input }) => {
229
+ return service.getAvailabilityStats(input);
230
+ }),
227
231
  });
228
232
  };
229
233
 
package/src/service.ts CHANGED
@@ -917,6 +917,120 @@ export class HealthCheckService {
917
917
  * Calculate bucket start time for dynamic interval sizing.
918
918
  * Aligns buckets to the query start time.
919
919
  */
920
+ /**
921
+ * Get availability statistics for a health check over 31-day and 365-day periods.
922
+ * Availability is calculated as (healthyCount / totalRunCount) * 100.
923
+ */
924
+ async getAvailabilityStats(props: {
925
+ systemId: string;
926
+ configurationId: string;
927
+ }): Promise<{
928
+ availability31Days: number | null;
929
+ availability365Days: number | null;
930
+ totalRuns31Days: number;
931
+ totalRuns365Days: number;
932
+ }> {
933
+ const { systemId, configurationId } = props;
934
+ const now = new Date();
935
+
936
+ // Calculate cutoff dates
937
+ const cutoff31Days = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000);
938
+ const cutoff365Days = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
939
+
940
+ // Query daily aggregates for the full 365-day period
941
+ const dailyAggregates = await this.db
942
+ .select({
943
+ bucketStart: healthCheckAggregates.bucketStart,
944
+ runCount: healthCheckAggregates.runCount,
945
+ healthyCount: healthCheckAggregates.healthyCount,
946
+ })
947
+ .from(healthCheckAggregates)
948
+ .where(
949
+ and(
950
+ eq(healthCheckAggregates.systemId, systemId),
951
+ eq(healthCheckAggregates.configurationId, configurationId),
952
+ eq(healthCheckAggregates.bucketSize, "daily"),
953
+ gte(healthCheckAggregates.bucketStart, cutoff365Days),
954
+ ),
955
+ );
956
+
957
+ // Also query raw runs for the recent period not yet aggregated (typically last 7 days)
958
+ const recentRuns = await this.db
959
+ .select({
960
+ status: healthCheckRuns.status,
961
+ timestamp: healthCheckRuns.timestamp,
962
+ })
963
+ .from(healthCheckRuns)
964
+ .where(
965
+ and(
966
+ eq(healthCheckRuns.systemId, systemId),
967
+ eq(healthCheckRuns.configurationId, configurationId),
968
+ gte(healthCheckRuns.timestamp, cutoff365Days),
969
+ ),
970
+ );
971
+
972
+ // Separate data by period
973
+ let totalRuns31Days = 0;
974
+ let healthyRuns31Days = 0;
975
+ let totalRuns365Days = 0;
976
+ let healthyRuns365Days = 0;
977
+
978
+ // Process daily aggregates
979
+ for (const agg of dailyAggregates) {
980
+ totalRuns365Days += agg.runCount;
981
+ healthyRuns365Days += agg.healthyCount;
982
+
983
+ if (agg.bucketStart >= cutoff31Days) {
984
+ totalRuns31Days += agg.runCount;
985
+ healthyRuns31Days += agg.healthyCount;
986
+ }
987
+ }
988
+
989
+ // Process recent raw runs (to include data not yet aggregated)
990
+ // Deduplicate by checking if a run's timestamp falls within an already-counted aggregate bucket
991
+ const aggregateBucketStarts = new Set(
992
+ dailyAggregates.map((a) => a.bucketStart.getTime()),
993
+ );
994
+
995
+ for (const run of recentRuns) {
996
+ // Calculate which daily bucket this run would belong to
997
+ const runBucketStart = new Date(run.timestamp);
998
+ runBucketStart.setUTCHours(0, 0, 0, 0);
999
+
1000
+ // Only count if this bucket isn't already in aggregates
1001
+ if (!aggregateBucketStarts.has(runBucketStart.getTime())) {
1002
+ totalRuns365Days += 1;
1003
+ if (run.status === "healthy") {
1004
+ healthyRuns365Days += 1;
1005
+ }
1006
+
1007
+ if (run.timestamp >= cutoff31Days) {
1008
+ totalRuns31Days += 1;
1009
+ if (run.status === "healthy") {
1010
+ healthyRuns31Days += 1;
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // Calculate availability percentages
1017
+ const availability31Days =
1018
+ // eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
1019
+ totalRuns31Days > 0 ? (healthyRuns31Days / totalRuns31Days) * 100 : null;
1020
+ const availability365Days =
1021
+ totalRuns365Days > 0
1022
+ ? (healthyRuns365Days / totalRuns365Days) * 100
1023
+ : // eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
1024
+ null;
1025
+
1026
+ return {
1027
+ availability31Days,
1028
+ availability365Days,
1029
+ totalRuns31Days,
1030
+ totalRuns365Days,
1031
+ };
1032
+ }
1033
+
920
1034
  private getBucketStartDynamic(
921
1035
  timestamp: Date,
922
1036
  rangeStart: Date,