Merge pull request #3067 from bluewave-labs/fix-incidents-creation

Incident Management System - Backend Implementation
This commit is contained in:
Alexander Holliday
2025-11-19 09:35:15 -08:00
committed by GitHub
12 changed files with 1333 additions and 15 deletions

View File

@@ -14,6 +14,7 @@ import LogController from "../controllers/v1/logController.js";
import StatusPageController from "../controllers/v1/statusPageController.js";
import NotificationController from "../controllers/v1/notificationController.js";
import DiagnosticController from "../controllers/v1/diagnosticController.js";
import IncidentController from "../controllers/v1/incidentController.js";
// V2 Controllers
import AuthControllerV2 from "../controllers/v2/AuthController.js";
@@ -70,6 +71,10 @@ export const initializeControllers = (services) => {
diagnosticService: services.diagnosticService,
});
controllers.incidentController = new IncidentController(commonDependencies, {
incidentService: services.incidentService,
});
//V2
controllers.authControllerV2 = new AuthControllerV2(services.authServiceV2, services.inviteServiceV2);
controllers.inviteControllerV2 = new InviteControllerV2(services.inviteServiceV2);

View File

@@ -13,6 +13,8 @@ import LogRoutes from "../routes/v1/logRoutes.js";
import DiagnosticRoutes from "../routes/v1//diagnosticRoute.js";
import NotificationRoutes from "../routes/v1/notificationRoute.js";
import IncidentRoutes from "../routes/v1/incidentRoute.js";
//V2
import AuthRoutesV2 from "../routes/v2/auth.js";
import InviteRoutesV2 from "../routes/v2/invite.js";
@@ -34,6 +36,7 @@ export const setupRoutes = (app, controllers) => {
const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController);
const notificationRoutes = new NotificationRoutes(controllers.notificationController);
const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController);
const incidentRoutes = new IncidentRoutes(controllers.incidentController);
app.use("/api/v1/auth", authApiLimiter, authRoutes.getRouter());
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
@@ -46,6 +49,7 @@ export const setupRoutes = (app, controllers) => {
app.use("/api/v1/status-page", statusPageRoutes.getRouter());
app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter());
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter());
app.use("/api/v1/incidents", verifyJWT, incidentRoutes.getRouter());
// V2
const authRoutesV2 = new AuthRoutesV2(controllers.authControllerV2);

View File

