diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 896a5e302..b0ef0c927 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -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 { + const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cached = await cache.get(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 { + 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({ diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 48aef424f..abb1e4c2b 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -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 ); } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 289b19b90..006c8f622 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -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, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { 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, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { 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, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { 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, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { 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", diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 36e3dacff..76939b537 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -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) diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 2fa596950..d2ef1ec26 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -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(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") ); } -} +} \ No newline at end of file diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts index 9aa548624..c3dcf6c88 100644 --- a/server/routers/resource/getStatusHistory.ts +++ b/server/routers/resource/getStatusHistory.ts @@ -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(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") ); } -} +} \ No newline at end of file diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index f1717c8a9..26f1dbbd2 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -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(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") ); } -} +} \ No newline at end of file diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a44a1a1fb..96df0260d 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -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 diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 99f1acdeb..92c434a19 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -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