Something went wrong. Try again.
Extremely basic alerting on health endpoints using Cloudflare durable objects
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586import type { AlertKind } from "./email";import type { Check, Region } from "./monitor";
const API = "https://api.pushover.net/1/messages.json";/** Pushover's limits: title 250 characters, message 1024. */const MAX_TITLE = 250;const MAX_MESSAGE = 1024;
/** Optional secrets; the channel is off until both are set. */type PushoverEnv = Env & { PUSHOVER_TOKEN?: string; PUSHOVER_USER?: string };
export function pushoverEnabled(env: Env): boolean { const e = env as PushoverEnv; return Boolean(e.PUSHOVER_TOKEN && e.PUSHOVER_USER);}
/** * Sends an alert as a Pushover notification, the second channel next to email. Returns false (and * logs) on failure rather than throwing. Call only when pushoverEnabled(). */export async function sendPushover( env: Env, kind: AlertKind, check: Check, failingSince: number | null, failingRegions: Region[] = [],): Promise<boolean> { const e = env as PushoverEnv; const host = check.endpoint.replace(/^https?:\/\//, ""); const result = check.status ? `HTTP ${check.status}` : (check.error ?? "no response"); const title = `${kind.toUpperCase()}: ${host} (${result}) from ${check.region}`; const lines: string[] = []; if (failingSince !== null) lines.push(`Failing since ${new Date(failingSince).toISOString()}`); if (failingRegions.length > 1) lines.push(`Failing from ${failingRegions.join(", ")}`); if (kind !== "recovered") lines.push(...failureLines(check)); const form = new URLSearchParams({ token: e.PUSHOVER_TOKEN!, user: e.PUSHOVER_USER!, title: title.slice(0, MAX_TITLE), message: (lines.join("\n") || result).slice(0, MAX_MESSAGE), // Recoveries arrive quietly; failures make a sound. priority: kind === "recovered" ? "-1" : "0", url: `${env.PUBLIC_URL}/endpoint?url=${encodeURIComponent(check.endpoint)}`, url_title: "History", timestamp: String(Math.floor(check.ts / 1000)), }); try { const res = await fetch(API, { method: "POST", body: form, signal: AbortSignal.timeout(Number(env.TIMEOUT_MS)) }); const reply = await res.text(); if (!res.ok || !reply.includes('"status":1')) throw new Error(`HTTP ${res.status}: ${reply.slice(0, 200)}`); console.log("alert pushover sent", { endpoint: check.endpoint, kind, title }); return true; } catch (err) { console.error("alert pushover failed", { endpoint: check.endpoint, kind, error: String(err) }); return false; }}
type HealthCheck = { status?: string; error?: string; detail?: string };
/** The failing entries of a /healthz `checks` object ({name: {status, error?, detail?}}), if it has one. */function failingChecks(body: string | null): [string, HealthCheck][] { if (!body) return []; try { const checks = (JSON.parse(body) as { checks?: Record<string, HealthCheck> }).checks; if (checks && typeof checks === "object") return Object.entries(checks).filter(([, c]) => c?.status !== "ok"); } catch { // Not JSON. } return [];}
/** What failed, for the message body: the failing /healthz checks, else the raw body or the fetch error. */export function failureLines(check: Check): string[] { if (check.error && !check.status) return [check.error]; const failing = failingChecks(check.body); if (failing.length) return failing.map(([name, c]) => `${name}: ${c?.error ?? c?.detail ?? c?.status ?? "failed"}`); return check.body ? [check.body] : [];}
/** * What failed, as stable keys for comparing failures over time: the failing /healthz check names, * else the HTTP status, else "no response". Details (which change as, say, a disk fills) are left out. */export function failureKeys(check: Check): string[] { const failing = failingChecks(check.body); if (failing.length) return failing.map(([name]) => name).sort(); return [check.status ? `HTTP ${check.status}` : "no response"];}