@@ -17,6 +17,7 @@ import DiagnosticService from "../service/v1/business/diagnosticService.js";
import InviteService from "../service/v1/business/inviteService.js";
import MaintenanceWindowService from "../service/v1/business/maintenanceWindowService.js";
import MonitorService from "../service/v1/business/monitorService.js";
import IncidentService from "../service/v1/business/incidentService.js";
import papaparse from "papaparse";
import axios from "axios";
import got from "got";
@@ -57,6 +58,7 @@ import MonitorStats from "../db/v1/models/MonitorStats.js";
import Notification from "../db/v1/models/Notification.js";
import RecoveryToken from "../db/v1/models/RecoveryToken.js";
import AppSettings from "../db/v1/models/AppSettings.js";
import Incident from "../db/v1/models/Incident.js";
import InviteModule from "../db/v1/modules/inviteModule.js";
import CheckModule from "../db/v1/modules/checkModule.js";
@@ -67,6 +69,7 @@ import MonitorModule from "../db/v1/modules/monitorModule.js";
import NotificationModule from "../db/v1/modules/notificationModule.js";
import RecoveryModule from "../db/v1/modules/recoveryModule.js";
import SettingsModule from "../db/v1/modules/settingsModule.js";
import IncidentModule from "../db/v1/modules/incidentModule.js";
// V2 Business
import AuthServiceV2 from "../service/v2/business/AuthService.js";
@@ -120,6 +123,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const notificationModule = new NotificationModule({ Notification, Monitor });
const recoveryModule = new RecoveryModule({ User, RecoveryToken, crypto, stringService });
const settingsModule = new SettingsModule({ AppSettings });
const incidentModule = new IncidentModule({ logger, Incident, Monitor, User });
const db = new MongoDB({
logger,
@@ -133,6 +137,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
notificationModule,
recoveryModule,
settingsModule,
incidentModule,
});
await db.connect();
@@ -152,8 +157,18 @@ export const initializeServices = async ({ logger, envSettings, settingsService
settingsService,
});
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const bufferService = new BufferService({ db, logger, envSettings });
const statusService = new StatusService({ db, logger, buffer: bufferService });
const errorService = new ErrorService();
const incidentService = new IncidentService({
db,
logger,
errorService,
stringService,
});
const bufferService = new BufferService({ db, logger, envSettings, incidentService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService });
const notificationUtils = new NotificationUtils({
stringService,
@@ -169,8 +184,6 @@ export const initializeServices = async ({ logger, envSettings, settingsService
notificationUtils,
});
const errorService = new ErrorService();
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
db,
logger,
@@ -275,6 +288,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
inviteService,
maintenanceWindowService,
monitorService,
incidentService,
errorService,
logger,
//v2

View File

@@ -0,0 +1,184 @@
import BaseController from "./baseController.js";
const SERVICE_NAME = "incidentController";
/**
* Incident Controller
*
* Handles all incident-related HTTP requests including retrieving incidents,
* resolving incidents manually, and getting incident summaries.
*
* @class IncidentController
* @description Manages incident operations and tracking
*/
class IncidentController extends BaseController {
static SERVICE_NAME = SERVICE_NAME;
/**
* Creates an instance of IncidentController.
*
* @param {Object} commonDependencies - Common dependencies injected into the controller
* @param {Object} dependencies - The dependencies required by the controller
* @param {Object} dependencies.incidentService - Incident business logic service
*/
constructor(commonDependencies, { incidentService }) {
super(commonDependencies);
this.incidentService = incidentService;
}
get serviceName() {
return IncidentController.SERVICE_NAME;
}
/**
* Retrieves all incidents for the current user's team with filtering and pagination.
*
* @async
* @function getIncidentsByTeam
* @param {Object} req - Express request object
* @param {Object} req.query - Query parameters for filtering and pagination
* @param {string} [req.query.sortOrder] - Sort order (asc/desc)
* @param {string} [req.query.dateRange] - Date range filter
* @param {string} [req.query.filter] - General filter string
* @param {number} [req.query.page] - Page number for pagination
* @param {number} [req.query.rowsPerPage] - Number of rows per page
* @param {boolean} [req.query.status] - Filter by incident status (true=active, false=resolved)
* @param {string} [req.query.monitorId] - Filter by monitor ID
* @param {string} [req.query.resolutionType] - Filter by resolution type (automatic/manual)
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user.teamId - User's team ID
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with incidents data
* @throws {Error} 422 - Validation error if query parameters are invalid
* @example
* GET /incidents/team?page=1&rowsPerPage=20&status=true&filter=active
* // Requires JWT authentication
*/
getIncidentsByTeam = this.asyncHandler(
async (req, res) => {
const result = await this.incidentService.getIncidentsByTeam({
teamId: req?.user?.teamId,
query: req?.query,
});
return res.success({
msg: "Incidents retrieved successfully",
data: result,
});
},
SERVICE_NAME,
"getIncidentsByTeam"
);
/**
* Retrieves a summary of incidents for the current user's team.
*
* @async
* @function getIncidentSummary
* @param {Object} req - Express request object
* @param {Object} req.query - Query parameters
* @param {string} [req.query.dateRange] - Date range filter
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user.teamId - User's team ID
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with incidents summary
* @example
* GET /incidents/summary?dateRange=week
* // Requires JWT authentication
*/
getIncidentSummary = this.asyncHandler(
async (req, res) => {
const summary = await this.incidentService.getIncidentSummary({
teamId: req?.user?.teamId,
query: req?.query,
});
return res.success({
msg: "Incident summary retrieved successfully",
data: summary,
});
},
SERVICE_NAME,
"getIncidentSummary"
);
/**
* Retrieves a specific incident by ID.
*
* @async
* @function getIncidentById
* @param {Object} req - Express request object
* @param {Object} req.params - URL parameters
* @param {string} req.params.incidentId - ID of the incident to retrieve
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user.teamId - User's team ID
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with incident data
* @throws {Error} 404 - Not found if incident doesn't exist
* @throws {Error} 403 - Forbidden if user doesn't have access to incident
* @example
* GET /incidents/507f1f77bcf86cd799439011
* // Requires JWT authentication
*/
getIncidentById = this.asyncHandler(
async (req, res) => {
const incident = await this.incidentService.getIncidentById({
incidentId: req?.params?.incidentId,
teamId: req?.user?.teamId,
});
return res.success({
msg: "Incident retrieved successfully",
data: incident,
});
},
SERVICE_NAME,
"getIncidentById"
);
/**
* Manually resolves a specific incident by ID.
*
* @async
* @function resolveIncidentManually
* @param {Object} req - Express request object
* @param {Object} req.params - URL parameters
* @param {string} req.params.incidentId - ID of the incident to resolve
* @param {Object} req.body - Request body containing resolution data
* @param {string} [req.body.comment] - Optional comment about the resolution
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user.teamId - User's team ID
* @param {string} req.user._id - User's ID
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with updated incident data
* @throws {Error} 422 - Validation error if request body is invalid
* @throws {Error} 404 - Not found if incident doesn't exist
* @throws {Error} 403 - Forbidden if user doesn't have access to incident
* @throws {Error} 400 - Bad request if incident is already resolved
* @example
* PUT /incidents/507f1f77bcf86cd799439011/resolve
* {
* "comment": "Issue resolved by restarting the service"
* }
* // Requires JWT authentication
*/
resolveIncidentManually = this.asyncHandler(
async (req, res) => {
const resolvedIncident = await this.incidentService.resolveIncidentManually({
incidentId: req?.params?.incidentId,
userId: req?.user?._id,
teamId: req?.user?.teamId,
comment: req?.body?.comment,
});
return res.success({
msg: "Incident resolved successfully",
data: resolvedIncident,
});
},
SERVICE_NAME,
"resolveIncidentManually"
);
}
export default IncidentController;

View File

@@ -19,6 +19,7 @@ class MongoDB {
pageSpeedCheckModule,
recoveryModule,
settingsModule,
incidentModule,
}) {
this.logger = logger;
this.envSettings = envSettings;
@@ -34,6 +35,7 @@ class MongoDB {
this.settingsModule = settingsModule;
this.statusPageModule = statusPageModule;
this.networkCheckModule = networkCheckModule;
this.incidentModule = incidentModule;
}
get serviceName() {

View File

@@ -0,0 +1,76 @@
import mongoose from "mongoose";
const IncidentSchema = mongoose.Schema(
{
monitorId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Monitor",
required: true,
immutable: true,
index: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
required: true,
immutable: true,
index: true,
},
startTime: {
type: Date,
required: true,
immutable: true,
},
endTime: {
type: Date,
default: null,
},
status: {
type: Boolean,
default: true,
index: true,
},
message: {
type: String,
default: null,
},
statusCode: {
type: Number,
index: true,
default: null,
},
resolutionType: {
type: String,
enum: ["automatic", "manual"],
default: null,
},
resolvedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
default: null,
},
comment: {
type: String,
default: null,
},
checks: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Check",
},
],
},
{ timestamps: true }
);
IncidentSchema.index({ monitorId: 1, status: 1 });
IncidentSchema.index({ teamId: 1, status: 1 });
IncidentSchema.index({ teamId: 1, startTime: -1 });
IncidentSchema.index({ status: 1, startTime: -1 });
IncidentSchema.index({ resolutionType: 1, status: 1 });
IncidentSchema.index({ resolvedBy: 1, status: 1 });
IncidentSchema.index({ createdAt: -1 });
const Incident = mongoose.model("Incident", IncidentSchema);
export default Incident;

