| 1 | /** |
| 2 | * Noticing trouble before anyone reports it: when a part fails (or is |
| 3 | * slow) on DETECT_BAD of its last DETECT_WINDOW checks, a draft incident |
| 4 | * is made for staff in sudo, not shown on the status page until someone |
| 5 | * publishes it. A run of trouble ends after RECOVER_AFTER good checks in a |
| 6 | * row, however long it lasted; when a run that had crossed the line ends, |
| 7 | * open incidents on its part get a note. A detected draft nobody has |
| 8 | * picked up is dismissed on its own once its parts have stayed healthy for |
| 9 | * RECOVERED_FOR_MS, and one left unacknowledged is raised again after |
| 10 | * STALE_AFTER_MS, then every STALE_EVERY_MS. While a deploy is running |
| 11 | * (and for DEPLOY_GRACE_MS after) no new draft is made unless the trouble |
| 12 | * outlasts it. No Workers imports, so it is tested under Node. |
| 13 | * |
| 14 | * A slow answer only counts once the check, asked again at once, is slow |
| 15 | * too (probe.ts `probe`), so one cold start is not a slow check. |
| 16 | */ |
| 17 | import type { ComponentImpact, StatusComponentState } from "@g1t/contracts"; |
| 18 | |
| 19 | /** Failed or slow checks, of the last DETECT_WINDOW, before a draft is made. */ |
| 20 | export const DETECT_BAD = 4; |
| 21 | /** How many of a part's latest checks DETECT_BAD counts over (a minute apart). */ |
| 22 | export const DETECT_WINDOW = 5; |
| 23 | /** Good checks in a row that end a run of trouble, whether or not it was raised. */ |
| 24 | export const RECOVER_AFTER = 3; |
| 25 | /** How long a detected draft's parts stay healthy before it is dismissed on its own. */ |
| 26 | export const RECOVERED_FOR_MS = 10 * 60_000; |
| 27 | /** How long a detected draft waits, unacknowledged, before the alert goes out again. */ |
| 28 | export const STALE_AFTER_MS = 45 * 60_000; |
| 29 | /** After that, how often it goes out again while the draft still waits. */ |
| 30 | export const STALE_EVERY_MS = 6 * 60 * 60_000; |
| 31 | /** How long after a deploy finishes its restarts are still forgiven. */ |
| 32 | export const DEPLOY_GRACE_MS = 3 * 60_000; |
| 33 | /** A deploy that said it started and never said it finished stops counting after this. */ |
| 34 | export const DEPLOY_MAX_MS = 30 * 60_000; |
| 35 | |
| 36 | /** One check in a run's `recent`: good, slow, or not answering. */ |
| 37 | export type Mark = "." | "s" | "x"; |
| 38 | |
| 39 | /** |
| 40 | * One part's current run of trouble: from its first failed or slow check |
| 41 | * until RECOVER_AFTER good checks in a row. Good checks inside the run |
| 42 | * (flapping) do not end it; they are counted in `checks` and `recent`. |
| 43 | */ |
| 44 | export type Streak = { |
| 45 | component: string; |
| 46 | /** The worst seen in this run. */ |
| 47 | state: "degraded" | "down"; |
| 48 | /** Failed or slow checks in this run. */ |
| 49 | count: number; |
| 50 | /** Every check in this run, from its first failed or slow one. */ |
| 51 | checks: number; |
| 52 | /** The run's latest checks, oldest first, at most DETECT_WINDOW: "." good, "s" slow, "x" not answering. */ |
| 53 | recent: string; |
| 54 | /** The first failed or slow check of this run. */ |
| 55 | since: string; |
| 56 | /** Whether it has crossed the line, and been raised. */ |
| 57 | alerted: boolean; |
| 58 | }; |
| 59 | |
| 60 | /** An open incident (draft or public), with the parts it affects. */ |
| 61 | export type OpenRef = { id: string; components: string[] }; |
| 62 | |
| 63 | /** A run that crossed the line: `checks` failed or slow of the `of` checks since `since`. */ |
| 64 | export type Trouble = { key: string; state: "degraded" | "down"; since: string; checks: number; of: number }; |
| 65 | |
| 66 | export type Detection = { |
| 67 | /** Every part's run still going after this round; parts not here have none. */ |
| 68 | streaks: Streak[]; |
| 69 | /** Parts that crossed the line with no open incident on them: one draft for all. */ |
| 70 | draft: Trouble[]; |
| 71 | /** Parts that crossed the line while an incident on them was open. */ |
| 72 | failing: (Trouble & { incident: string })[]; |
| 73 | /** Parts whose raised run ended: answering again, in time. */ |
| 74 | recovered: (Trouble & { incident: string })[]; |
| 75 | /** Parts that crossed the line during a deploy: kept counting, not raised yet. */ |
| 76 | held: string[]; |
| 77 | }; |
| 78 | |
| 79 | export type DetectOptions = { |
| 80 | /** Failed or slow checks of the last `window` that cross the line. */ |
| 81 | bad?: number; |
| 82 | window?: number; |
| 83 | /** Good checks in a row that end a run. */ |
| 84 | recoverAfter?: number; |
| 85 | /** A deploy is running, or just finished: no new drafts, only counting. */ |
| 86 | quiet?: boolean; |
| 87 | }; |
| 88 | |
| 89 | const isBad = (m: string) => m === "s" || m === "x"; |
| 90 | |
| 91 | function mark(state: StatusComponentState): Mark { |
| 92 | return state === "down" ? "x" : state === "degraded" ? "s" : "."; |
| 93 | } |
| 94 | |
| 95 | /** How many of `recent` were failed or slow. */ |
| 96 | export function badIn(recent: string): number { |
| 97 | return [...recent].filter(isBad).length; |
| 98 | } |
| 99 | |
| 100 | /** Good checks at the end of `recent`. */ |
| 101 | function goodTail(recent: string): number { |
| 102 | let n = 0; |
| 103 | for (let i = recent.length - 1; i >= 0 && recent[i] === "."; i--) n++; |
| 104 | return n; |
| 105 | } |
| 106 | |
| 107 | /** Whether a run's latest check was failed or slow: what keeps a draft from counting as healthy. */ |
| 108 | export function troubledNow(streak: Streak): boolean { |
| 109 | return isBad(streak.recent.at(-1) ?? "x"); |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * A run as kept before `checks` and `recent` (migration 0003): every one |
| 114 | * of its checks failed, in a row. |
| 115 | */ |
| 116 | export function upgradeStreak(s: Omit<Streak, "checks" | "recent"> & { checks?: number | null; recent?: string | null }, window = DETECT_WINDOW): Streak { |
| 117 | const count = Math.max(1, Number(s.count) || 1); |
| 118 | const recent = s.recent || (s.state === "down" ? "x" : "s").repeat(Math.min(count, window)); |
| 119 | return { ...s, count, checks: Math.max(Number(s.checks) || 0, count), recent }; |
| 120 | } |
| 121 | |
| 122 | export function detect( |
| 123 | previous: Map<string, Streak>, |
| 124 | observations: { component: string; state: StatusComponentState }[], |
| 125 | open: OpenRef[], |
| 126 | maintenance: Set<string>, |
| 127 | at: Date, |
| 128 | { bad: need = DETECT_BAD, window = DETECT_WINDOW, recoverAfter = RECOVER_AFTER, quiet = false }: DetectOptions = {}, |
| 129 | ): Detection { |
| 130 | const out: Detection = { streaks: [], draft: [], failing: [], recovered: [], held: [] }; |
| 131 | const covering = (key: string) => open.filter((i) => i.components.includes(key)); |
| 132 | for (const { component: key, state } of observations) { |
| 133 | const prev = previous.get(key); |
| 134 | if (state === "unmonitored" || maintenance.has(key)) continue; |
| 135 | const m = mark(state); |
| 136 | if (!prev && !isBad(m)) continue; |
| 137 | const recent = `${prev?.recent ?? ""}${m}`.slice(-window); |
| 138 | const streak: Streak = prev |
| 139 | ? { |
| 140 | ...prev, |
| 141 | recent, |
| 142 | checks: prev.checks + 1, |
| 143 | count: prev.count + (isBad(m) ? 1 : 0), |
| 144 | state: prev.state === "down" || state === "down" ? "down" : "degraded", |
| 145 | } |
| 146 | : { component: key, state: state as "degraded" | "down", count: 1, checks: 1, recent, since: at.toISOString(), alerted: false }; |
| 147 | const trouble = (): Trouble => ({ key, state: streak.state, since: streak.since, checks: streak.count, of: streak.checks }); |
| 148 | if (!isBad(m)) { |
| 149 | // Enough good checks in a row end the run, however long it was. Leaving it out of `streaks` ends it. |
| 150 | if (goodTail(recent) >= Math.min(recoverAfter, window)) { |
| 151 | if (streak.alerted) { |
| 152 | // The good checks that ended it are not part of the trouble. |
| 153 | const t = { ...trouble(), of: streak.checks - goodTail(recent) }; |
| 154 | for (const i of covering(key)) out.recovered.push({ incident: i.id, ...t }); |
| 155 | } |
| 156 | continue; |
| 157 | } |
| 158 | } else if (!streak.alerted && badIn(recent) >= need) { |
| 159 | const t = trouble(); |
| 160 | const incidents = covering(key); |
| 161 | if (incidents.length) { |
| 162 | streak.alerted = true; |
| 163 | for (const i of incidents) out.failing.push({ incident: i.id, ...t }); |
| 164 | } else if (quiet) { |
| 165 | out.held.push(key); |
| 166 | } else { |
| 167 | streak.alerted = true; |
| 168 | out.draft.push(t); |
| 169 | } |
| 170 | } |
| 171 | out.streaks.push(streak); |
| 172 | } |
| 173 | return out; |
| 174 | } |
| 175 | |
| 176 | // --- Deploys -------------------------------------------------------------------------- |
| 177 | |
| 178 | /** |
| 179 | * A deploy as the deploy tool reported it. Deploys that overlap (the jobs |
| 180 | * of one stage, in parallel) are one window: `running` counts the starts |
| 181 | * not yet finished, and it is finished when the last one is. |
| 182 | */ |
| 183 | export type DeployWindow = { |
| 184 | id: string | null; |
| 185 | started_at: string; |
| 186 | finished_at: string | null; |
| 187 | /** Starts not yet finished. */ |
| 188 | running: number; |
| 189 | /** The latest start: a window not finished stops counting DEPLOY_MAX_MS after it. */ |
| 190 | last_started_at: string; |
| 191 | }; |
| 192 | |
| 193 | /** Whether detection holds off at `at`: during a deploy, and for a grace period after. */ |
| 194 | export function deployQuiet(window: DeployWindow | null, at: Date): boolean { |
| 195 | if (!window) return false; |
| 196 | const start = Date.parse(window.started_at); |
| 197 | const t = at.getTime(); |
| 198 | if (Number.isNaN(start) || t < start - 60_000) return false; |
| 199 | if (window.finished_at) return t < Date.parse(window.finished_at) + DEPLOY_GRACE_MS; |
| 200 | const last = Date.parse(window.last_started_at); |
| 201 | return t < (Number.isNaN(last) ? start : Math.max(start, last)) + DEPLOY_MAX_MS; |
| 202 | } |
| 203 | |
| 204 | /** When detection stops holding off for this window. */ |
| 205 | export function quietUntil(window: DeployWindow): string { |
| 206 | const end = window.finished_at |
| 207 | ? Date.parse(window.finished_at) + DEPLOY_GRACE_MS |
| 208 | : Math.max(Date.parse(window.started_at), Date.parse(window.last_started_at)) + DEPLOY_MAX_MS; |
| 209 | return new Date(end).toISOString(); |
| 210 | } |
| 211 | |
| 212 | /** The deploy tool's start or finish, folded into what is kept. */ |
| 213 | export function deployChange(window: DeployWindow | null, phase: "started" | "finished", id: string | null, at: Date): DeployWindow { |
| 214 | const now = at.toISOString(); |
| 215 | // Still quiet: running, or in its grace period. A start then joins the window, keeping its start. |
| 216 | const live = window && deployQuiet(window, at) ? window : null; |
| 217 | const running = live && !live.finished_at ? Math.max(1, live.running) : 0; |
| 218 | if (phase === "started") { |
| 219 | return { id: id ?? live?.id ?? null, started_at: live ? live.started_at : now, finished_at: null, running: running + 1, last_started_at: now }; |
| 220 | } |
| 221 | if (running > 1) return { ...live!, id: live!.id ?? id, running: running - 1 }; |
| 222 | // The last one running finished; a finish with no start is a window of its own. |
| 223 | return { |
| 224 | id: id ?? window?.id ?? null, |
| 225 | started_at: running ? live!.started_at : now, |
| 226 | finished_at: now, |
| 227 | running: 0, |
| 228 | last_started_at: running ? live!.last_started_at : now, |
| 229 | }; |
| 230 | } |
| 231 | |
| 232 | // --- Detected drafts that recover, and drafts left waiting --------------------------------- |
| 233 | |
| 234 | /** A detected draft no one has picked up yet, since when its parts have been healthy, and when it was last raised again. */ |
| 235 | export type WatchedDraft = { |
| 236 | id: string; |
| 237 | title: string; |
| 238 | components: string[]; |
| 239 | started_at: string; |
| 240 | /** When the draft was made: STALE_AFTER_MS counts from here. */ |
| 241 | declared_at: string; |
| 242 | healthy_since: string | null; |
| 243 | /** When the alert last went out again for it; null before the first time. */ |
| 244 | reminded_at: string | null; |
| 245 | }; |
| 246 | |
| 247 | export type Settled = { |
| 248 | /** Every watched draft's healthy-since after this round: null while a part is in trouble. */ |
| 249 | healthy: { id: string; since: string | null }[]; |
| 250 | /** Drafts healthy long enough to dismiss. */ |
| 251 | dismiss: { id: string; title: string; recovered_at: string; lasted_ms: number }[]; |
| 252 | }; |
| 253 | |
| 254 | /** |
| 255 | * Which detected drafts have recovered for good. `troubled` is every part |
| 256 | * whose latest check was failed or slow, in a run still going (detect.ts |
| 257 | * `troubledNow`). |
| 258 | */ |
| 259 | export function settleDrafts(drafts: WatchedDraft[], troubled: Set<string>, at: Date, after = RECOVERED_FOR_MS): Settled { |
| 260 | const out: Settled = { healthy: [], dismiss: [] }; |
| 261 | for (const d of drafts) { |
| 262 | if (d.components.some((k) => troubled.has(k))) { |
| 263 | out.healthy.push({ id: d.id, since: null }); |
| 264 | continue; |
| 265 | } |
| 266 | const since = d.healthy_since ?? at.toISOString(); |
| 267 | if (at.getTime() - Date.parse(since) >= after) { |
| 268 | out.dismiss.push({ id: d.id, title: d.title, recovered_at: since, lasted_ms: Math.max(0, Date.parse(since) - Date.parse(d.started_at)) }); |
| 269 | } else { |
| 270 | out.healthy.push({ id: d.id, since }); |
| 271 | } |
| 272 | } |
| 273 | return out; |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * Detected drafts to raise again: unacknowledged for STALE_AFTER_MS since |
| 278 | * they were made, the first time; then every STALE_EVERY_MS after the last |
| 279 | * time. `drafts` are the ones still waiting after this round's dismissals. |
| 280 | */ |
| 281 | export function staleDrafts( |
| 282 | drafts: Pick<WatchedDraft, "id" | "title" | "declared_at" | "reminded_at">[], |
| 283 | at: Date, |
| 284 | { after = STALE_AFTER_MS, every = STALE_EVERY_MS } = {}, |
| 285 | ): { id: string; title: string; waiting_ms: number }[] { |
| 286 | const t = at.getTime(); |
| 287 | return drafts |
| 288 | .filter((d) => (d.reminded_at ? t - Date.parse(d.reminded_at) >= every : t - Date.parse(d.declared_at) >= after)) |
| 289 | .map((d) => ({ id: d.id, title: d.title, waiting_ms: Math.max(0, t - Date.parse(d.declared_at)) })); |
| 290 | } |
| 291 | |
| 292 | // --- Wording -------------------------------------------------------------------------------- |
| 293 | |
| 294 | /** What a detected failure does to its part, for the draft. */ |
| 295 | export function detectedImpact(state: "degraded" | "down"): ComponentImpact { |
| 296 | return state === "down" ? "major_outage" : "degraded"; |
| 297 | } |
| 298 | |
| 299 | /** The draft's title: "Detected: API and Git not answering". */ |
| 300 | export function draftTitle(parts: { name: string; state: "degraded" | "down" }[]): string { |
| 301 | const list = (names: string[]) => (names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); |
| 302 | const down = parts.filter((p) => p.state === "down").map((p) => p.name); |
| 303 | const slow = parts.filter((p) => p.state === "degraded").map((p) => p.name); |
| 304 | const said = [down.length ? `${list(down)} not answering` : "", slow.length ? `${list(slow)} slow` : ""].filter(Boolean).join("; "); |
| 305 | return `Detected: ${said}`.slice(0, 120); |
| 306 | } |
| 307 | |
| 308 | /** A part's slow line in words: "1.5 s", "800 ms". */ |
| 309 | export function limitWords(ms: number): string { |
| 310 | return ms >= 1000 ? `${Number((ms / 1000).toFixed(1))} s` : `${ms} ms`; |
| 311 | } |
| 312 | |
| 313 | /** "3 minutes", "1 minute", "2h 05m". */ |
| 314 | export function minutesWords(ms: number): string { |
| 315 | const minutes = Math.max(1, Math.round(ms / 60_000)); |
| 316 | if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`; |
| 317 | return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`; |
| 318 | } |
| 319 | |
| 320 | /** "4 checks in a row", or "4 of 5 checks" when good ones came between. */ |
| 321 | function checksWords(t: { checks: number; of?: number }): string { |
| 322 | const of = t.of ?? t.checks; |
| 323 | return of > t.checks ? `${t.checks} of ${of} checks` : `${t.checks} check${t.checks === 1 ? "" : "s"} in a row`; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * One part's trouble in a sentence, slow and down said apart: |
| 328 | * "Git has been slow — over 1.5 s — on 4 checks in a row since 6 Oct 07:25 UTC." |
| 329 | * "API has not answered on 4 of 5 checks since 6 Oct 07:25 UTC." |
| 330 | * `since` is already written out, in whatever zone the reader needs. |
| 331 | */ |
| 332 | export function troubleSentence(name: string, t: { state: "degraded" | "down"; checks: number; of?: number }, since: string, slowMs: number): string { |
| 333 | const checks = checksWords(t); |
| 334 | return t.state === "down" |
| 335 | ? `${name} has not answered on ${checks} since ${since}.` |
| 336 | : `${name} has been slow — over ${limitWords(slowMs)} — on ${checks} since ${since}.`; |
| 337 | } |
| 338 | |
| 339 | /** A part answering again, after a run that crossed the line. */ |
| 340 | export function recoverySentence(name: string, t: { state: "degraded" | "down"; checks: number; of?: number }, since: string): string { |
| 341 | const checks = checksWords(t); |
| 342 | return t.state === "down" |
| 343 | ? `${name} is answering again, after not answering on ${checks} since ${since}.` |
| 344 | : `${name} is back to normal speed, after being slow on ${checks} since ${since}.`; |
| 345 | } |
| 346 | |
| 347 | /** The timeline line, and the follow-up email's words, when a recovered draft is dismissed on its own. */ |
| 348 | export function autoDismissText(lastedMs: number, recoveredAt: string, healthyFor = RECOVERED_FOR_MS): string { |
| 349 | return `Recovered after ${minutesWords(lastedMs)}, at ${recoveredAt}, and stayed healthy for ${minutesWords(healthyFor)}; dismissed automatically. It never appeared on the status page.`; |
| 350 | } |
| 351 | |
| 352 | /** The timeline line when a draft left waiting is raised again. */ |
| 353 | export function staleText(waitingMs: number, emailed: boolean): string { |
| 354 | return `Unacknowledged for ${minutesWords(waitingMs)}.${emailed ? " The alert address was emailed again." : ""}`; |
| 355 | } |