backend implementation

This commit is contained in:
Alex Holliday committed 2026-03-09 11:14:40 -07:00
1 parent 0058772f61
commit d9cb81e71d
10 files changed
+117 -75

No files matched your search

+1
View File
@@ -252,6 +252,7 @@ export const initializeServices = async ({
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
+1 -11
View File
@@ -21,13 +21,11 @@ type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
type: MonitorType;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "createdAt" | "updatedAt"> & {
metadata: CheckMetadataDocument;
expiry: Date;
ackAt?: Date | null;
createdAt: Date;
updatedAt: Date;
__v: number;
};
interface CheckDocument extends CheckDocumentBase {
@@ -229,14 +227,6 @@ const CheckSchema = new Schema<CheckDocument>(
type: Date,
default: Date.now,
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
default: undefined,
},
cpu: {
type: cpuSchema,
default: () => ({}),
@@ -47,4 +47,5 @@ export interface IChecksRepository {
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
@@ -46,13 +46,6 @@ class MongoChecksRepository implements IChecksRepository {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const toOptionalDateString = (value?: Date | string | null): string | undefined => {
if (!value) {
return undefined;
}
return toDateString(value);
};
const mapTimings = (timings?: GotTimings): GotTimings => {
const phases = timings?.phases ?? {
wait: 0,
@@ -171,8 +164,6 @@ class MongoChecksRepository implements IChecksRepository {
timings: mapTimings(doc.timings),
statusCode: doc.statusCode ?? 0,
message: doc.message ?? "",
ack: doc.ack ?? false,
ackAt: toOptionalDateString(doc.ackAt),
expiry: toDateString(doc.expiry),
cpu: mapCpu(doc.cpu),
memory: mapMemory(doc.memory),
@@ -186,7 +177,6 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -412,6 +402,11 @@ class MongoChecksRepository implements IChecksRepository {
return result.deletedCount ?? 0;
};
deleteOlderThan = async (date: Date): Promise<number> => {
const result = await CheckModel.deleteMany({ createdAt: { $lt: date } });
return result.deletedCount ?? 0;
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
+26 -5
View File
@@ -1,6 +1,14 @@
import { Types } from "mongoose";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } from "@/types/index.js";
import type {
MonitorStatusResponse,
CheckErrorInfo,
Check,
ILighthouseAudit,
ChecksQueryResult,
ChecksSummary,
MonitorPayloadMap,
} from "@/types/index.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
@@ -10,12 +18,13 @@ const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>): Partial<Check> | undefined;
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
getChecksByMonitor(params: { monitorId: string; query: Record<string, string>; teamId: string }): Promise<ChecksQueryResult>;
getChecksByTeam(params: { teamId: string; query: Record<string, string> }): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
updateChecksTTL(params: { teamId: string; ttl: string }): Promise<void>;
}
@@ -39,12 +48,14 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const check: Partial<Check> = {
const now = new Date().toISOString();
const check: Check = {
id: new Types.ObjectId().toString(),
createdAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
metadata: {
monitorId,
teamId,
@@ -55,6 +66,7 @@ export class CheckService implements ICheckService {
responseTime: responseTime || 0,
timings: timings,
message,
expiry: now,
};
if (type === "pagespeed") {
@@ -190,6 +202,15 @@ export class CheckService implements ICheckService {
return deletedCount;
};
deleteOlderThan = async (date: Date) => {
if (!date) {
throw new AppError({ message: "No date provided", service: SERVICE_NAME, method: "deleteChecksOlderThan", status: 400 });
}
const deletedCount = await this.checksRepository.deleteOlderThan(date);
return deletedCount;
};
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } });
};
@@ -89,9 +89,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
try {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob());
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
@@ -104,6 +105,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
}
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 });
return true;
} catch (error: any) {
@@ -2,8 +2,16 @@ const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { INetworkService, INotificationsService, IStatusService, IncidentService, type IGeoChecksService } from "@/service/index.js";
import type { StatusChangeResult } from "@/types/index.js";
import {
ICheckService,
INetworkService,
INotificationsService,
ISettingsService,
IStatusService,
IncidentService,
type IGeoChecksService,
} from "@/service/index.js";
import type { MaintenanceWindow, StatusChangeResult } from "@/types/index.js";
import {
IMaintenanceWindowsRepository,
IMonitorsRepository,
@@ -18,9 +26,10 @@ import { IBufferService } from "@/service/index.js";
export interface ISuperSimpleQueueHelper {
readonly serviceName: string;
getMonitorJob(): (monitor: Monitor) => Promise<void>;
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
getCleanupOrphanedJob(): () => Promise<void>;
getGeoCheckJob(): (monitor: Monitor) => Promise<void>;
getCleanupRetentionJob(): () => Promise<void>;
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
}
@@ -41,11 +50,12 @@ export interface MonitorActionDecision {
export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
private logger: ILogger;
private networkService: INetworkService;
private statusService: IStatusService;
private notificationsService: INotificationsService;
private checkService: any;
private checkService: ICheckService;
private settingsService: ISettingsService;
private buffer: IBufferService;
private incidentService: IncidentService;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
@@ -62,7 +72,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
networkService: INetworkService,
statusService: IStatusService,
notificationsService: INotificationsService,
checkService: any,
checkService: ICheckService,
settingsService: ISettingsService,
buffer: IBufferService,
incidentService: IncidentService,
maintenanceWindowsRepository: IMaintenanceWindowsRepository,
@@ -78,6 +89,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.networkService = networkService;
this.statusService = statusService;
this.checkService = checkService;
this.settingsService = settingsService;
this.buffer = buffer;
this.notificationsService = notificationsService;
this.incidentService = incidentService;
@@ -95,7 +107,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return SuperSimpleQueueHelper.SERVICE_NAME;
}
getMonitorJob = () => {
getHeartbeatJob = () => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
@@ -126,7 +138,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
}
// Step 3. Build check
const check = await this.checkService.buildCheck(status);
const check = this.checkService.buildCheck(status);
if (!check) {
this.logger.warn({
message: `No check could be built for monitor ${monitorId}`,
@@ -146,33 +158,31 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
// Step 6. Handle notifications (best effort, continue even in event of failure, don't wait)
if (decision.shouldSendNotification) {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => {
this.logger.error({
message: error.message,
message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`,
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
});
}
// Step 7. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => {
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
this.logger.warn({
message: error.message,
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error handling incident for job ${monitor.id}: ${error.message}`,
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
});
} catch (error: any) {
} catch (error: unknown) {
this.logger.warn({
message: error.message,
service: error.service || SERVICE_NAME,
method: error.method || "getMonitorJob",
stack: error.stack,
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -258,19 +268,19 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
} catch (error: any) {
} catch (error: unknown) {
this.logger.warn({
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
};
getGeoCheckJob = () => {
getHeartbeatGeoJob = () => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
@@ -278,7 +288,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
// Step 1: Validate monitor eligibility
if (!monitorId) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" });
}
if (!monitor.geoCheckEnabled) {
@@ -288,7 +298,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.debug({
message: `Monitor ${monitorId} type does not support geo checks, skipping`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
method: "getHeartbeatGeoJob",
});
return;
}
@@ -297,7 +307,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.warn({
message: `No geo check locations configured for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
method: "getHeartbeatGeoJob",
});
return;
}
@@ -308,7 +318,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.debug({
message: `Monitor ${monitorId} is in maintenance window, skipping geo check`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
method: "getHeartbeatGeoJob",
});
return;
}
@@ -319,7 +329,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.warn({
message: `No geo check could be built for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
method: "getHeartbeatGeoJob",
});
return;
}
@@ -330,14 +340,14 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.debug({
message: `Geo check job executed for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
method: "getHeartbeatGeoJob",
});
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getGeoCheckJob",
stack: error.stack,
method: "getHeartbeatGeoJob",
stack: error instanceof Error ? error.stack : undefined,
});
// Don't throw - geo check failures shouldn't crash the job scheduler
}
@@ -347,7 +357,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => {
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
@@ -374,6 +384,31 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return maintenanceWindowIsActive;
}
getCleanupRetentionJob = () => {
return async () => {
try {
const settings = await this.settingsService.getDBSettings();
const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS
const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000;
const cutoffDate = new Date(Date.now() - checkTTLInMs);
const deleteCount = await this.checkService.deleteOlderThan(cutoffDate);
this.logger.info({
message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`,
service: SERVICE_NAME,
method: "getCleanupRetentionJob",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getCleanupRetentionJob",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
};
private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision {
const { monitor, statusChanged, prevStatus } = statusChangeResult;
@@ -1,4 +1,4 @@
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
import type { Monitor, MonitorStatusResponse, MonitorPayloadMap, MonitorType } from "@/types/index.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
@@ -8,7 +8,7 @@ const SERVICE_NAME = "NetworkService";
export interface INetworkService {
readonly serviceName: string;
requestStatus(monitor: Monitor): Promise<MonitorStatusResponse<unknown>>;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
url: string,
@@ -43,12 +43,12 @@ export class NetworkService implements INetworkService {
}
// Main entry point
async requestStatus(monitor: Monitor) {
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
if (!provider) {
return this.handleUnsupportedType(monitor.type);
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
return provider.handle(monitor);
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
+1 -4
View File
@@ -98,8 +98,6 @@ export interface Check {
timings?: GotTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
@@ -113,7 +111,6 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -205,4 +202,4 @@ export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt">;
export type CheckSnapshot = Omit<Check, "metadata" | "expiry" | "updatedAt">;
+1 -1
View File
@@ -126,7 +126,7 @@ export interface MonitorPayloadMap {
game: GameStatusPayload;
grpc: GrpcStatusPayload;
websocket: WebSocketStatusPayload;
default: unknown;
unknown: unknown;
}
export type StatusChangeResult = {