View File

@@ -0,0 +1,305 @@
import { ObjectId } from "mongodb";
const SERVICE_NAME = "incidentModule";
const dateRangeLookup = {
recent: new Date(new Date().setDate(new Date().getDate() - 2)),
hour: new Date(new Date().setHours(new Date().getHours() - 1)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: undefined,
};
class IncidentModule {
constructor({ logger, Incident, Monitor, User }) {
this.logger = logger;
this.Incident = Incident;
this.Monitor = Monitor;
this.User = User;
}
createIncident = async (incidentData) => {
try {
const incident = await this.Incident.create(incidentData);
return incident;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "createIncident";
throw error;
}
};
createIncidents = async (incidents) => {
try {
await this.Incident.insertMany(incidents, { ordered: false });
} catch (error) {
error.service = SERVICE_NAME;
error.method = "createIncidents";
throw error;
}
};
getActiveIncidentByMonitor = async (monitorId) => {
try {
return await this.Incident.findOne({ monitorId: new ObjectId(monitorId), status: true });
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getActiveIncidentByMonitor";
throw error;
}
};
getActiveIncidentsByMonitors = async (monitorIds) => {
try {
if (!monitorIds || monitorIds.length === 0) {
return new Map();
}
const objectIds = monitorIds.map((id) => new ObjectId(id));
const incidents = await this.Incident.find({
monitorId: { $in: objectIds },
status: true,
});
const map = new Map();
incidents.forEach((incident) => {
const monitorIdStr = incident.monitorId.toString();
map.set(monitorIdStr, incident);
});
return map;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getActiveIncidentsByMonitors";
throw error;
}
};
getLastManuallyResolvedIncident = async (monitorId) => {
try {
return await this.Incident.findOne({
monitorId: new ObjectId(monitorId),
status: false,
resolutionType: "manual",
})
.sort({ endTime: -1 })
.limit(1);
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getLastManuallyResolvedIncident";
throw error;
}
};
getIncidentById = async (incidentId) => {
try {
return await this.Incident.findById(incidentId).populate("monitorId", "name type url").populate("resolvedBy", "name email");
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getIncidentById";
throw error;
}
};
resolveIncident = async (incidentId, { resolutionType = "automatic", resolvedBy = null, comment = null, endTime = new Date() } = {}) => {
try {
const update = {
status: false,
endTime,
resolutionType,
resolvedBy,
...(comment !== null && { comment }),
};
const incident = await this.Incident.findOneAndUpdate({ _id: new ObjectId(incidentId) }, { $set: update }, { new: true });
if (!incident) {
throw new Error("Incident not found");
}
return incident;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "resolveIncident";
throw error;
}
};
addCheckToIncident = async (incidentId, checkId) => {
try {
const incident = await this.Incident.findOneAndUpdate(
{ _id: new ObjectId(incidentId) },
{ $addToSet: { checks: new ObjectId(checkId) } },
{ new: true }
);
if (!incident) {
throw new Error("Incident not found");
}
return incident;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "addCheckToIncident";
throw error;
}
};
addChecksToIncidentsBatch = async (operations) => {
try {
if (!operations || operations.length === 0) {
return;
}
const bulkOps = operations.map((op) => ({
updateOne: {
filter: { _id: new ObjectId(op.incidentId) },
update: { $addToSet: { checks: new ObjectId(op.checkId) } },
},
}));
await this.Incident.bulkWrite(bulkOps, { ordered: false });
} catch (error) {
error.service = SERVICE_NAME;
error.method = "addChecksToIncidentsBatch";
throw error;
}
};
getIncidentsByTeam = async ({ teamId, sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType }) => {
try {
page = Number.isFinite(parseInt(page)) ? parseInt(page) : 0;
rowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20;
let statusBoolean = undefined;
if (status !== undefined && status !== null) {
if (typeof status === "string") {
statusBoolean = status === "true";
} else if (typeof status === "boolean") {
statusBoolean = status;
}
}
const matchStage = {
teamId: new ObjectId(teamId),
...(statusBoolean !== undefined && { status: statusBoolean }),
...(monitorId && { monitorId: new ObjectId(monitorId) }),
...(resolutionType && { resolutionType }),
...(dateRangeLookup[dateRange] && {
startTime: {
$gte: dateRangeLookup[dateRange],
},
}),
};
sortOrder = sortOrder === "asc" ? 1 : -1;
const skip = page * rowsPerPage;
const incidents = await this.Incident.aggregate([
{ $match: matchStage },
{ $sort: { startTime: sortOrder } },
{
$facet: {
summary: [{ $count: "incidentsCount" }],
incidents: [{ $skip: skip }, { $limit: rowsPerPage }],
},
},
{
$project: {
incidentsCount: {
$ifNull: [{ $arrayElemAt: ["$summary.incidentsCount", 0] }, 0],
},
incidents: {
$ifNull: ["$incidents", []],
},
},
},
]);
return incidents[0];
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getIncidentsByTeam";
throw error;
}
};
getIncidentSummary = async ({ teamId, dateRange }) => {
try {
const matchStage = {
teamId: new ObjectId(teamId),
...(dateRangeLookup[dateRange] && {
startTime: {
$gte: dateRangeLookup[dateRange],
},
}),
};
const summary = await this.Incident.aggregate([
{ $match: matchStage },
{
$group: {
_id: "$status",
count: { $sum: 1 },
manualResolutions: {
$sum: {
$cond: [{ $eq: ["$resolutionType", "manual"] }, 1, 0],
},
},
},
},
]);
const result = {
total: 0,
active: 0,
resolved: 0,
manual: 0,
};
summary.forEach((item) => {
result.total += item.count;
if (item._id === true) {
result.active = item.count;
}
if (item._id === false) {
result.resolved = item.count;
}
result.manual += item.manualResolutions;
});
return result;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getIncidentSummary";
throw error;
}
};
deleteIncidentsByMonitor = async (monitorId) => {
try {
const result = await this.Incident.deleteMany({ monitorId: new ObjectId(monitorId) });
return result.deletedCount;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteIncidentsByMonitor";
throw error;
}
};
deleteIncidentsByTeamId = async (teamId) => {
try {
const teamMonitors = await this.Monitor.find({ teamId }, { _id: 1 });
const monitorIds = teamMonitors.map((monitor) => monitor._id);
const deleteResult = await this.Incident.deleteMany({ monitorId: { $in: monitorIds } });
return deleteResult.deletedCount;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteIncidentsByTeamId";
throw error;
}
};
}
export default IncidentModule;

View File

@@ -0,0 +1,26 @@
import { Router } from "express";
import { isAllowed } from "../../middleware/v1/isAllowed.js";
class IncidentRoutes {
constructor(incidentController) {
this.router = Router();
this.incidentController = incidentController;
this.initRoutes();
}
initRoutes() {
// Team routes
this.router.get("/team", this.incidentController.getIncidentsByTeam);
this.router.get("/team/summary", this.incidentController.getIncidentSummary);
// Individual incident routes
this.router.get("/:incidentId", this.incidentController.getIncidentById);
this.router.put("/:incidentId/resolve", isAllowed(["admin", "superadmin"]), this.incidentController.resolveIncidentManually);
}
getRouter() {
return this.router;
}
}
export default IncidentRoutes;

View File

@@ -0,0 +1,83 @@
import { Typography, Select } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import type { SelectProps } from "@mui/material/Select";
import { useTheme } from "@mui/material/styles";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
export const SelectInput: React.FC<SelectProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Select
{...props}
sx={{
height: "34px",
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
}}
/>
);
};
type ItemTypes = string | number;
interface SelectItem {
_id: ItemTypes;
name: string;
}
export type CustomSelectProps = SelectProps & {
items: SelectItem[];
placeholder?: string;
isHidden?: boolean;
hasError?: boolean;
};
export const SelectFromItems: React.FC<CustomSelectProps> = (
{ items, placeholder, isHidden = false, hasError = false, ...props }
) => {
return (
<SelectInput
error={hasError}
IconComponent={KeyboardArrowDownIcon}
displayEmpty
MenuProps={{ disableScrollLock: true }}
renderValue={(selected) => {
if (!selected) {
return (
<Typography
noWrap
color="text.secondary"
>
{placeholder ?? ""}
</Typography>
);
}
const selectedItem = items.find((item) => item._id === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
noWrap
title={displayName}
>
{displayName}
</Typography>
);
}}
{...props}
>
{items.map((item) => (
<MenuItem
key={item._id}
value={item._id}
>
{item.name}
</MenuItem>
))}
</SelectInput>
);
}
SelectInput.displayName = "SelectInput";

