import { type AlertPolicy, type AlertState, type AlertTransition } from "./monitor_alert_state_types.ts";
/**
* One step of an alert's lifecycle. Pure: the caller stores the returned
* state and passes it back at the next evaluation.
*
* Firing needs the condition to hold forSeconds (Prometheus `for`);
* resolving needs it to stay false clearForSeconds, so a flapping check does
* not send resolved-firing-resolved storms. lastNotifiedAt records the last
* notification of any kind; it drives the repeat interval while firing.
*/
export function nextAlertState(previous: AlertState, condition: boolean, now: number, policy: AlertPolicy): AlertTransition {
if (!Number.isSafeInteger(now)) throw new RangeError("now must be a whole number of seconds");
const { forSeconds, clearForSeconds } = policy;
const renotify = policy.renotifySeconds ?? null;
if (forSeconds < 0) throw new RangeError(`forSeconds must not be negative, received ${forSeconds}`);
if (clearForSeconds < 0) throw new RangeError(`clearForSeconds must not be negative, received ${clearForSeconds}`);
if (renotify !== null && renotify < 1) throw new RangeError(`renotifySeconds must be null or at least 1, received ${renotify}`);
const phase = previous.phase;
if (phase !== "inactive" && phase !== "pending" && phase !== "firing" && phase !== "resolved") {
throw new RangeError(`unknown alert phase: ${String(phase)}`);
}
const since = previous.since ?? null;
const last = previous.lastNotifiedAt ?? null;
const clearSince = previous.clearSince ?? null;
if ((phase === "pending" || phase === "firing") && since === null) {
throw new RangeError(`since must be set in phase ${phase}`);
}
const times: Array<[string, number | null]> = [["since", since], ["lastNotifiedAt", last], ["clearSince", clearSince]];
for (const [name, t] of times) {
if (t !== null && now < t) throw new RangeError(`now ${now} is earlier than ${name} ${t}`);
}
const same: AlertState = { phase, since, lastNotifiedAt: last, clearSince };
if (phase === "inactive" || phase === "resolved") {
if (!condition) return { state: same, notify: null, changed: false };
if (forSeconds === 0) {
return { state: { phase: "firing", since: now, lastNotifiedAt: now, clearSince: null }, notify: "firing", changed: true };
}
return { state: { phase: "pending", since: now, lastNotifiedAt: last, clearSince: null }, notify: null, changed: true };
}
if (phase === "pending") {
if (!condition) return { state: { phase: "inactive", since: now, lastNotifiedAt: last, clearSince: null }, notify: null, changed: true };
if (now - (since as number) >= forSeconds) {
return { state: { phase: "firing", since: now, lastNotifiedAt: now, clearSince: null }, notify: "firing", changed: true };
}
return { state: same, notify: null, changed: false };
}
// firing
if (condition) {
if (renotify !== null && (last === null || now - last >= renotify)) {
return { state: { phase, since, lastNotifiedAt: now, clearSince: null }, notify: "repeat", changed: false };
}
return { state: { phase, since, lastNotifiedAt: last, clearSince: null }, notify: null, changed: false };
}
const clearing = clearSince ?? now;
if (now - clearing >= clearForSeconds) {
return { state: { phase: "resolved", since: now, lastNotifiedAt: now, clearSince: null }, notify: "resolved", changed: true };
}
return { state: { phase, since, lastNotifiedAt: last, clearSince: clearing }, notify: null, changed: false };
}