@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 +17 -0
- package/package.json +2 -1
- package/src/availability.test.ts +225 -0
- package/src/router.ts +4 -0
- package/src/service.ts +114 -0
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.
|
|
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,
|