View File

@@ -0,0 +1,414 @@
const SERVICE_NAME = "incidentService";
class IncidentService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, errorService, stringService }) {
this.db = db;
this.logger = logger;
this.errorService = errorService;
this.stringService = stringService;
}
get serviceName() {
return IncidentService.SERVICE_NAME;
}
createIncident = async (monitor, check) => {
try {
if (!monitor || !monitor._id) {
throw this.errorService.createBadRequestError("Monitor is required");
}
if (!check || !check._id) {
throw this.errorService.createBadRequestError("Check is required");
}
const activeIncident = await this.db.incidentModule.getActiveIncidentByMonitor(monitor._id);
if (activeIncident) {
await this.db.incidentModule.addCheckToIncident(activeIncident._id, check._id);
this.logger.info({
service: this.SERVICE_NAME,
method: "createIncident",
message: `Check added to existing active incident for monitor ${monitor.name}`,
incidentId: activeIncident._id,
monitorId: monitor._id,
});
return activeIncident;
}
const incidentData = {
monitorId: monitor._id,
teamId: monitor.teamId,
type: monitor.type,
startTime: new Date(),
status: true,
message: check.message || null,
statusCode: check.statusCode || null,
checks: [check._id],
};
const incident = await this.db.incidentModule.createIncident(incidentData);
this.logger.info({
service: this.SERVICE_NAME,
method: "createIncident",
message: `New incident created for monitor ${monitor.name}`,
incidentId: incident._id,
monitorId: monitor._id,
});
return incident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "createIncident",
message: error.message,
monitorId: monitor?._id,
error: error.stack,
});
throw error;
}
};
resolveIncident = async (monitor, check) => {
try {
if (!monitor || !monitor._id) {
throw this.errorService.createBadRequestError("Monitor is required");
}
const activeIncident = await this.db.incidentModule.getActiveIncidentByMonitor(monitor._id);
if (!activeIncident) {
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: `No active incident found for monitor ${monitor.name}`,
monitorId: monitor._id,
});
return null;
}
await this.db.incidentModule.addCheckToIncident(activeIncident._id, check._id);
const resolvedIncident = await this.db.incidentModule.resolveIncident(activeIncident._id, {
resolutionType: "automatic",
resolvedBy: null,
endTime: new Date(),
});
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: `Incident automatically resolved for monitor ${monitor.name}`,
incidentId: resolvedIncident._id,
monitorId: monitor._id,
});
return resolvedIncident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: error.message,
monitorId: monitor?._id,
error: error.stack,
});
throw error;
}
};
resolveIncidentManually = async ({ incidentId, userId, teamId, comment }) => {
try {
if (!incidentId) {
throw this.errorService.createBadRequestError("No incident ID in request");
}
if (!userId) {
throw this.errorService.createBadRequestError("No user ID in request");
}
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const incident = await this.db.incidentModule.getIncidentById(incidentId);
if (!incident) {
throw this.errorService.createNotFoundError("Incident not found");
}
if (!incident.teamId.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
if (incident.status === false) {
throw this.errorService.createBadRequestError("Incident is already resolved");
}
const resolvedIncident = await this.db.incidentModule.resolveIncident(incidentId, {
resolutionType: "manual",
resolvedBy: userId,
comment: comment || null,
endTime: new Date(),
});
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncidentManually",
message: `Incident manually resolved by user`,
incidentId: resolvedIncident._id,
userId,
});
return resolvedIncident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "resolveIncidentManually",
message: error.message,
incidentId,
error: error.stack,
});
throw error;
}
};
getIncidentsByTeam = async ({ teamId, query }) => {
try {
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const { sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType } = query || {};
const result = await this.db.incidentModule.getIncidentsByTeam({
teamId,
sortOrder,
dateRange,
page,
rowsPerPage,
status,
monitorId,
resolutionType,
});
return result;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
teamId,
error: error.stack,
});
throw error;
}
};
getIncidentSummary = async ({ teamId, query }) => {
try {
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const { dateRange } = query || {};
const summary = await this.db.incidentModule.getIncidentSummary({
teamId,
dateRange,
});
return summary;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
teamId,
error: error.stack,
});
throw error;
}
};
getIncidentById = async ({ incidentId, teamId }) => {
try {
if (!incidentId) {
throw this.errorService.createBadRequestError("No incident ID in request");
}
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const incident = await this.db.incidentModule.getIncidentById(incidentId);
if (!incident) {
throw this.errorService.createNotFoundError("Incident not found");
}
if (!incident.teamId.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
return incident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentById",
message: error.message,
incidentId,
error: error.stack,
});
throw error;
}
};
processIncidentsFromBuffer = async (incidentBufferItems) => {
try {
if (!incidentBufferItems || incidentBufferItems.length === 0) {
return;
}
const createItems = [];
const resolveItems = [];
for (const item of incidentBufferItems) {
if (item.action === "resolve") {
resolveItems.push(item);
} else {
createItems.push(item);
}
}
for (const item of resolveItems) {
try {
await this.resolveIncident(item.monitor, item.check);
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "processIncidentsFromBuffer",
message: `Failed to resolve incident from buffer: ${error.message}`,
monitorId: item.monitor?._id,
error: error.stack,
});
}
}
if (createItems.length === 0) {
return;
}
const groupedByMonitor = {};
for (const item of createItems) {
if (!item.monitor || !item.monitor._id || !item.check || !item.check._id) {
this.logger.warn({
service: this.SERVICE_NAME,
method: "processIncidentsFromBuffer",
message: "Skipping item with missing monitor or check data",
item,
});
continue;
}
const monitorId = item.monitor._id.toString();
if (!groupedByMonitor[monitorId]) {
groupedByMonitor[monitorId] = [];
}
groupedByMonitor[monitorId].push(item);
}
const monitorIds = Object.keys(groupedByMonitor);
if (monitorIds.length === 0) {
return;
}
const activeIncidents = await this.db.incidentModule.getActiveIncidentsByMonitors(monitorIds);
const incidentsCreatedInFlush = {};
const checksToAddToIncidents = [];
const newIncidentsToCreate = [];
for (const [monitorId, items] of Object.entries(groupedByMonitor)) {
const existingIncident = activeIncidents.get(monitorId) || incidentsCreatedInFlush[monitorId];
if (existingIncident) {
const incidentId = existingIncident._id ? existingIncident._id.toString() : existingIncident;
for (const item of items) {
checksToAddToIncidents.push({
incidentId,
checkId: item.check._id.toString(),
});
}
} else {
const firstItem = items[0];
const incidentData = {
monitorId: firstItem.monitor._id,
teamId: firstItem.monitor.teamId,
type: firstItem.monitor.type,
startTime: new Date(),
status: true,
message: firstItem.check.message || null,
statusCode: firstItem.check.statusCode || null,
checks: [firstItem.check._id],
};
newIncidentsToCreate.push({
incidentData,
monitorId,
remainingChecks: items.slice(1), // Checks restantes para agregar después
});
}
}
if (newIncidentsToCreate.length > 0) {
const incidentDataArray = newIncidentsToCreate.map((item) => item.incidentData);
await this.db.incidentModule.createIncidents(incidentDataArray);
const createdIncidentsMap = await this.db.incidentModule.getActiveIncidentsByMonitors(newIncidentsToCreate.map((item) => item.monitorId));
for (const item of newIncidentsToCreate) {
const createdIncident = createdIncidentsMap.get(item.monitorId);
if (createdIncident && createdIncident._id) {
const incidentId = createdIncident._id.toString();
incidentsCreatedInFlush[item.monitorId] = incidentId;
for (const remainingItem of item.remainingChecks) {
checksToAddToIncidents.push({
incidentId,
checkId: remainingItem.check._id.toString(),
});
}
}
}
}
if (checksToAddToIncidents.length > 0) {
await this.db.incidentModule.addChecksToIncidentsBatch(checksToAddToIncidents);
}
this.logger.info({
service: this.SERVICE_NAME,
method: "processIncidentsFromBuffer",
message: `Processed ${incidentBufferItems.length} incident buffer items`,
created: newIncidentsToCreate.length,
checksAdded: checksToAddToIncidents.length,
resolved: resolveItems.length,
});
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "processIncidentsFromBuffer",
message: error.message,
error: error.stack,
});
throw error;
}
};
}
export default IncidentService;

