Merge pull request #3690 from harsh-aghara/feat/accept-status-codes-backend

Add customUpCodes support for HTTP monitors (Backend)
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-06-08 12:04:42 -07:00
commit ce4cb63035
13 files changed
+346 -60

No files matched your search

+4
View File
@@ -291,6 +291,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
ref: "Tag",
},
],
customUpCodes: {
type: [Number],
default: [],
},
secret: {
type: String,
},
@@ -548,6 +548,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
tags: tagIds,
customUpCodes: doc.customUpCodes ?? [],
secret: doc.secret ?? undefined,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
@@ -612,6 +613,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
tags: tagIds,
customUpCodes: doc.customUpCodes ?? [],
secret: doc.secret ?? undefined,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
@@ -89,7 +89,7 @@ export class GeoChecksService implements IGeoChecksService {
}
// Step 2: Poll for results
const results = await this.globalPingService.pollForResults(measurementId);
const results = await this.globalPingService.pollForResults(measurementId, undefined, monitor.customUpCodes ?? []);
if (results.length === 0) {
// No successful results (all locations timed out or failed)
@@ -1,8 +1,9 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/types/monitor.js";
import { MonitorType } from "@/types/index.js";
import type { ILogger } from "@/utils/logger.js";
import got from "got";
import { isStatusUp } from "@/service/infrastructure/network/utils.js";
const SERVICE_NAME = "GlobalPingService";
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
@@ -62,7 +63,7 @@ interface GlobalPingProbeResult {
export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise<GeoCheckResult[]>;
}
export class GlobalPingService implements IGlobalPingService {
@@ -119,7 +120,11 @@ export class GlobalPingService implements IGlobalPingService {
}
}
async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise<GeoCheckResult[]> {
async pollForResults(
measurementId: string,
timeoutMs: number = MAX_POLL_TIMEOUT_MS,
customUpCodes: HttpStatusCode[] = []
): Promise<GeoCheckResult[]> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
@@ -132,7 +137,7 @@ export class GlobalPingService implements IGlobalPingService {
const measurement = response.body;
if (measurement.status === "finished") {
const results = this.transformResults(measurement.results || []);
const results = this.transformResults(measurement.results || [], customUpCodes);
this.logger.debug({
message: `GlobalPing measurement completed: ${measurementId}`,
service: SERVICE_NAME,
@@ -174,7 +179,7 @@ export class GlobalPingService implements IGlobalPingService {
return [];
}
private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] {
private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: HttpStatusCode[] = []): GeoCheckResult[] {
const successfulResults: GeoCheckResult[] = [];
for (const probeResult of probeResults) {
@@ -205,7 +210,7 @@ export class GlobalPingService implements IGlobalPingService {
successfulResults.push({
location,
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
status: isStatusUp(probeResult.result.statusCode, customUpCodes),
statusCode: probeResult.result.statusCode,
timings,
});
@@ -6,7 +6,7 @@ import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
import { NETWORK_ERROR, isStatusUp } from "@/service/infrastructure/network/utils.js";
import CacheableLookup from "cacheable-lookup";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
@@ -40,21 +40,93 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
return type === "http";
}
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
if (error instanceof HTTPError || error instanceof RequestError) {
private buildResponse<T>(
monitor: Monitor,
opts: {
body: string;
contentType: string;
statusCode: number;
statusUp: boolean;
message: string;
responseTime: number;
timings?: MonitorStatusResponse<T>["timings"];
}
): MonitorStatusResponse<T> {
const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts;
const isJson = contentType.includes("application/json");
if (monitor.jsonPath && !isJson) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: error.response?.statusCode ?? NETWORK_ERROR,
message: error.message,
responseTime: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0,
timings: error.timings,
payload: null as T,
code: statusCode,
message: "Response is not JSON",
responseTime,
timings,
payload: body,
};
}
let payload: T | string;
if (isJson) {
try {
payload = JSON.parse(body) as T;
} catch {
payload = body;
}
} else {
payload = body;
}
const matchResult = this.advancedMatcher.validate<T | string>(payload, monitor);
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: statusUp && matchResult.ok,
code: statusCode,
message: matchResult.ok ? message : matchResult.message,
responseTime,
timings,
payload,
extracted: matchResult.extracted,
};
}
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
if (error instanceof HTTPError || error instanceof RequestError) {
const statusCode = error.response?.statusCode;
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0;
if (!statusUp) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: statusCode ?? NETWORK_ERROR,
message: error.message,
responseTime,
timings: error.timings,
payload: null as T,
};
}
return this.buildResponse<T>(monitor, {
body: (error.response?.body ?? "") as string,
contentType: error.response?.headers?.["content-type"] || "",
statusCode: statusCode ?? NETWORK_ERROR,
statusUp,
message: error.message,
responseTime,
timings: error.timings,
});
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
@@ -68,7 +140,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
}
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
const { url, secret, jsonPath, ignoreTlsErrors } = monitor;
const { url, secret, ignoreTlsErrors } = monitor;
if (!url) {
throw new Error("URL is required for HTTP monitor");
@@ -85,47 +157,17 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
try {
const response = await this.got<string>(url, options);
const contentType = response.headers["content-type"] || "";
const isJson = contentType.includes("application/json");
const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes);
if (jsonPath && !isJson) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: response.statusCode,
message: "Response is not JSON",
responseTime: response.timings.phases.firstByte ?? 0,
timings: response.timings,
payload: response.body as unknown as T,
};
}
let payload: T;
if (isJson) {
try {
payload = JSON.parse(response.body) as T;
} catch {
payload = response.body as unknown as T;
}
} else {
payload = response.body as unknown as T;
}
const matchResult = this.advancedMatcher.validate<T>(payload, monitor);
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response.ok && matchResult.ok,
code: response.statusCode,
message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message,
return this.buildResponse<T>(monitor, {
body: response.body,
contentType: response.headers["content-type"] || "",
statusCode: response.statusCode,
statusUp,
message: response.statusMessage ?? "OK",
responseTime: response.timings.phases.firstByte ?? 0,
timings: response.timings,
payload,
extracted: matchResult.extracted,
};
});
} catch (error: unknown) {
return this.handleHttpError(error, monitor);
}
@@ -1,3 +1,5 @@
import type { HttpStatusCode } from "@/types/monitor.js";
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
const start = process.hrtime.bigint();
try {
@@ -12,3 +14,8 @@ export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ res
export const NETWORK_ERROR = 5000;
export const PING_ERROR = 5001;
export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => {
if (statusCode === undefined) return false;
return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode);
};
@@ -186,7 +186,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data
if (monitor.type !== "hardware" || !monitorStatusResponse.payload) {
if (monitor.type !== "hardware" || !monitorStatusResponse.payload || typeof monitorStatusResponse.payload === "string") {
return breaches;
}
+6
View File
@@ -2,6 +2,11 @@ import type { CheckSnapshot } from "@/types/check.js";
export type { CheckSnapshot } from "@/types/check.js";
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
export type { GeoContinent } from "@/types/geoCheck.js";
import http from "node:http";
export const HttpStatusCodes = Object.keys(http.STATUS_CODES).map(Number);
export const HttpStatusCodeSet = new Set(HttpStatusCodes);
export type HttpStatusCode = number;
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
@@ -60,6 +65,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes: HttpStatusCode[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
+1 -1
View File
@@ -35,7 +35,7 @@ export interface MonitorStatusResponse<
code: number;
message: string;
responseTime?: number;
payload?: T | null;
payload?: T | string | null;
timings?: GotTimings;
first_byte_took?: number;
body_read_took?: number;
+7 -1
View File
@@ -1,7 +1,9 @@
import { z } from "zod";
import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js";
import { DnsRecordTypes, HttpStatusCodeSet, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -81,6 +83,7 @@ export const createMonitorBodyValidation = z
tempAlertThreshold: z.number().optional(),
notifications: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -110,6 +113,7 @@ export const editMonitorBodyValidation = z
interval: z.number().optional(),
notifications: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
useAdvancedMatching: z.boolean().optional(),
@@ -180,6 +184,7 @@ const importedMonitorSchema = z
uptimePercentage: z.number().optional(),
notifications: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
cpuAlertThreshold: z.number().default(100),
cpuAlertCounter: z.number().default(5),
@@ -241,6 +246,7 @@ export const monitorResponseSchema = z
matchMethod: z.enum(MonitorMatchMethods).optional(),
notifications: z.array(z.string()),
tags: z.array(z.string()),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
@@ -56,6 +56,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
useAdvancedMatching: false,
matchMethod: undefined,
expectedValue: undefined,
customUpCodes: [],
...overrides,
}) as Monitor;
@@ -253,14 +254,13 @@ describe("HttpProvider", () => {
expect(result.extracted).toBe("value");
});
it("sets status to false when response.ok is false even if matcher passes", async () => {
it("sets status to false when status code is non-2xx even if matcher passes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: true, message: "Success" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor());
// status = response.ok && matchResult.ok
expect(result.status).toBe(false);
});
});
@@ -345,4 +345,92 @@ describe("HttpProvider", () => {
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
});
});
// ── customUpCodes ────────────────────────────────────────────────────
describe("customUpCodes", () => {
it("returns status true when HTTPError status code is in customUpCodes", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401 };
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [401] }));
expect(result.status).toBe(true);
expect(result.code).toBe(401);
});
it("returns status false when HTTPError status code is not in customUpCodes", async () => {
const err = new HTTPError("Internal Server Error");
(err as any).response = { statusCode: 500 };
(err as any).timings = { phases: { total: 40 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [401, 403] }));
expect(result.status).toBe(false);
expect(result.code).toBe(500);
});
it("returns status false when customUpCodes is empty", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401 };
(err as any).timings = { phases: { total: 25 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [] }));
expect(result.status).toBe(false);
expect(result.code).toBe(401);
});
it("treats non-2xx success response as up when status code is in customUpCodes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301, statusMessage: "Moved Permanently" }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [301] }));
expect(result.status).toBe(true);
expect(result.code).toBe(301);
});
it("does not override matcher failure even when status code is in customUpCodes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: false, message: "Body mismatch" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ customUpCodes: [301] }));
expect(result.status).toBe(false);
expect(result.message).toBe("Body mismatch");
});
it("does not override matcher failure on HTTPError when status code is in customUpCodes", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = {
statusCode: 401,
body: '{"status":"down"}',
headers: { "content-type": "application/json" },
};
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const matcher = createMockMatcher({
ok: false,
message: "Body mismatch",
extracted: "down",
});
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ customUpCodes: [401], useAdvancedMatching: true }));
expect(result.status).toBe(false);
expect(result.message).toBe("Body mismatch");
expect(result.payload).toEqual({ status: "down" });
expect(result.extracted).toBe("down");
});
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import { timeRequest, NETWORK_ERROR, PING_ERROR, isStatusUp } from "../../../../src/service/infrastructure/network/utils.ts";
describe("network utils", () => {
describe("timeRequest", () => {
@@ -41,4 +41,40 @@ describe("network utils", () => {
expect(PING_ERROR).toBe(5001);
});
});
describe("isStatusUp", () => {
it("returns true for 2xx status codes", () => {
expect(isStatusUp(200)).toBe(true);
expect(isStatusUp(201)).toBe(true);
expect(isStatusUp(204)).toBe(true);
expect(isStatusUp(299)).toBe(true);
});
it("returns false for non-2xx status codes without customUpCodes", () => {
expect(isStatusUp(100)).toBe(false);
expect(isStatusUp(301)).toBe(false);
expect(isStatusUp(404)).toBe(false);
expect(isStatusUp(500)).toBe(false);
});
it("returns true when status code is in customUpCodes", () => {
expect(isStatusUp(301, [301])).toBe(true);
expect(isStatusUp(404, [200, 404])).toBe(true);
expect(isStatusUp(401, [401, 403])).toBe(true);
});
it("returns false when status code is not in customUpCodes", () => {
expect(isStatusUp(500, [401, 403])).toBe(false);
});
it("returns false when statusCode is undefined", () => {
expect(isStatusUp(undefined)).toBe(false);
expect(isStatusUp(undefined, [200])).toBe(false);
});
it("defaults customUpCodes to empty array", () => {
expect(isStatusUp(200)).toBe(true);
expect(isStatusUp(404)).toBe(false);
});
});
});
@@ -222,3 +222,93 @@ describe("monitorValidation — strategy gating", () => {
});
});
});
describe("monitorValidation — customUpCodes", () => {
const baseHttpBody = {
name: "HTTP check",
type: "http" as const,
url: "https://example.com",
};
describe("createMonitorBodyValidation", () => {
it("accepts valid HTTP status codes", () => {
const parsed = createMonitorBodyValidation.parse({
...baseHttpBody,
customUpCodes: [200, 301, 404, 503],
});
expect(parsed.customUpCodes).toEqual([200, 301, 404, 503]);
});
it("rejects invalid HTTP status codes", () => {
expect(() =>
createMonitorBodyValidation.parse({
...baseHttpBody,
customUpCodes: [5000],
})
).toThrow();
expect(() =>
createMonitorBodyValidation.parse({
...baseHttpBody,
customUpCodes: [-1],
})
).toThrow();
});
it("defaults to an empty array when not provided", () => {
const parsed = createMonitorBodyValidation.parse(baseHttpBody);
expect(parsed.customUpCodes).toEqual([]);
});
});
describe("editMonitorBodyValidation", () => {
it("accepts valid HTTP status codes on edits", () => {
const parsed = editMonitorBodyValidation.parse({
customUpCodes: [200, 201],
});
expect(parsed.customUpCodes).toEqual([200, 201]);
});
it("rejects invalid HTTP status codes on edits", () => {
expect(() =>
editMonitorBodyValidation.parse({
customUpCodes: [9999],
})
).toThrow();
});
});
describe("importMonitorsBodyValidation", () => {
it("retains valid HTTP status codes on imported HTTP monitors", () => {
const parsed = importMonitorsBodyValidation.parse({
monitors: [
{
...baseHttpBody,
customUpCodes: [404],
},
],
});
expect(parsed.monitors[0].customUpCodes).toEqual([404]);
});
it("rejects invalid HTTP status codes on imported HTTP monitors", () => {
expect(() =>
importMonitorsBodyValidation.parse({
monitors: [
{
...baseHttpBody,
customUpCodes: [5000],
},
],
})
).toThrow();
});
it("defaults to an empty array when not provided on import", () => {
const parsed = importMonitorsBodyValidation.parse({
monitors: [baseHttpBody],
});
expect(parsed.monitors[0].customUpCodes).toEqual([]);
});
});
});