Add caching to the hc and fix resource stuff

This commit is contained in:
Owen
2026-04-27 14:29:57 -07:00
parent 61aaa5a832
commit 28dd06c41f
9 changed files with 140 additions and 122 deletions

View File

@@ -1,4 +1,73 @@
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
function statusHistoryCacheKey(
entityType: string,
entityId: number,
days: number
): string {
return `statusHistory:${entityType}:${entityId}:${days}`;
}
export async function getCachedStatusHistory(
entityType: string,
entityId: number,
days: number
): Promise<StatusHistoryResponse> {
const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
if (cached !== undefined) {
return cached;
}
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100)
: 100;
const result: StatusHistoryResponse = {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
};
await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result;
}
export async function invalidateStatusHistoryCache(
entityType: string,
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix));
if (keys.length > 0) {
await cache.del(keys);
}
}
export const statusHistoryQuerySchema = z
.object({

View File

@@ -22,6 +22,7 @@ import {
Transaction
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
@@ -61,8 +62,9 @@ export async function fireHealthCheckHealthyAlert(
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, trx);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
@@ -124,8 +126,9 @@ export async function fireHealthCheckUnhealthyAlert(
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, trx);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
@@ -176,8 +179,9 @@ export async function fireHealthCheckUnknownAlert(
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, trx);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
@@ -190,11 +194,11 @@ export async function fireHealthCheckUnknownAlert(
}
}
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
async function handleResource(orgId: string, healthCheckTargetId?: number | null, send: boolean = true, trx: Transaction | typeof db = db) {
if (!healthCheckTargetId) {
return;
}
// we have resources lets get them
// we have targets lets get them
const [target] = await trx
.select()
.from(targets)
@@ -204,6 +208,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
@@ -213,6 +218,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
@@ -256,6 +262,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "unhealthy") {
@@ -264,6 +271,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "healthy") {
@@ -272,6 +280,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "degraded") {
@@ -280,6 +289,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
resource.resourceId,
resource.name,
undefined,
send,
trx
);
}

View File

@@ -14,6 +14,7 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
// Public API
@@ -35,6 +36,7 @@ export async function fireResourceHealthyAlert(
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
@@ -45,6 +47,11 @@ export async function fireResourceHealthyAlert(
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_healthy",
@@ -90,6 +97,7 @@ export async function fireResourceUnhealthyAlert(
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
@@ -100,6 +108,11 @@ export async function fireResourceUnhealthyAlert(
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_unhealthy",
@@ -145,6 +158,7 @@ export async function fireResourceDegradedAlert(
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
@@ -155,6 +169,11 @@ export async function fireResourceDegradedAlert(
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_degraded",
@@ -200,6 +219,7 @@ export async function fireResourceUnknownAlert(
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
@@ -210,6 +230,11 @@ export async function fireResourceUnknownAlert(
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_toggle",

View File

@@ -14,6 +14,7 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
@@ -47,6 +48,7 @@ export async function fireSiteOnlineAlert(
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
await processAlerts({
eventType: "site_online",
@@ -102,6 +104,7 @@ export async function fireSiteOfflineAlert(
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)

View File

@@ -13,15 +13,13 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
computeBuckets,
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
@@ -59,39 +57,10 @@ export async function getHealthCheckStatusHistory(
const entityId = parsedParams.data.healthCheckId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
data,
success: true,
error: false,
message: "Status history retrieved successfully",
@@ -103,4 +72,4 @@ export async function getHealthCheckStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -1,14 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
computeBuckets,
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
@@ -46,39 +44,10 @@ export async function getResourceStatusHistory(
const entityId = parsedParams.data.resourceId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
data,
success: true,
error: false,
message: "Status history retrieved successfully",
@@ -90,4 +59,4 @@ export async function getResourceStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -1,14 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
computeBuckets,
getCachedStatusHistory,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
@@ -46,39 +44,10 @@ export async function getSiteStatusHistory(
const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
const data = await getCachedStatusHistory(entityType, entityId, days);
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
data,
success: true,
error: false,
message: "Status history retrieved successfully",
@@ -90,4 +59,4 @@ export async function getSiteStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -267,7 +267,7 @@ export async function createTarget(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
@@ -278,7 +278,7 @@ export async function createTarget(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
@@ -288,7 +288,7 @@ export async function createTarget(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
healthCheck[0].targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx

View File

@@ -228,12 +228,7 @@ export async function updateTarget(
hcHealthValue = undefined;
}
const isDisablingHc =
(parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null) &&
existingHc.hcEnabled === true;
const [updatedHc] = await trx
[updatedHc] = await trx
.update(targetHealthCheck)
.set({
siteId: parsedBody.data.siteId,
@@ -259,32 +254,41 @@ export async function updateTarget(
.returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
);
await fireHealthCheckUnhealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name || "",
undefined,
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
);
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
undefined,
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
);
await fireHealthCheckHealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
undefined,
updatedHc.targetId,
undefined,
false, // dont send the alert because we just want to create the alert, not notify users yet
trx