View File

@@ -2,12 +2,14 @@ const SERVICE_NAME = "BufferService";
class BufferService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, envSettings }) {
constructor({ db, logger, envSettings, incidentService }) {
this.BUFFER_TIMEOUT = envSettings.nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute
this.db = db;
this.logger = logger;
this.incidentService = incidentService;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.incidentBuffer = [];
this.scheduleNextFlush();
this.logger.info({
message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`,
@@ -33,6 +35,66 @@ class BufferService {
}
}
addIncidentToBuffer({ monitor, check, action = "create" }) {
try {
if (!monitor || !check) {
this.logger.warn({
message: "Skipping incident buffer item: missing monitor or check",
service: this.SERVICE_NAME,
method: "addIncidentToBuffer",
});
return;
}
this.incidentBuffer.push({ monitor, check, action });
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "addIncidentToBuffer",
stack: error.stack,
});
}
}
removeCheckFromBuffer(checkToRemove) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove._id && check._id) {
return check._id.toString() === checkToRemove._id.toString();
}
return (
check.monitorId?.toString() === checkToRemove.monitorId?.toString() &&
check.teamId?.toString() === checkToRemove.teamId?.toString() &&
check.type === checkToRemove.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error.stack,
});
return false;
}
}
scheduleNextFlush() {
this.bufferTimer = setTimeout(async () => {
try {
@@ -51,10 +113,10 @@ class BufferService {
}, this.BUFFER_TIMEOUT);
}
async flushBuffer() {
let items = this.buffer.length;
try {
await this.db.checkModule.createChecks(this.buffer);
if (this.buffer.length > 0) {
await this.db.checkModule.createChecks(this.buffer);
}
} catch (error) {
this.logger.error({
message: error.message,
@@ -63,13 +125,41 @@ class BufferService {
stack: error.stack,
});
}
try {
if (this.incidentBuffer.length > 0 && this.incidentService) {
await this.flushIncidentBuffer();
}
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "flushBuffer",
stack: error.stack,
});
}
this.buffer = [];
// this.logger.debug({
// message: `Flushed ${items} items`,
// service: this.SERVICE_NAME,
// method: "flushBuffer",
// });
items = 0;
this.incidentBuffer = [];
}
async flushIncidentBuffer() {
if (!this.incidentService || this.incidentBuffer.length === 0) {
return;
}
try {
const itemsToProcess = [...this.incidentBuffer];
await this.incidentService.processIncidentsFromBuffer(itemsToProcess);
} catch (error) {
this.logger.error({
message: `Error flushing incident buffer: ${error.message}`,
service: this.SERVICE_NAME,
method: "flushIncidentBuffer",
stack: error.stack,
});
throw error;
}
}
}

View File

@@ -1,4 +1,5 @@
import MonitorStats from "../../../db/v1/models/MonitorStats.js";
import Check from "../../../db/v1/models/Check.js";
const SERVICE_NAME = "StatusService";
class StatusService {
@@ -7,11 +8,13 @@ class StatusService {
/**
* @param {{
* buffer: import("./bufferService.js").BufferService
* incidentService: import("../business/incidentService.js").IncidentService
* }}
*/ constructor({ db, logger, buffer }) {
*/ constructor({ db, logger, buffer, incidentService }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
this.incidentService = incidentService;
}
get serviceName() {
@@ -95,6 +98,63 @@ class StatusService {
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 Check(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.
*
@@ -170,6 +230,61 @@ class StatusService {
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;