Files
Checkmate/server/src/service/infrastructure/statusService.js
T
2026-01-13 20:20:00 +00:00

449 lines
14 KiB
JavaScript
Executable File

import MonitorStats from "../../db/models/MonitorStats.js";
import { CheckModel } from "@/db/models/index.js";
const SERVICE_NAME = "StatusService";
class StatusService {
static SERVICE_NAME = SERVICE_NAME;
/**
* @param {{
* buffer: import("./bufferService.js").BufferService
* incidentService: import("../business/incidentService.js").IncidentService
* }}
*/ constructor({ db, logger, buffer, incidentService, monitorsRepository }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
this.incidentService = incidentService;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return StatusService.SERVICE_NAME;
}
async updateRunningStats({ monitor, networkResponse }) {
try {
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
// Get stats
let stats = await MonitorStats.findOne({ monitorId });
if (!stats) {
stats = new MonitorStats({
monitorId,
avgResponseTime: 0,
totalChecks: 0,
totalUpChecks: 0,
totalDownChecks: 0,
uptimePercentage: 0,
lastCheck: null,
});
}
// Update stats
// Last response time
stats.lastResponseTime = responseTime;
// Avg response time:
let avgResponseTime = stats.avgResponseTime;
if (typeof responseTime !== "undefined" && responseTime !== null) {
if (avgResponseTime === 0) {
avgResponseTime = responseTime;
} else {
avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + responseTime) / stats.totalChecks;
}
}
stats.avgResponseTime = avgResponseTime;
// Total checks
stats.totalChecks++;
if (status === true) {
stats.totalUpChecks++;
// Update the timeSinceLastFailure if needed
if (stats.timeOfLastFailure === 0) {
stats.timeOfLastFailure = new Date().getTime();
}
} else {
stats.totalDownChecks++;
stats.timeOfLastFailure = 0;
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
await stats.save();
return true;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
message: error.message,
method: "updateRunningStats",
stack: error.stack,
});
return false;
}
}
getStatusString = (status) => {
if (status === true) return "up";
if (status === false) return "down";
return "unknown";
};
/**
* Saves check if needed and adds to incident buffer
* Removes check from checks buffer if it was saved immediately
*
* @param {Object} check - The check object
* @param {Object} monitor - The monitor object
* @param {string} action - The incident action ("create" or "resolve")
* @param {string} errorContext - Context for error messages
* @returns {Promise<void>}
*/
handleIncidentForCheck = async (check, monitor, action, errorContext = "incident handling") => {
try {
let savedCheck = check;
if (!check._id) {
try {
const checkModel = new CheckModel(check);
savedCheck = await checkModel.save();
this.buffer.removeCheckFromBuffer(check);
} catch (checkError) {
this.logger.error({
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`,
monitorId: monitor.id,
stack: checkError.stack,
});
savedCheck = null;
}
}
if (savedCheck && savedCheck._id) {
try {
this.buffer.addIncidentToBuffer({ monitor, check: savedCheck, action });
} catch (incidentError) {
this.logger.error({
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`,
monitorId: monitor.id,
action,
stack: incidentError.stack,
});
}
}
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Error in ${errorContext}: ${error.message}`,
monitorId: monitor?.id,
stack: error.stack,
});
}
};
/**
* Updates the status of a monitor based on the network response.
*
* @param {Object} networkResponse - The network response containing monitorId and status.
* @param {string} networkResponse.monitorId - The ID of the monitor.
* @param {string} networkResponse.status - The new status of the monitor.
* @returns {Promise<Object>} - A promise that resolves to an object containinfg the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred.
* @returns {Promise<Object>} returnObject - The object returned by the function.
* @returns {Object} returnObject.monitor - The monitor object.
* @returns {boolean} returnObject.statusChanged - Flag indicating if the status has changed.
* @returns {boolean} returnObject.prevStatus - The previous status of the monitor
*/
updateStatus = async (networkResponse) => {
const check = this.buildCheck(networkResponse);
await this.insertCheck(check);
try {
const { monitorId, status, code } = networkResponse;
const monitor = await this.monitorsRepository.findById(monitorId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
// If the status window size has changed, empty
while (monitor.statusWindow.length > monitor.statusWindowSize) {
monitor.statusWindow.shift();
}
// Update status sliding window
monitor.statusWindow.push(status);
if (monitor.statusWindow.length > monitor.statusWindowSize) {
monitor.statusWindow.shift();
}
if (monitor.status === undefined || monitor.status === null) {
monitor.status = status;
}
let newStatus = monitor.status;
let statusChanged = false;
const prevStatus = monitor.status;
// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor: updated,
statusChanged: false,
prevStatus,
code,
timestamp: Date.now(),
};
}
// Check if threshold has been met
const failures = monitor.statusWindow.filter((s) => s === false).length;
const failureRate = (failures / monitor.statusWindow.length) * 100;
// If threshold has been met and the monitor is not already down, mark down:
if (failureRate >= monitor.statusWindowThreshold && monitor.status !== false) {
newStatus = false;
statusChanged = true;
}
// If the failure rate is below the threshold and the monitor is down, recover:
else if (failureRate < monitor.statusWindowThreshold && monitor.status === false) {
newStatus = true;
statusChanged = true;
}
if (statusChanged) {
this.logger.info({
service: this.SERVICE_NAME,
message: `${monitor.name} went from ${this.getStatusString(prevStatus)} to ${this.getStatusString(newStatus)}`,
prevStatus,
newStatus,
});
if (newStatus === false) {
await this.handleIncidentForCheck(check, monitor, "create", "status change to down");
} else if (prevStatus === false) {
await this.handleIncidentForCheck(check, monitor, "resolve", "status change to up");
}
}
if (monitor.status === false && !statusChanged) {
try {
const lastManuallyResolvedIncident = await this.db.incidentModule.getLastManuallyResolvedIncident(monitor.id);
let calculatedFailureRate = failureRate;
if (lastManuallyResolvedIncident && lastManuallyResolvedIncident.endTime) {
try {
const checksAfterResolution = await Check.find({
monitorId: monitor.id,
createdAt: { $gt: lastManuallyResolvedIncident.endTime },
})
.sort({ createdAt: 1 })
.limit(monitor.statusWindowSize)
.select("status")
.lean();
if (checksAfterResolution.length > 0) {
const checksStatuses = checksAfterResolution.map((c) => c.status);
const failuresAfterResolution = checksStatuses.filter((s) => s === false).length;
calculatedFailureRate = (failuresAfterResolution / monitor.statusWindow.length) * 100;
} else {
calculatedFailureRate = 0;
}
} catch (checkQueryError) {
this.logger.error({
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Failed to query checks after manual resolution: ${checkQueryError.message}`,
monitorId: monitor.id,
stack: checkQueryError.stack,
});
}
}
if (calculatedFailureRate >= monitor.statusWindowThreshold) {
await this.handleIncidentForCheck(check, monitor, "create", "threshold check without status change");
}
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Error handling threshold check without status change: ${error.message}`,
monitorId: monitor.id,
stack: error.stack,
});
}
}
monitor.status = newStatus;
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor: updated,
statusChanged,
prevStatus,
code,
timestamp: new Date().getTime(),
};
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "updateStatus";
throw error;
}
};
/**
* Builds a check object from the network response.
*
* @param {Object} networkResponse - The network response object.
* @param {string} networkResponse.monitorId - The monitor ID.
* @param {string} networkResponse.type - The type of the response.
* @param {string} networkResponse.status - The status of the response.
* @param {number} networkResponse.responseTime - The response time.
* @param {number} networkResponse.code - The status code.
* @param {string} networkResponse.message - The message.
* @param {Object} networkResponse.payload - The payload of the response.
* @returns {Object} The check object.
*/
buildCheck = (networkResponse) => {
const {
monitorId,
teamId,
type,
status,
responseTime,
code,
message,
payload,
first_byte_took,
body_read_took,
dns_took,
conn_took,
connect_took,
tls_took,
timings,
} = networkResponse;
const check = {
metadata: {
monitorId,
teamId,
type,
},
status,
statusCode: code,
responseTime,
timings: timings || {},
message,
first_byte_took,
body_read_took,
dns_took,
conn_took,
connect_took,
tls_took,
};
if (type === "pagespeed") {
if (typeof payload === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: this.SERVICE_NAME,
method: "buildCheck",
details: "empty payload",
});
return undefined;
}
const categories = payload?.lighthouseResult?.categories ?? {};
const audits = payload?.lighthouseResult?.audits ?? {};
const mapAudit = (audit) => {
if (!audit || typeof audit !== "object") {
return undefined;
}
return {
id: audit.id,
title: audit.title,
score: typeof audit.score === "number" ? audit.score : (audit.score ?? null),
displayValue: audit.displayValue,
numericValue: typeof audit.numericValue === "number" ? audit.numericValue : undefined,
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
};
}
if (type === "hardware") {
const { cpu, memory, disk, host, net } = payload?.data ?? {};
const { errors } = payload?.errors ?? [];
check.cpu = cpu ?? {};
check.memory = memory ?? {};
check.disk = disk ?? {};
check.host = host ?? {};
check.errors = errors ?? [];
check.capture = payload?.capture ?? {};
check.net = net ?? {};
}
return check;
};
/**
* Inserts a check into the database based on the network response.
*
* @param {Object} networkResponse - The network response object.
* @param {string} networkResponse.monitorId - The monitor ID.
* @param {string} networkResponse.type - The type of the response.
* @param {string} networkResponse.status - The status of the response.
* @param {number} networkResponse.responseTime - The response time.
* @param {number} networkResponse.code - The status code.
* @param {string} networkResponse.message - The message.
* @param {Object} networkResponse.payload - The payload of the response.
* @returns {Promise<void>} A promise that resolves when the check is inserted.
*/
insertCheck = async (check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: this.SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer({ check });
return true;
} catch (error) {
this.logger.error({
message: error.message,
service: error.service || this.SERVICE_NAME,
method: error.method || "insertCheck",
details: error.details || `Error inserting check for monitor: ${check?.monitorId}`,
stack: error.stack,
});
}
};
}
export default StatusService;