import { DurableObject } from "cloudflare:workers"; import { alerts } from "./alerts"; export const REGIONS = ["wnam", "enam", "weur", "eeur", "apac"] as const; export type Region = (typeof REGIONS)[number]; export const REGION_NAMES: Record = { wnam: "Western North America", enam: "Eastern North America", weur: "Western Europe", eeur: "Eastern Europe", apac: "Asia Pacific", }; /** One health check result. status 0 means no HTTP response (timeout / network error). */ export interface Check { region: Region; endpoint: string; ts: number; status: number; duration_ms: number; body: string | null; error: string | null; } export interface RegionSnapshot { region: Region; nextAlarm: number | null; checks: Check[]; } const RETENTION_MS = 14 * 24 * 60 * 60 * 1000; const MAX_BODY_CHARS = 64 * 1024; type CheckRow = Omit; export class MonitorDO extends DurableObject { private sql: SqlStorage; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.sql = ctx.storage.sql; this.sql.exec(` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY, endpoint TEXT NOT NULL, ts INTEGER NOT NULL, status INTEGER NOT NULL, duration_ms INTEGER NOT NULL, body TEXT, error TEXT ); CREATE INDEX IF NOT EXISTS checks_endpoint_ts ON checks (endpoint, ts); CREATE INDEX IF NOT EXISTS checks_ts ON checks (ts); CREATE TRIGGER IF NOT EXISTS checks_retention AFTER INSERT ON checks BEGIN DELETE FROM checks WHERE ts < NEW.ts - ${RETENTION_MS}; END; -- Alert state moved to AlertsDO. DROP TABLE IF EXISTS alert_state; `); } /** The region this instance monitors from; recorded by ensureAlarm(). */ private get region(): Region { return (this.ctx.storage.kv.get("region") ?? "wnam") as Region; } /** Records this instance's region and starts the check loop if it isn't running. */ async ensureAlarm(region: Region): Promise { this.ctx.storage.kv.put("region", region); if ((await this.ctx.storage.getAlarm()) === null) { await this.ctx.storage.setAlarm(Date.now()); } } async alarm(): Promise { // Schedule the next run first so a failure below can never stop the loop. await this.ctx.storage.setAlarm(Date.now() + Number(this.env.CHECK_INTERVAL_SECONDS) * 1000); await Promise.all(endpoints(this.env).map((url) => this.check(url))); } /** Latest check for each configured endpoint. Also bootstraps the alarm loop. */ async latest(region: Region): Promise { await this.ensureAlarm(region); const checks: Check[] = []; for (const url of endpoints(this.env)) { const row = this.sql .exec(`SELECT endpoint, ts, status, duration_ms, body, error FROM checks WHERE endpoint = ? ORDER BY ts DESC LIMIT 1`, url) .toArray()[0]; if (row) checks.push({ region, ...row }); } return { region, nextAlarm: await this.ctx.storage.getAlarm(), checks }; } /** Checks for one endpoint older than `before`, newest first. */ async history(endpoint: string, before: number, limit: number, failuresOnly: boolean): Promise { const region = this.region; return this.sql .exec( `SELECT endpoint, ts, status, duration_ms, body, error FROM checks WHERE endpoint = ? AND ts < ? ${failuresOnly ? "AND status != 200" : ""} ORDER BY ts DESC LIMIT ?`, endpoint, before, limit, ) .toArray() .map((row) => ({ region, ...row })); } private async check(url: string): Promise { const ts = Date.now(); let status = 0; let body: string | null = null; let error: string | null = null; try { const res = await fetch(url, { signal: AbortSignal.timeout(Number(this.env.TIMEOUT_MS)), headers: { accept: "application/json", "user-agent": "rtw-monitor" }, cache: "no-store", }); status = res.status; body = (await res.text()).slice(0, MAX_BODY_CHARS); } catch (e) { error = e instanceof Error && e.name === "TimeoutError" ? `Timed out after ${this.env.TIMEOUT_MS} ms` : String(e); } const check: Check = { region: this.region, endpoint: url, ts, status, duration_ms: Date.now() - ts, body, error }; this.sql.exec( `INSERT INTO checks (endpoint, ts, status, duration_ms, body, error) VALUES (?, ?, ?, ?, ?, ?)`, url, ts, status, check.duration_ms, body, error, ); // Alerting is global and deduped across regions; a coordinator outage must not stop checks. try { await alerts(this.env).report(check); } catch (e) { console.error("alert report failed", { endpoint: url, error: String(e) }); } } } export function endpoints(env: Env): string[] { return env.ENDPOINTS as unknown as string[]; }