Skip to content
4 changes: 4 additions & 0 deletions server/src/db/models/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
ref: "Tag",
},
],
customUpCodes: {
type: [Number],
default: [],
},
secret: {
type: String,
},
Expand Down
2 changes: 2 additions & 0 deletions server/src/repositories/monitors/MongoMonitorsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion server/src/service/business/geoChecksService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 11 additions & 6 deletions server/src/service/infrastructure/globalPingService.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
});
Expand Down
134 changes: 88 additions & 46 deletions server/src/service/infrastructure/network/HttpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -40,19 +40,91 @@ 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,
code: statusCode,
message: "Response is not JSON",
responseTime,
timings,
payload: body as unknown as T,
};
}

let payload: T;
if (isJson) {
try {
payload = JSON.parse(body) as T;
} catch {
payload = body as unknown as T;
}
} else {
payload = body as unknown as T;
}

const matchResult = this.advancedMatcher.validate<T>(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: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0,
responseTime,
timings: error.timings,
payload: null as T,
};
});
}

return {
Expand All @@ -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");
Expand All @@ -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");

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 statusUp = isStatusUp(response.statusCode, monitor.customUpCodes);

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);
}
Expand Down
7 changes: 7 additions & 0 deletions server/src/service/infrastructure/network/utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
};
6 changes: 6 additions & 0 deletions server/src/types/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -60,6 +65,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes: HttpStatusCode[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
Expand Down
8 changes: 7 additions & 1 deletion server/src/validation/monitorValidation.ts
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading