watchdog: sagen WARUM, nebeneinander pruefen, Gespenster aufraeumen
DER GRUND STEHT JETZT DABEI. Bisher landete im Ausfall-Embed nur "HTTP 503".
Das sagt, DASS etwas ist, nicht WAS -- und wer nachts eine DM bekommt, will
genau das wissen, bevor er sich einloggt.
Viele Dienste sagen es naemlich selbst. d4rk_media antwortet auf /status mit
einer 503 UND einer Begruendung, und die geht jetzt mit in die Meldung UND in
die Stoerung (startIncident nimmt einen Grund entgegen -- damit steht er in der
Historie der oeffentlichen Statusseite und nicht nur einmalig in einer DM):
vorher HTTP 503
jetzt HTTP 503 — sicherung: letzte vor 40 Stunden
Gemessen am laufenden Dienst, nicht ausgedacht.
Gelesen wird nur bei content-type JSON und hoechstens 64 KB; der Satz wird bei
240 Zeichen gekappt. Erkannt werden erst unsere eigene Form (eine Liste von
Pruefungen, die durchgefallenen werden genannt), dann die ueblichen Felder
error/message/reason/grund/detail, zuletzt ein blosser Zustand wie "degraded".
Wer nichts davon liefert, bekommt weiterhin "HTTP nnn" -- kein Fehler, nur
keine Zusatzauskunft. Zehn Faelle durchgeprueft, darunter kaputtes JSON, kein
Objekt und null.
DAS URTEIL BLEIBT DER STATUSCODE. Ein Dienst, der 200 mit "ok": false
antwortet, gilt weiterhin als erreichbar -- alles andere waere eine stille
Verhaltensaenderung fuer jede andere ueberwachte Adresse.
Nebenbei bekommt der Rumpf damit endlich eine Behandlung: er wird gelesen oder
verworfen. Ein fetch, dessen Antwort niemand anfasst, haelt die Verbindung, bis
der Aufraeumer sie holt.
NEBENEINANDER STATT NACHEINANDER. Die Schleife lief der Reihe nach, und jede
Adresse durfte bis zum Zeitlimit brauchen: bei fuenf Diensten und zehn Sekunden
Grenze konnte ein Durchlauf fast eine Minute dauern. Bei zwei Minuten Intervall
ist das knapp, bei zehn Diensten ueberholt sich der Waechter selbst. Geprueft
wird jetzt gleichzeitig, AUSGEWERTET weiter der Reihe nach -- die DMs sollen in
nachvollziehbarer Ordnung ankommen und nicht in der, in der zufaellig
geantwortet wurde.
GESPENSTER AUFRAEUMEN. `state` wurde nie aufgeraeumt: ein aus der Dienst-Tabelle
geloeschter Eintrag blieb bis zum Neustart im Speicher und damit in der
Uebersicht im Panel stehen. Was nicht mehr ueberwacht wird, fliegt jetzt am Ende
jedes Durchlaufs raus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+157
-23
@@ -1,6 +1,6 @@
|
||||
// Watchdog: prüft konfigurierte URLs, meldet Ausfälle per DM und schreibt
|
||||
// jeden Check mit — daraus baut die Übersicht im Panel ihren Uptime-Balken.
|
||||
// URLs kommen aus dem Setting watchdog_urls (Config-Seite), leer = deaktiviert.
|
||||
// Was geprüft wird, steht in der Dienst-Tabelle (Setup → Dienste).
|
||||
import {
|
||||
listMonitored, recordWatchdogSample, pruneWatchdogHistory,
|
||||
startIncident, endIncident, pruneIncidents,
|
||||
@@ -30,31 +30,154 @@ async function dmAdmin(client, embed) {
|
||||
await user?.send({ embeds: [embed] }).catch(() => {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Der Grund
|
||||
//
|
||||
// Bis hierhin stand im Ausfall-Embed nur "HTTP 503". Das sagt, DASS etwas ist,
|
||||
// nicht WAS — und wer nachts eine DM bekommt, will genau das wissen, bevor er
|
||||
// sich einloggt.
|
||||
//
|
||||
// Viele Dienste sagen es nämlich selbst. d4rk_media zum Beispiel antwortet auf
|
||||
// /status mit einer 503 UND einer Begründung:
|
||||
//
|
||||
// { "stand": "gestoert", "pruefungen": [
|
||||
// { "was": "bestand", "ok": false,
|
||||
// "hinweis": "kein Eintrag in der Datenbank, aber Dateien auf der Platte" } ] }
|
||||
//
|
||||
// Dieser Satz ist der Unterschied zwischen "irgendwas ist kaputt" und "die
|
||||
// Datenbank ist leer, die Dateien liegen noch da". Er kostet uns nichts: den
|
||||
// Rumpf müssen wir ohnehin lesen oder verwerfen, sonst hängt die Verbindung.
|
||||
//
|
||||
// DAS URTEIL BLEIBT DER STATUSCODE. Ein Dienst, der 200 mit "ok": false
|
||||
// antwortet, gilt weiterhin als erreichbar — alles andere wäre eine stille
|
||||
// Verhaltensänderung für jede andere überwachte Adresse. Wer das anders will,
|
||||
// lässt seinen Dienst eine 5xx schicken; d4rk_media kann das über ?streng=1.
|
||||
|
||||
/** Wie viel vom Rumpf wir überhaupt anfassen. Ein überwachter Dienst könnte
|
||||
* ein Megabyte zurückgeben; für einen Satz Begründung reicht das hier. */
|
||||
const GRUND_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** Höchstlänge des Satzes im Embed. Discord nimmt 1024 je Feld — aber eine
|
||||
* Begründung, die man scrollen muss, ist keine. */
|
||||
const GRUND_MAX_ZEICHEN = 240;
|
||||
|
||||
/** Aus einer JSON-Antwort einen Satz machen, oder null.
|
||||
*
|
||||
* Erst die Form, die unsere eigenen Dienste sprechen (eine Liste von
|
||||
* Prüfungen), dann die üblichen Einzelfelder. Wer keins davon liefert,
|
||||
* bekommt weiterhin nur "HTTP 503" — das ist kein Fehler, nur keine
|
||||
* Zusatzauskunft. */
|
||||
function grundAus(daten) {
|
||||
if (!daten || typeof daten !== 'object') return null;
|
||||
|
||||
// Unsere Form: was ist konkret durchgefallen?
|
||||
if (Array.isArray(daten.pruefungen)) {
|
||||
const schlecht = daten.pruefungen
|
||||
.filter((p) => p && p.ok === false)
|
||||
.map((p) => (p.hinweis ? `${p.was}: ${p.hinweis}` : String(p.was)))
|
||||
.filter(Boolean);
|
||||
if (schlecht.length) return schlecht.join(' · ');
|
||||
}
|
||||
|
||||
// Die üblichen Verdächtigen, in der Reihenfolge ihrer Aussagekraft.
|
||||
for (const feld of ['grund', 'reason', 'error', 'message', 'detail', 'fehler']) {
|
||||
const wert = daten[feld];
|
||||
if (typeof wert === 'string' && wert.trim()) return wert.trim();
|
||||
}
|
||||
|
||||
// Als Letztes ein bloßer Zustand ("degraded", "gestoert") — besser als
|
||||
// nichts, aber nur, wenn er nicht ohnehin "ok" sagt.
|
||||
for (const feld of ['stand', 'status', 'state']) {
|
||||
const wert = daten[feld];
|
||||
if (typeof wert === 'string' && !/^(ok|up|gesund|healthy)$/i.test(wert.trim())) {
|
||||
return wert.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Den Rumpf lesen, wenn er klein genug und JSON ist. Gibt immer etwas zurück
|
||||
* (notfalls null) und wirft nie — eine Begründung ist ein Zusatz, kein Grund,
|
||||
* eine Prüfung scheitern zu lassen.
|
||||
*
|
||||
* Der Rumpf wird IN JEDEM FALL verbraucht oder verworfen. Ein `fetch`, dessen
|
||||
* Antwort niemand anfasst, hält die Verbindung offen, bis der Aufräumer sie
|
||||
* holt — bei einer Prüfung alle paar Minuten fällt das nicht auf, aber es ist
|
||||
* schlicht der falsche Umgang. */
|
||||
async function grundLesen(res) {
|
||||
try {
|
||||
const typ = res.headers.get('content-type') ?? '';
|
||||
const laenge = Number(res.headers.get('content-length') ?? NaN);
|
||||
|
||||
if (!/\bjson\b/i.test(typ) || (Number.isFinite(laenge) && laenge > GRUND_MAX_BYTES)) {
|
||||
await res.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (text.length > GRUND_MAX_BYTES) return null;
|
||||
|
||||
const satz = grundAus(JSON.parse(text));
|
||||
if (!satz) return null;
|
||||
return satz.length > GRUND_MAX_ZEICHEN
|
||||
? `${satz.slice(0, GRUND_MAX_ZEICHEN - 1)}…`
|
||||
: satz;
|
||||
} catch {
|
||||
// Kaputtes JSON, abgebrochene Verbindung, was auch immer: dann eben
|
||||
// ohne Begründung.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Eine Prüfung
|
||||
|
||||
/** Eine einzelne Adresse prüfen. Wirft nie. */
|
||||
async function pruefe(dienst) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(dienst.url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')),
|
||||
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
|
||||
});
|
||||
const grund = await grundLesen(res);
|
||||
return {
|
||||
ok: res.status < 500,
|
||||
status: res.status,
|
||||
detail: grund ? `HTTP ${res.status} — ${grund}` : `HTTP ${res.status}`,
|
||||
dauer: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
detail: error.cause?.code ?? error.name ?? 'Fehler',
|
||||
dauer: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Prüfdurchlauf — exportiert für Tests und den Intervall-Timer */
|
||||
export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog_fails') } = {}) {
|
||||
if (!moduleEnabled('watchdog')) return;
|
||||
for (const dienst of watchedServices()) {
|
||||
const { url } = dienst;
|
||||
const s = state.get(url) ?? { fails: 0, down: false, since: null };
|
||||
const dienste = watchedServices();
|
||||
|
||||
let ok = false;
|
||||
let detail = '';
|
||||
let status = null;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(tuningMs.seconds('watchdog_timeout')),
|
||||
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
|
||||
});
|
||||
ok = res.status < 500;
|
||||
status = res.status;
|
||||
detail = `HTTP ${res.status}`;
|
||||
} catch (error) {
|
||||
detail = error.cause?.code ?? error.name ?? 'Fehler';
|
||||
}
|
||||
const dauer = Date.now() - start;
|
||||
// NEBENEINANDER statt nacheinander. Vorher lief die Schleife der Reihe
|
||||
// nach, und jede Adresse durfte bis zum Zeitlimit brauchen — bei fünf
|
||||
// Diensten und zehn Sekunden Grenze konnte ein Durchlauf also fast eine
|
||||
// Minute dauern. Steht das Prüfintervall auf zwei Minuten, ist das schon
|
||||
// knapp; bei zehn Diensten überholt sich der Wächter selbst.
|
||||
//
|
||||
// Die Antworten kommen dadurch gleichzeitig, das Auswerten bleibt der
|
||||
// Reihe nach: die DMs sollen in einer nachvollziehbaren Ordnung ankommen
|
||||
// und nicht in der, in der zufällig geantwortet wurde.
|
||||
const ergebnisse = await Promise.all(dienste.map(pruefe));
|
||||
|
||||
for (const [i, dienst] of dienste.entries()) {
|
||||
const { url } = dienst;
|
||||
const { ok, status, detail, dauer } = ergebnisse[i];
|
||||
const s = state.get(url) ?? { fails: 0, down: false, since: null };
|
||||
|
||||
// Immer mitschreiben, nicht nur bei Wechseln — der Balken braucht
|
||||
// durchgehende Messpunkte, sonst sähen ruhige Stunden aus wie Lücken.
|
||||
@@ -83,12 +206,15 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog
|
||||
} else {
|
||||
const fails = s.fails + 1;
|
||||
if (!s.down && fails >= failsBeforeAlert) {
|
||||
// Der Grund geht MIT in die Störung — listIncidents gibt ihn
|
||||
// zurück, und damit steht er in der Historie der öffentlichen
|
||||
// Statusseite und nicht nur einmalig in einer DM.
|
||||
startIncident(url, detail);
|
||||
await dmAdmin(client, brandEmbed(client, 'WATCHDOG', { secondary: true })
|
||||
.setTitle('🚨 Nicht erreichbar')
|
||||
.setDescription(`**${dienst.name}**\n${url}`)
|
||||
.addFields(
|
||||
{ name: 'Fehler', value: detail, inline: true },
|
||||
{ name: 'Fehler', value: detail, inline: false },
|
||||
{ name: 'Versuche', value: `${fails} in Folge`, inline: true },
|
||||
));
|
||||
console.warn(`[watchdog] ${dienst.name} down (${detail})`);
|
||||
@@ -99,6 +225,14 @@ export async function watchdogTick(client, { failsBeforeAlert = tuning('watchdog
|
||||
}
|
||||
}
|
||||
|
||||
// Was nicht mehr überwacht wird, gehört auch nicht mehr in den Zustand.
|
||||
// Ohne das zeigt die Übersicht im Panel Dienste an, die längst gelöscht
|
||||
// sind — und der Eintrag bliebe bis zum Neustart des Bots stehen.
|
||||
const aktuell = new Set(dienste.map((d) => d.url));
|
||||
for (const url of state.keys()) {
|
||||
if (!aktuell.has(url)) state.delete(url);
|
||||
}
|
||||
|
||||
// Alte Messpunkte und abgeschlossene Störungen wegräumen (90 Tage)
|
||||
try {
|
||||
pruneWatchdogHistory();
|
||||
|
||||
Reference in New Issue
Block a user