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 { 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 export const statusHistoryQuerySchema = z
.object({ .object({

View File

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

View File

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

View File

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

View File

@@ -13,15 +13,13 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
computeBuckets, getCachedStatusHistory,
statusHistoryQuerySchema, statusHistoryQuerySchema,
StatusHistoryResponse StatusHistoryResponse
} from "@server/lib/statusHistory"; } from "@server/lib/statusHistory";
@@ -59,39 +57,10 @@ export async function getHealthCheckStatusHistory(
const entityId = parsedParams.data.healthCheckId; const entityId = parsedParams.data.healthCheckId;
const { days } = parsedQuery.data; const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000); const data = await getCachedStatusHistory(entityType, entityId, days);
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;
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data: { data,
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
success: true, success: true,
error: false, error: false,
message: "Status history retrieved successfully", message: "Status history retrieved successfully",
@@ -103,4 +72,4 @@ export async function getHealthCheckStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -1,14 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
computeBuckets, getCachedStatusHistory,
statusHistoryQuerySchema, statusHistoryQuerySchema,
StatusHistoryResponse StatusHistoryResponse
} from "@server/lib/statusHistory"; } from "@server/lib/statusHistory";
@@ -46,39 +44,10 @@ export async function getResourceStatusHistory(
const entityId = parsedParams.data.resourceId; const entityId = parsedParams.data.resourceId;
const { days } = parsedQuery.data; const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000); const data = await getCachedStatusHistory(entityType, entityId, days);
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;
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data: { data,
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
success: true, success: true,
error: false, error: false,
message: "Status history retrieved successfully", message: "Status history retrieved successfully",
@@ -90,4 +59,4 @@ export async function getResourceStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -1,14 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
computeBuckets, getCachedStatusHistory,
statusHistoryQuerySchema, statusHistoryQuerySchema,
StatusHistoryResponse StatusHistoryResponse
} from "@server/lib/statusHistory"; } from "@server/lib/statusHistory";
@@ -46,39 +44,10 @@ export async function getSiteStatusHistory(
const entityId = parsedParams.data.siteId; const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data; const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000); const data = await getCachedStatusHistory(entityType, entityId, days);
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;
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data: { data,
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
success: true, success: true,
error: false, error: false,
message: "Status history retrieved successfully", message: "Status history retrieved successfully",
@@ -90,4 +59,4 @@ export async function getSiteStatusHistory(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

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

View File

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