geochecks iniital commit

This commit is contained in:
Alex Holliday
2026-02-24 18:10:20 +00:00
parent 8e37ed7ba2
commit 1d4e9d6806
8 changed files with 438 additions and 33 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(2);
const [activeTab, setActiveTab] = useState<number>(1);
return (
<BasePage>
<Tabs
+20 -1
View File
@@ -17,6 +17,8 @@ import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/S
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
import GeoChecksService from "../service/business/geoChecksService.js";
import GlobalPingService from "../service/infrastructure/globalPingService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
@@ -83,6 +85,7 @@ export type InitializedServices = {
jobQueue: any;
userService: any;
checkService: any;
geoChecksService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
@@ -171,7 +174,21 @@ export const initializeServices = async ({
checksRepository,
});
const bufferService = new BufferService({ logger, checkService, settingsService });
const globalPingService = new GlobalPingService({ logger });
// Create geoChecksService with circular dependency workaround
const geoChecksService = new GeoChecksService({
logger,
geoChecksRepository,
globalPingService,
bufferService: null as any,
settingsService,
});
const bufferService = new BufferService({ logger, checkService, geoChecksService, settingsService });
// Set bufferService reference
(geoChecksService as any).bufferService = bufferService;
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
@@ -210,6 +227,7 @@ export const initializeServices = async ({
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService,
});
const superSimpleQueue = await SuperSimpleQueue.create({
@@ -274,6 +292,7 @@ export const initializeServices = async ({
maintenanceWindowService,
monitorService,
incidentService,
geoChecksService,
logger,
notificationsService,
statusPageService,
@@ -1,7 +1,7 @@
import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js";
export interface IGeoChecksRepository {
create(geoCheck: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">): Promise<GeoCheck>;
create(geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>;
@@ -72,23 +72,26 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
};
};
create = async (geoCheck: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">): Promise<GeoCheck> => {
create = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
try {
const doc = await GeoCheckModel.create({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
});
return this.toEntity(doc);
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
} catch (error: any) {
this.logger.error({
message: `Error creating geo check: ${error.message}`,
message: `Failed to create geo checks: ${error.message}`,
service: SERVICE_NAME,
method: "create",
stack: error.stack,
});
throw error;
}
@@ -0,0 +1,153 @@
import type { Monitor, GeoCheck } from "@/types/index.js";
import { Types } from "mongoose";
import type { IGeoChecksRepository } from "@/repositories/index.js";
import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js";
import type { IBufferService } from "@/service/infrastructure/bufferService.js";
const SERVICE_NAME = "GeoChecksService";
export interface IGeoChecksService {
readonly serviceName: string;
executeGeoCheck(monitor: Monitor): Promise<void>;
}
class GeoChecksService implements IGeoChecksService {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
private geoChecksRepository: IGeoChecksRepository;
private globalPingService: IGlobalPingService;
private bufferService: IBufferService;
private TTL_DAYS: number;
constructor({
logger,
geoChecksRepository,
globalPingService,
bufferService,
settingsService,
}: {
logger: any;
geoChecksRepository: IGeoChecksRepository;
globalPingService: IGlobalPingService;
bufferService: IBufferService;
settingsService: any;
}) {
this.logger = logger;
this.geoChecksRepository = geoChecksRepository;
this.globalPingService = globalPingService;
this.bufferService = bufferService;
this.TTL_DAYS = settingsService.getSettings().checksTTL || 90;
}
get serviceName() {
return GeoChecksService.SERVICE_NAME;
}
/**
* Execute a geo-distributed check for a monitor
* 1. Create measurement request with GlobalPing API
* 2. Poll for results (with 30s timeout)
* 3. Transform and save results to buffer
*/
async executeGeoCheck(monitor: Monitor): Promise<void> {
try {
if (!monitor.url) {
this.logger.warn({
message: "Monitor missing URL for geo check",
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id },
});
return;
}
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
this.logger.warn({
message: "Monitor missing geo check locations",
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id },
});
return;
}
// Step 1: Create measurement request
const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
if (!measurementId) {
// GlobalPing API is down, skip this check
this.logger.debug({
message: "Skipping geo check due to API unavailability",
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id },
});
return;
}
// Step 2: Poll for results
const results = await this.globalPingService.pollForResults(measurementId);
if (results.length === 0) {
// No successful results (all locations timed out or failed)
this.logger.debug({
message: "No successful geo check results",
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id, measurementId },
});
return;
}
// Step 3: Build GeoCheck document
const geoCheck = this.buildGeoCheck(monitor, results);
// Step 4: Add to buffer for batched insertion
this.bufferService.addGeoCheckToBuffer(geoCheck);
this.logger.debug({
message: `Geo check completed for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id, resultsCount: results.length },
});
} catch (error: any) {
this.logger.error({
message: "Error executing geo check",
service: SERVICE_NAME,
method: "executeGeoCheck",
details: { monitorId: monitor.id, error: error.message },
stack: error.stack,
});
}
}
private buildGeoCheck(monitor: Monitor, results: any[]): GeoCheck {
const now = new Date();
const expiryDate = new Date(now.getTime() + this.TTL_DAYS * 24 * 60 * 60 * 1000);
return {
id: new Types.ObjectId().toString(),
metadata: {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
},
results,
expiry: expiryDate.toISOString(),
__v: 0,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
}
/**
* Create geo checks (called by buffer service)
*/
createGeoChecks = async (geoChecks: GeoCheck[]) => {
return this.geoChecksRepository.create(geoChecks);
};
}
export default GeoChecksService;
@@ -4,6 +4,7 @@ import { AppError } from "@/utils/AppError.js";
import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js";
import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js";
import IncidentService from "@/service/business/incidentService.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import {
IMaintenanceWindowsRepository,
IMonitorsRepository,
@@ -43,6 +44,7 @@ class SuperSimpleQueueHelper {
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
private geoChecksService: IGeoChecksService;
constructor({
logger,
@@ -58,6 +60,7 @@ class SuperSimpleQueueHelper {
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService,
}: {
logger: any;
networkService: INetworkService;
@@ -72,6 +75,7 @@ class SuperSimpleQueueHelper {
monitorStatsRepository: IMonitorStatsRepository;
checksRepository: IChecksRepository;
incidentsRepository: IIncidentsRepository;
geoChecksService: IGeoChecksService;
}) {
this.logger = logger;
this.networkService = networkService;
@@ -86,6 +90,7 @@ class SuperSimpleQueueHelper {
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
this.geoChecksService = geoChecksService;
}
get serviceName() {
@@ -301,23 +306,8 @@ class SuperSimpleQueueHelper {
return;
}
// Step 3: Call GlobalPing API
// TODO: Implement GlobalPing API service
// const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
// const geoResults = await this.globalPingService.pollForResults(measurementId);
// Step 4: Create GeoCheck document
// TODO: Build and save GeoCheck
// const geoCheck = {
// metadata: {
// monitorId,
// teamId,
// type: monitor.type,
// },
// results: geoResults,
// expiry: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
// };
// await this.geoChecksRepository.create(geoCheck);
// Step 3: Execute geo check (handles API calls, polling, and saving)
await this.geoChecksService.executeGeoCheck(monitor);
this.logger.debug({
message: `Geo check job executed for monitor ${monitorId}`,
@@ -1,9 +1,11 @@
import type { Check } from "@/types/index.js";
import type { GeoCheck } from "@/types/index.js";
const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
@@ -15,15 +17,29 @@ class BufferService implements IBufferService {
private logger: any;
private SERVICE_NAME: string;
private buffer: any[];
private geoBuffer: any[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: any;
private geoChecksService: any;
constructor({ logger, checkService, settingsService }: { logger: any; checkService: any; settingsService: any }) {
constructor({
logger,
checkService,
geoChecksService,
settingsService,
}: {
logger: any;
checkService: any;
geoChecksService?: any;
settingsService: any;
}) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute
this.logger = logger;
this.checksService = checkService;
this.geoChecksService = geoChecksService;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.geoBuffer = [];
this.scheduleNextFlush();
this.logger.info({
message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`,
@@ -49,6 +65,19 @@ class BufferService implements IBufferService {
}
}
addGeoCheckToBuffer(geoCheck: GeoCheck) {
try {
this.geoBuffer.push(geoCheck);
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "addGeoCheckToBuffer",
stack: error.stack,
});
}
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
@@ -112,6 +141,10 @@ class BufferService implements IBufferService {
if (this.buffer.length > 0) {
await this.checksService.createChecks(this.buffer);
}
if (this.geoBuffer.length > 0 && this.geoChecksService) {
await this.geoChecksService.createGeoChecks(this.geoBuffer);
}
} catch (error: any) {
this.logger.error({
message: error.message,
@@ -122,6 +155,7 @@ class BufferService implements IBufferService {
}
this.buffer = [];
this.geoBuffer = [];
}
}
@@ -0,0 +1,206 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
import got from "got";
const SERVICE_NAME = "GlobalPingService";
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_TIMEOUT_MS = 30000;
interface GlobalPingMeasurementRequest {
type: "http";
target: string;
locations: Array<{ continent: GeoContinent }>;
limit: number;
}
interface GlobalPingMeasurementResponse {
id: string;
type: string;
status: "in-progress" | "finished" | "failed";
probesCount: number;
results?: GlobalPingProbeResult[];
}
interface GlobalPingProbeResult {
probe: {
continent: GeoContinent;
region: string;
country: string;
state: string | null;
city: string;
longitude: number;
latitude: number;
};
result: {
status: "finished" | "failed" | "timeout";
statusCode?: number;
statusCodeName?: string;
timings?: {
total: number;
dns: number;
tcp: number;
tls: number;
firstByte: number;
download: number;
};
rawOutput?: string;
};
}
export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
}
class GlobalPingService implements IGlobalPingService {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
constructor({ logger }: { logger: any }) {
this.logger = logger;
}
get serviceName() {
return GlobalPingService.SERVICE_NAME;
}
async createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null> {
try {
const requestBody: GlobalPingMeasurementRequest = {
type: "http",
target: url,
locations: locations.map((continent) => ({ continent })),
limit: locations.length,
};
const response = await got.post<GlobalPingMeasurementResponse>(`${GLOBAL_PING_API_BASE}/measurements`, {
json: requestBody,
responseType: "json",
timeout: { request: 10000 },
});
const measurementId = response.body.id;
this.logger.debug({
message: `Created GlobalPing measurement: ${measurementId}`,
service: SERVICE_NAME,
method: "createMeasurement",
details: { measurementId, url, locations },
});
return measurementId;
} catch (error: any) {
this.logger.error({
message: "GlobalPing API unavailable, skipping geo check",
service: SERVICE_NAME,
method: "createMeasurement",
details: error.message,
});
return null;
}
}
async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise<GeoCheckResult[]> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
try {
const response = await got.get<GlobalPingMeasurementResponse>(`${GLOBAL_PING_API_BASE}/measurements/${measurementId}`, {
responseType: "json",
timeout: { request: 5000 },
});
const measurement = response.body;
if (measurement.status === "finished") {
const results = this.transformResults(measurement.results || []);
this.logger.debug({
message: `GlobalPing measurement completed: ${measurementId}`,
service: SERVICE_NAME,
method: "pollForResults",
details: { measurementId, resultsCount: results.length },
});
return results;
}
if (measurement.status === "failed") {
this.logger.warn({
message: `GlobalPing measurement failed: ${measurementId}`,
service: SERVICE_NAME,
method: "pollForResults",
});
return [];
}
// Still in-progress, wait and poll again
await this.sleep(POLL_INTERVAL_MS);
} catch (error: any) {
this.logger.error({
message: "Error polling GlobalPing API",
service: SERVICE_NAME,
method: "pollForResults",
details: error.message,
});
return [];
}
}
// Timeout reached
this.logger.warn({
message: `GlobalPing measurement polling timeout: ${measurementId}`,
service: SERVICE_NAME,
method: "pollForResults",
details: { measurementId, timeoutMs },
});
return [];
}
private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] {
const successfulResults: GeoCheckResult[] = [];
for (const probeResult of probeResults) {
// Skip failed or timeout results
if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) {
continue;
}
const location: GeoCheckLocation = {
continent: probeResult.probe.continent,
region: probeResult.probe.region,
country: probeResult.probe.country,
state: probeResult.probe.state || "",
city: probeResult.probe.city,
longitude: probeResult.probe.longitude,
latitude: probeResult.probe.latitude,
};
const timings: GeoCheckTimings = {
total: probeResult.result.timings.total,
dns: probeResult.result.timings.dns,
tcp: probeResult.result.timings.tcp,
tls: probeResult.result.timings.tls,
firstByte: probeResult.result.timings.firstByte,
download: probeResult.result.timings.download,
};
const result: GeoCheckResult = {
location,
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
statusCode: probeResult.result.statusCode,
timings,
};
successfulResults.push(result);
}
return successfulResults;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
export default GlobalPingService;