From 4e31499f78df18e459ee193b665dbe6040d0dfde Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Mon, 1 Jun 2026 00:03:03 +0530 Subject: [PATCH] feat(backend): add customUpCodes support for HTTP monitors Adds a customUpCodes field to the monitor schema to allow users to specify which HTTP status codes should be treated as UP instead of DOWN. Includes core logic in HttpProvider and unit tests. Refs #3657 --- server/src/db/models/Monitor.ts | 4 ++ .../monitors/MongoMonitorsRepository.ts | 2 + .../src/service/business/geoChecksService.ts | 2 +- .../infrastructure/globalPingService.ts | 11 ++-- .../infrastructure/network/HttpProvider.ts | 6 +- server/src/types/monitor.ts | 1 + server/src/validation/monitorValidation.ts | 4 ++ .../providers/network/httpProvider.test.ts | 64 +++++++++++++++++++ 8 files changed, 86 insertions(+), 8 deletions(-) diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index e6bffddd8..5b982c004 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -291,6 +291,10 @@ const MonitorSchema = new Schema( ref: "Tag", }, ], + customUpCodes: { + type: [Number], + default: [], + }, secret: { type: String, }, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 3d1f9c3a5..28979890f 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -456,6 +456,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, @@ -520,6 +521,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, diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 95b945f2a..5f557506f 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -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) diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 41f96b286..6050cc5eb 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -62,7 +62,7 @@ interface GlobalPingProbeResult { export interface IGlobalPingService { readonly serviceName: string; createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise; - pollForResults(measurementId: string, timeoutMs?: number): Promise; + pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: number[]): Promise; } export class GlobalPingService implements IGlobalPingService { @@ -119,7 +119,7 @@ export class GlobalPingService implements IGlobalPingService { } } - async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise { + async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS, customUpCodes: number[] = []): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -132,7 +132,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 +174,7 @@ export class GlobalPingService implements IGlobalPingService { return []; } - private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] { + private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: number[] = []): GeoCheckResult[] { const successfulResults: GeoCheckResult[] = []; for (const probeResult of probeResults) { @@ -205,7 +205,8 @@ export class GlobalPingService implements IGlobalPingService { successfulResults.push({ location, - status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300, + status: + customUpCodes.includes(probeResult.result.statusCode) || (probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300), statusCode: probeResult.result.statusCode, timings, }); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index bbc0934f8..2ae177819 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -31,11 +31,13 @@ export class HttpProvider implements IStatusProvider { private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { + const isCustomUp = error instanceof HTTPError && (monitor.customUpCodes ?? []).includes(error.response?.statusCode); + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: false, + status: isCustomUp, code: error.response?.statusCode ?? NETWORK_ERROR, message: error.message, responseTime: error.timings?.phases?.total ?? 0, @@ -106,7 +108,7 @@ export class HttpProvider implements IStatusProvider { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response.ok && matchResult.ok, + status: ((monitor.customUpCodes ?? []).includes(response.statusCode) || response.ok) && matchResult.ok, code: response.statusCode, message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, responseTime: response.timings.phases.total ?? 0, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 0708297f3..415b7dff0 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -60,6 +60,7 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; tags: string[]; + customUpCodes: number[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index d5164b8ef..53a8ec6ca 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -81,6 +81,7 @@ export const createMonitorBodyValidation = z tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(z.number()).default([]), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -110,6 +111,7 @@ export const editMonitorBodyValidation = z interval: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(z.number()).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -180,6 +182,7 @@ const importedMonitorSchema = z uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), tags: z.array(z.string()).default([]), + customUpCodes: z.array(z.number()).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), @@ -241,6 +244,7 @@ export const monitorResponseSchema = z matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), tags: z.array(z.string()), + customUpCodes: z.array(z.number()).optional(), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 1b814d7b2..afd3710d9 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -345,4 +345,68 @@ 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"); + }); + }); });