OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway / lib / watch.js

71 lines · javascript ref:
// The watcher — checks the gateway's health on its own and keeps the alert state.
//
// ⛔ WHY IT RUNS ON A TIMER when the probes otherwise run only on demand: a manual fix nobody hears about
// is the outage (loser-club was invisible to bsky for 2½ months, and the relay had us "offline" with
// nothing reporting it). So: one check 2 minutes after boot (an OTA restart is when the relay drops us),
// then every 15 minutes — the relay guard's own cadence — each with FRESH probes. The gateway's RSS is
// one of the alerts, so if repeated probing leaked (as favela-web's HTTPS did) it would say so.
//
// State lives in a JSON file beside the gateway's socket, so an alert's "open since" survives a restart.

import fs from 'bare-fs'
import path from 'bare-path'
import { evaluateAlerts } from './alerts.js'

const RESOLVED_KEEP = 20

export function createWatcher ({ metrics, stateFile = null, firstMs = 2 * 60 * 1000, intervalMs = 15 * 60 * 1000, now = () => Date.now(), log = () => {} } = {}) {
  let saved = { state: {}, lastCheckAt: null, recentlyResolved: [] }
  if (stateFile) {
    try { saved = { ...saved, ...JSON.parse(fs.readFileSync(stateFile, 'utf8')) } } catch {}
  }
  let open = Object.values(saved.state).filter((e) => e.open).map((e) => ({ id: e.id, severity: e.severity, title: e.title, detail: e.detail, fix: e.fix, since: e.firstSeen, lastSeen: e.lastSeen }))
  let first = null
  let timer = null
  let running = null

  function persist () {
    if (!stateFile) return
    try {
      fs.mkdirSync(path.dirname(stateFile), { recursive: true })
      fs.writeFileSync(stateFile + '.tmp', JSON.stringify(saved))
      fs.renameSync(stateFile + '.tmp', stateFile)
    } catch (err) { log({ msg: 'alerts: could not persist state', error: err.message }) }
  }

  async function check () {
    let m
    try { m = await metrics({ refresh: true }) } catch (err) {
      log({ msg: 'alerts: metrics failed — state unchanged', error: (err && err.message) || String(err) })
      return
    }
    const t = now()
    const r = evaluateAlerts(m, saved.state, t)
    for (const a of r.open) if (!open.some((o) => o.id === a.id)) log({ msg: 'ALERT open', id: a.id, severity: a.severity, title: a.title, fix: a.fix })
    for (const a of r.resolved) log({ msg: 'alert resolved', id: a.id, openFor: Math.round((t - a.since) / 1000) + 's' })
    open = r.open
    saved = { state: r.state, lastCheckAt: t, recentlyResolved: [...r.resolved.reverse(), ...saved.recentlyResolved].slice(0, RESOLVED_KEEP) }
    persist()
  }

  // One check at a time; a request during a check waits for it.
  function checkNow () {
    if (!running) running = check().finally(() => { running = null })
    return running
  }

  return {
    checkNow,
    current: () => ({ open, recentlyResolved: saved.recentlyResolved, lastCheckAt: saved.lastCheckAt }),
    start () {
      first = setTimeout(() => { checkNow() }, firstMs)
      timer = setInterval(() => { checkNow() }, intervalMs)
      if (first.unref) first.unref()
      if (timer.unref) timer.unref()
    },
    stop () { clearTimeout(first); clearInterval(timer) }
  }
}

export default createWatcher